diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d981f7ac0..5102b8b87 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -205,7 +205,170 @@ jobs: run: | python -m pytest -q \ MonteCarloMarginalizeCode/Code/test/expensive_before_merging/integrators/test_probe_confirm.py \ - MonteCarloMarginalizeCode/Code/test/test_cip_priors.py + MonteCarloMarginalizeCode/Code/test/test_cip_priors.py \ + MonteCarloMarginalizeCode/Code/test/test_cosmology_single_source.py + + q-window-stencil-check: + needs: install + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.10' + cache: 'pip' + cache-dependency-path: requirements.txt + - name: Enable symlink + run: sudo ln -sf $(which python3) /usr/bin/python + - name: Install dependencies + run: | + python -m pip install --upgrade pip --break-system-packages + python -m pip install -r requirements.txt --break-system-packages + python -m pip install coverage pytest --break-system-packages + python -m pip install --editable . --break-system-packages + - name: Run Q_lm sub-sample stencil accuracy and selection gates + # numpy-only, runs in seconds, but it guards a CORE LIKELIHOOD choice that fails + # SILENTLY: picking the wrong sub-sample stencil raises nothing, it just makes Q_lm(t) + # less accurate, which surfaces only as a slightly wrong likelihood surface. + # + # test_q_window_interp asserts the cubic/sinc crossover in BOTH directions on purpose. + # sinc winning everywhere would mean the Lanczos window had been widened until it was no + # longer a local stencil, so neither direction may be relaxed to make a change pass. + # test_time_interp_choice pins the pipeline thresholds inside the measured ambiguous + # band, and checks the decision uses the sampling rate the run is actually on. + # + # test_calmarg_stencil_gating runs its CPU arms without a GPU (its GPU arm is additive), + # so it belongs here: it is what stops cubic/sinc being routed to the fused calibration + # kernel, which is implemented for 'nearest' only. + # + # test_interpolate_time_cli runs the three scripts as real SUBPROCESSES (~30 s). That + # cost is the point: the unit tests exercise the resolver and the gate predicate, but + # neither can see whether the SCRIPTS are still wired to them. Reverting a parser to + # const=None, or deleting a script's resolver call, leaves every unit test green while + # restoring a bare flag that silently does nothing. All three mutations were checked to + # fail these tests before they landed. + # + # The GPU parity files (test_q_window_interp_gpu, test_noloop_gpu_stencils) are + # deliberately NOT here -- there is no GPU on these runners, and they would report as + # skipped. They are run by hand on a GPU node; the numbers are in PR #97. + # + # test_slowrot_* files do NOT belong here. They are gated by slowrot-check, whose + # manifest requires every test_slowrot_*.py in this directory to be listed or + # explicitly excluded; a copy in this job's list is invisible to that manifest and + # would simply run the file twice (issue #169). + run: | + python -m pytest -q \ + MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_q_window_interp.py \ + MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_time_interp_choice.py \ + MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_calmarg_stencil_gating.py \ + MonteCarloMarginalizeCode/Code/RIFT/misc/test_psd_bandwidth.py \ + MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_interpolate_time_cli.py + + slowrot-check: + needs: install + runs-on: ubuntu-latest + # RIFT/likelihood/test_slowrot_*.py was run by NOTHING in this workflow until this + # job landed: `grep -rn slowrot .github/workflows/ci.yml` returned a single hit and + # it was a comment (issue #169). That gap is worse than an ordinary one, because + # the two most recent changes to this code are changes whose DELIVERABLE IS THE + # GUARD -- #163 (the Nyquist derivative weight at both parities) and #165 (the + # Hermitian Nyquist response weight, which provably moves no number). Neither + # leaves anything behind if its guard never runs. + # + # See .travis/test-slowrot.sh for why the gate counts tests and runs three files + # outside pytest: five test_slowrot_*.py files collect ZERO items and exit 5, "no + # tests ran", which reads as a pass, and three of those five carry asserts pytest + # cannot count. + # + # SEPARATE FROM jax-ile-check ON PURPOSE. This suite is numpy + lal: no GPU, no + # jax, no numpyro. Folding it into the jax gate would put a 1-2 minute numpy + # regression behind that job's jax/numpyro install and its ~5-15 minute run, and + # would couple a numpy failure's diagnosis to an unpinned upstream jax release. + # Python 3.10 to match the sibling numpy jobs, NOT the 3.11 jax-ile-check needs. + # + # timeout-minutes is a runaway backstop, not a budget: it is more than an order of + # magnitude above the observed job wall. Measured cost, and the host-by-host numbers + # behind it, are in PR #172 (2026-08) -- deliberately not restated here, because a + # wall-clock table in a comment rots silently and nothing imports it. + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.10' + cache: 'pip' + cache-dependency-path: requirements.txt + - name: Enable symlink + run: sudo ln -sf $(which python3) /usr/bin/python + - name: Install dependencies + run: | + python -m pip install --upgrade pip --break-system-packages + python -m pip install -r requirements.txt --break-system-packages + python -m pip install coverage pytest --break-system-packages + python -m pip install --editable . --break-system-packages + - name: Run slow-rotation / finite-size CPU regression gate + env: + OMP_NUM_THREADS: 1 + run: bash .travis/test-slowrot.sh + + jax-ile-check: + needs: install + runs-on: ubuntu-latest + # test/jax/ was run by NOTHING in this workflow until this job landed (the file had + # zero matches for "jax"), and two real defects survived a month each behind that + # gap. See .travis/test-jax.sh for why the gate counts tests instead of just + # invoking pytest: most files in test/jax/ are __main__ scripts, and pytest exits 5 + # ("no tests ran") on those -- a green tick over an empty run. + # + # Python 3.11 rather than the 3.10 used by the sibling jobs: current jax wheels + # require >=3.11. jax/numpyro are installed UNPINNED and deliberately so (CI should + # see what a user gets). NOTE: RIFT.likelihood.jax_ile declares its dependencies + # NOWHERE in setup.py -- extras_require['jax-apps'] is for RIFT.interpolators.jax_gp + # and lists a different set -- so this job is their de-facto declaration. An + # extras_require['jax-ile'] would be the right home for them + # and means a breaking upstream jax release can redden this job outside any PR's + # control. If that becomes noisy, pin here rather than deleting the job. + # + # Cost. CURRENT (EXPECTED_TESTS=27 in .travis/test-jax.sh): 27 tests, measured + # 578-834 s of pytest across repeat runs on quiet CPU nodes (ldas-pcdev11/13, jax + # 0.9.2, JAX_PLATFORMS=cpu, JAX_ENABLE_X64=1, OMP_NUM_THREADS=1). + # test_jax_slowrot.py dominates (the p_max=0/p_max=1 rotation ladders and + # freqresponse, each followed by the AD/jit/vmap/hessian checks); it is the first + # thing to trim if CI minutes ever bite. timeout-minutes is generous so a slower + # runner does not flake, but a hang still ends. + # + # HISTORICAL, and left here only as a runner-vs-local calibration: when this job + # collected 14 tests it measured 964 s wall locally and ran 14 passed in 285.96 s on + # the actual runner (job 95750869285, python 3.11.15, jax 0.10.2, numpyro 0.21.0) -- + # ~10x headroom, with the unpinned install already a jax minor version ahead of the + # 0.9.2 measured locally. Do NOT read the 14 as a current count; the file list has + # grown since and the gate asserts 27. + timeout-minutes: 60 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + cache: 'pip' + cache-dependency-path: requirements.txt + - name: Enable symlink + run: sudo ln -sf $(which python3) /usr/bin/python + - name: Install dependencies and the CPU JAX stack + # ONE pip invocation on purpose: pip does not co-resolve across invocations, so + # installing jax separately lets a jax-driven numpy bump past numba's ceiling land + # as a dependency-conflict WARNING with exit 0, and surface later as a collection + # error (factored_likelihood imports numba) that looks like a RIFT bug. + # numpyro is needed by test_nuts_phimarg (the NUTS phase-marginalized sampler). + # flowMC is deliberately NOT installed -- see test-jax.sh for the exclusions. + run: | + python -m pip install --upgrade pip --break-system-packages + python -m pip install -r requirements.txt coverage pytest "jax[cpu]" numpyro --break-system-packages + python -m pip install --editable . --break-system-packages + - name: Run jax_ile CPU regression gate + env: + JAX_PLATFORMS: cpu + OMP_NUM_THREADS: 1 + run: bash .travis/test-jax.sh lisa-check: needs: install @@ -279,6 +442,8 @@ jobs: include: - asimov-series: '0.5' asimov-spec: 'asimov>=0.5,<0.6' + - asimov-series: '0.7' + asimov-spec: 'asimov>=0.7,<0.8' steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 @@ -299,6 +464,9 @@ jobs: python -m pip install htcondor --only-binary=:all: --break-system-packages - name: Install Asimov run: python -m pip install '${{ matrix.asimov-spec }}' 'asimov-gwdata>=0.4,<0.5' --break-system-packages + - name: Install Asimov 0.7 pipeline plugins + if: matrix.asimov-series == '0.7' + run: python -m pip install 'asimov-bayeswave>=0.2,<0.3' 'pe-configurator>=1,<2' --break-system-packages - name: Run Asimov integration test run: bash .travis/test-asimov.sh @@ -370,7 +538,8 @@ jobs: MonteCarloMarginalizeCode/Code/test/test_l0_rescue_seed.py \ MonteCarloMarginalizeCode/Code/test/test_seq_warmstart_seed.py \ MonteCarloMarginalizeCode/Code/test/test_fairdraw_double_weighting.py \ - MonteCarloMarginalizeCode/Code/test/test_portfolio_fairdraw_backend.py + MonteCarloMarginalizeCode/Code/test/test_portfolio_fairdraw_backend.py \ + MonteCarloMarginalizeCode/Code/test/test_rvs_record.py - name: Audit _rvs consumers against the fair-draw rebind # sampler._rvs is rebound to an EXPORT resample at the end of integrate_log, and five # separate defects have come from a consumer reading it afterwards as though it were @@ -380,6 +549,15 @@ jobs: # whoever happens to edit the surrounding code. run: | python MonteCarloMarginalizeCode/Code/test/expensive_before_merging/integrators/audit_rvs_fairdraw.py --check + - name: Audit sampler backend contracts + # (DESIGN_rvs_naming.md) The backends are structurally different in ways + # nothing states -- _rvs['integrand'] holds lnL on three of them, linear L on two, and + # EITHER on mcsamplerEnsemble depending on a kwarg -- and a consumer that guesses wrong + # gets a plausible number rather than an error. This does not forbid the differences; + # it fails when one CHANGES without the recorded table changing with it, so the next + # developer meets a diff instead of a landmine. + run: | + python MonteCarloMarginalizeCode/Code/test/expensive_before_merging/integrators/audit_backend_contracts.py --check - name: Run test scripts run: | . .travis/test-coord.sh diff --git a/.travis/test-asimov.sh b/.travis/test-asimov.sh index 4a1fda9ef..9290105c8 100644 --- a/.travis/test-asimov.sh +++ b/.travis/test-asimov.sh @@ -1,10 +1,8 @@ #! /bin/bash set -euo pipefail -# The RIFT Asimov plugin is currently developed and validated against the -# Asimov 0.5 series. This test skips cleanly for unsupported/future series -# from inside pytest, so developers can preflight 0.6/0.7 environments without -# editing the test. +# The RIFT Asimov plugin is validated against the legacy 0.5 series and the +# plugin-based 0.7 series. Unsupported API series skip cleanly in pytest. # Bootstrap-source selection ("scheduler: bootstrap file:") is driven against a stub # production rather than a project on disk, so it lives outside asimov_integration/. # It still needs asimov importable, and this is the only lane that installs it, so run @@ -12,4 +10,5 @@ set -euo pipefail # required checks stay green. python -m pytest -q \ MonteCarloMarginalizeCode/Code/test/asimov_integration \ + MonteCarloMarginalizeCode/Code/test/test_asimov_compatibility.py \ MonteCarloMarginalizeCode/Code/test/test_asimov_bootstrap_source.py diff --git a/.travis/test-jax.sh b/.travis/test-jax.sh new file mode 100755 index 000000000..c84ea04c4 --- /dev/null +++ b/.travis/test-jax.sh @@ -0,0 +1,207 @@ +#!/usr/bin/env bash +# CPU regression gate for the JAX extrinsic likelihood (RIFT/likelihood/jax_ile), +# driven from test/jax/. +# +# WHY THIS SCRIPT EXISTS AT ALL, AND WHY IT COUNTS TESTS +# ----------------------------------------------------- +# Until this gate landed, NOTHING in .github/workflows/ci.yml ran test/jax/ -- the +# workflow had zero matches for "jax". Two real defects survived a month each behind +# that gap (see the PR that adds this file). +# +# The obvious repair -- point pytest at test/jax/ -- would have manufactured MORE +# confidence than it earned. Several files in that directory are scripts with an +# `if __name__ == "__main__":` block and NO `test_*` function. Pointing pytest at such +# a file collects ZERO items and exits 5, "no tests ran", which reads as a pass in a +# skim of the log. So this script does two things a bare pytest invocation does not: +# +# 1. It asserts a FLOOR on the number of collected tests before running anything. +# If a future refactor drops a `test_*` entry point, renames a file, or moves it, +# collection silently shrinks and this job goes RED instead of green-on-nothing. +# The floor is pinned to the exact count as of this commit; raise it when you add +# tests, and never lower it without saying why in the commit message. +# 2. It fails on ANY nonzero pytest exit, which includes exit 5. +# +# JAX_PLATFORMS=cpu is set: no GPU is required, and jax must not go hunting for one. +set -uo pipefail +# NOTE: deliberately no -e. Every command below has its rc handled explicitly so the +# failure messages stay specific; if you add a command, guard it yourself. + +# JAXDIR below is repo-relative, so anchor cwd rather than trusting the caller. +cd "$(dirname "$0")/.." || { echo "test-jax.sh: cannot cd to repo root" >&2; exit 1; } + +PYTHON_BIN="${RIFT_JAX_PYTHON:-${PYTHON:-python}}" +if ! command -v "${PYTHON_BIN}" >/dev/null 2>&1; then + PYTHON_BIN="$(command -v python3)" +fi + +# Guard the tool checks: a missing interpreter plus a redirected stderr is +# indistinguishable from a clean result. +"${PYTHON_BIN}" -c 'import pytest' || { echo "test-jax.sh: pytest unavailable" >&2; exit 1; } +"${PYTHON_BIN}" -c 'import jax, jaxlib; print("jax", jax.__version__)' \ + || { echo "test-jax.sh: jax unavailable" >&2; exit 1; } +"${PYTHON_BIN}" -c 'import numpyro; print("numpyro", numpyro.__version__)' \ + || { echo "test-jax.sh: numpyro unavailable (needed by test_nuts_phimarg)" >&2; exit 1; } + +export JAX_PLATFORMS="${JAX_PLATFORMS:-cpu}" +export OMP_NUM_THREADS="${OMP_NUM_THREADS:-1}" + +JAXDIR="MonteCarloMarginalizeCode/Code/test/jax" + +# Included files, with the count each contributes as of this commit: +# test_jax_likelihood.py 3 synthetic packed data: nearest-vs-NoLoop, AD +# vs finite differences, jit/vmap +# test_jax_endtoend.py 1 full precompute -> pack -> JAX vs the numpy +# NoLoop on a real injection (fixed by #144) +# test_jax_slowrot_coeffs.py 2 rotation + freqresponse response coefficients +# against their numpy references +# test_jax_slowrot_wrapper.py 1 the one-call build_*_data_from_precompute path +# test_jax_slowrot.py 3 rotation Path A (p_max=0), Path B (p_max=1) +# and freqresponse: NoLoop parity + AD/jit/ +# vmap/hessian +# test_jax_slowrot_cauchy_schwarz.py 2 the rotation lnL VALUE (bound + explicit +# time-domain model), Path A and Path B. +# Agreement with the NoLoop is necessary but +# not sufficient -- see that file's docstring +# test_network_coords.py 1 network-frame sky fold on a real injection +# test_nuts_phimarg.py 1 fisher_nuts_sample_phimarg vs an analytic 4-D +# target (needs numpyro; no lal) +# test_tvals_grid_convention.py 13 issue #146: the time-marginalization window +# grid the JAX wrapper and +# bin/integrate_likelihood_extrinsic_batchmode +# build, extracted BY AST FROM THE DRIVER +# SOURCES and compared by value at srate +# 1024/2048/4096/8192/16384. Needs no jax; it +# lives here because it pins the jax_ile +# wrapper against the production driver, and +# because 16384 is the rate test_jax_endtoend +# (4096) structurally cannot cover. +# +# DELIBERATELY EXCLUDED (measured on ldas-pcdev11, JAX_PLATFORMS=cpu, OMP_NUM_THREADS=1): +# +# test_nuts_phimarg_injection.py Not a pytest file at all: it runs the whole study at +# module scope and calls sys.exit() there. WITHOUT numpyro +# that surfaces as a fast COLLECTION ERROR; WITH numpyro -- +# which THIS JOB INSTALLS -- `--collect-only` actually +# EXECUTES the study and hangs (reproduced: no output after +# ~6 min). So re-adding it would burn to timeout-minutes, +# not fail fast. It +# is also long -- a full NUTS run on a real injection +# that has exceeded a 1800 s cap in hand testing. Too +# expensive for every PR; run it by hand. +# +# test_flow_reuse.py Collects 0 (pytest exit 5); passes as a script. +# Excluded on DEPENDENCY risk, not runtime: three flowMC +# runs, and flowMC is an extra heavy dependency with a +# fast-moving sampler API that this test tracks closely, +# so an unpinned flowMC release would redden the gate +# for reasons unrelated to RIFT. Reasonable to add +# later behind a PINNED flowMC. Run it by hand when +# touching samplers.flowmc_sample. +# +# demo_*.py, debug_*.py, Demos, debugging scripts and a figure generator, not +# benchmark_snr_sequence.py, assertions. None defines a test_* function and none +# make_3g_figdata.py is intended as a gate. +FILES=( + "${JAXDIR}/test_jax_likelihood.py" + "${JAXDIR}/test_jax_endtoend.py" + "${JAXDIR}/test_jax_slowrot_coeffs.py" + "${JAXDIR}/test_jax_slowrot_wrapper.py" + "${JAXDIR}/test_jax_slowrot.py" + "${JAXDIR}/test_jax_slowrot_cauchy_schwarz.py" + "${JAXDIR}/test_network_coords.py" + "${JAXDIR}/test_nuts_phimarg.py" + "${JAXDIR}/test_tvals_grid_convention.py" +) + +# EXCLUDED: files in JAXDIR matching test_*.py that are deliberately NOT gated. The +# manifest check below fails if a file is in neither FILES nor EXCLUDED, so adding a new +# test_*.py to test/jax/ forces a decision instead of being silently unrun -- which is +# this gate's own failure mode, one level up. +EXCLUDED=( + "${JAXDIR}/test_nuts_phimarg_injection.py" + "${JAXDIR}/test_flow_reuse.py" +) + +echo "== manifest check (every test_*.py is gated or explicitly excluded) ==" +manifest_rc=0 +for f in "${JAXDIR}"/test_*.py; do + known=0 + for g in "${FILES[@]}" "${EXCLUDED[@]}"; do + [ "${f}" = "${g}" ] && { known=1; break; } + done + if [ "${known}" -eq 0 ]; then + echo "test-jax.sh: ${f} is neither gated nor explicitly excluded." >&2 + manifest_rc=1 + fi +done +if [ "${manifest_rc}" -ne 0 ]; then + echo " Add it to FILES (and raise EXPECTED_TESTS), or to EXCLUDED with a reason." >&2 + exit 1 +fi + +# Sum of the per-file counts above. Pinned deliberately: a bare `pytest test/jax/` +# that collected 0 would exit 5, and a partial loss (say 14 -> 3) would still exit 0. +EXPECTED_TESTS=27 + +echo "== collection floor check (expect >= ${EXPECTED_TESTS} tests) ==" +collect_out="$("${PYTHON_BIN}" -m pytest --collect-only -q -p no:cacheprovider "${FILES[@]}" 2>&1)" +collect_rc=$? +if [ "${collect_rc}" -ne 0 ]; then + printf '%s\n' "${collect_out}" + echo "test-jax.sh: pytest collection failed (exit ${collect_rc})" >&2 + exit 1 +fi +# Anchor to '.py::' at line start. An unanchored grep -c '::' also counts merged +# stderr (jax/XLA log lines, C++ symbols, '::1'), and because the floor is a >= test, +# OVER-counting is the dangerous direction: one stray line masks exactly one lost test. +n_collected="$(printf '%s\n' "${collect_out}" | grep -cE '^[^[:space:]]+\.py::')" +echo "collected ${n_collected} tests from ${#FILES[@]} files" +if [ "${n_collected}" -lt "${EXPECTED_TESTS}" ]; then + printf '%s\n' "${collect_out}" + echo "test-jax.sh: collected ${n_collected} tests, expected at least ${EXPECTED_TESTS}." >&2 + echo " A file was renamed/moved, or a test_* entry point was dropped and pytest is" >&2 + echo " now passing on fewer tests than this gate promises. Fix the file, or update" >&2 + echo " EXPECTED_TESTS in this script and say why." >&2 + exit 1 +fi + +junit="$(mktemp -t jaxci-junit-XXXXXX.xml)" +trap 'rm -f "${junit}"' EXIT + +echo "== running ==" +"${PYTHON_BIN}" -m pytest -q -p no:cacheprovider --durations=0 --junit-xml="${junit}" "${FILES[@]}" +rc=$? +if [ "${rc}" -ne 0 ]; then + # rc 5 == "no tests ran"; it is a FAILURE here, not a pass. + echo "test-jax.sh: pytest exited ${rc}" >&2 + exit "${rc}" +fi + +# OUTCOME check. The floor above counts COLLECTION, which cannot see a test that +# collects, runs, and asserts nothing: one pytest.skip() or importorskip() disables a +# gate while both the collected count and the pytest exit status stay green. That is +# the very shape this script exists to prevent, so assert what the RUN did. +"${PYTHON_BIN}" - "${junit}" "${EXPECTED_TESTS}" <<'PYCHECK' +import sys, xml.etree.ElementTree as ET +path, expected = sys.argv[1], int(sys.argv[2]) +root = ET.parse(path).getroot() +ts = root if root.tag == "testsuite" else root.find("testsuite") +if ts is None: + sys.stderr.write("test-jax.sh: no in the junit report\n"); sys.exit(1) +g = lambda k: int(ts.get(k, 0) or 0) +tests, skipped, failures, errors = g("tests"), g("skipped"), g("failures"), g("errors") +print("junit: tests=%d skipped=%d failures=%d errors=%d" % (tests, skipped, failures, errors)) +bad = [] +if tests < expected: + bad.append("ran %d tests, expected at least %d" % (tests, expected)) +if skipped: + bad.append("%d SKIPPED -- a skip silently disables a gate here; if a skip is " + "legitimate, exclude the file in FILES and say why" % skipped) +if failures or errors: + bad.append("%d failures, %d errors" % (failures, errors)) +if bad: + sys.stderr.write("test-jax.sh: " + "; ".join(bad) + "\n"); sys.exit(1) +PYCHECK +if [ $? -ne 0 ]; then exit 1; fi + +echo "jax_ile CPU regression gate: PASS (${n_collected} tests)" diff --git a/.travis/test-lisa.sh b/.travis/test-lisa.sh index e821b5739..f4f03adda 100644 --- a/.travis/test-lisa.sh +++ b/.travis/test-lisa.sh @@ -15,4 +15,12 @@ fi MonteCarloMarginalizeCode/Code/test/test_lisa_helper_contract.py \ MonteCarloMarginalizeCode/Code/test/test_lisa_pseudo_pipe_contract.py \ MonteCarloMarginalizeCode/Code/test/test_lisa_pp_surface.py \ - MonteCarloMarginalizeCode/Code/test/test_lisa_synthetic_demo.py + MonteCarloMarginalizeCode/Code/test/test_lisa_synthetic_demo.py \ + MonteCarloMarginalizeCode/Code/test/test_lisa_fairdraw_weights.py \ + MonteCarloMarginalizeCode/Code/test/test_lisa_l0_rescue.py \ + MonteCarloMarginalizeCode/Code/test/test_lisa_sampler_plumbing.py \ + MonteCarloMarginalizeCode/Code/test/test_lisa_av_state.py \ + MonteCarloMarginalizeCode/Code/test/test_lisa_use_lnL_branches.py \ + MonteCarloMarginalizeCode/Code/test/test_lisa_portfolio_method_integrity.py \ + MonteCarloMarginalizeCode/Code/test/test_lisa_mc_error_replicas.py \ + MonteCarloMarginalizeCode/Code/test/test_lisa_driver_drift.py diff --git a/.travis/test-simulation-manager.sh b/.travis/test-simulation-manager.sh index fb441f14c..77248ad3c 100755 --- a/.travis/test-simulation-manager.sh +++ b/.travis/test-simulation-manager.sh @@ -16,3 +16,8 @@ python3 -m pytest -v MonteCarloMarginalizeCode/Code/test/test_simulation_manager # v2 archive unit tests (database.py + queues + admin operations). python3 -m pytest -v MonteCarloMarginalizeCode/Code/test/test_database.py + +# In-package tests under RIFT/simulation_manager/tests/. These were not +# collected by anything before, so nearby_reuse and the condor transfer +# hooks shipped without CI coverage. +python3 -m pytest -v MonteCarloMarginalizeCode/Code/RIFT/simulation_manager/tests/ diff --git a/.travis/test-slowrot.sh b/.travis/test-slowrot.sh new file mode 100755 index 000000000..b960c6b70 --- /dev/null +++ b/.travis/test-slowrot.sh @@ -0,0 +1,264 @@ +#!/usr/bin/env bash +# CPU regression gate for the slow-rotation / finite-size likelihood +# (RIFT/likelihood/factored_likelihood_with_rotation.py, slowrot_response.py, +# slowrot_freqresponse.py), driven from RIFT/likelihood/test_slowrot_*.py. +# +# Four defences, each guarding a way this directory can go green while testing nothing. +# Do not simplify any of them into a bare `pytest `: +# +# 1. An EXPLICIT file list, not a glob. Several test_slowrot_*.py files collect ZERO +# items, and pytest exits 5 on those -- "no tests ran" reads as a pass in a log skim. +# 2. A FLOOR on the collected count, so a renamed file or a dropped test_* entry point +# goes RED instead of green-on-fewer-tests. +# 3. A hard fail on ANY nonzero pytest exit (5 included), plus a junit OUTCOME +# assertion. The floor counts COLLECTION, which cannot see a test that collects, +# runs, and asserts nothing. +# 4. A SCRIPTS tier for the assert-carrying files pytest cannot count. +# +# Needs numpy + lal only: no GPU, no jax, no numpyro. Rationale and measured cost: +# PR #172 (2026-08); the sibling gate it is modelled on is .travis/test-jax.sh. +set -uo pipefail +# NOTE: deliberately no -e. Every command below has its rc handled explicitly so the +# failure messages stay specific; if you add a command, guard it yourself. + +# SLOWDIR below is repo-relative, so anchor cwd rather than trusting the caller. +cd "$(dirname "$0")/.." || { echo "test-slowrot.sh: cannot cd to repo root" >&2; exit 1; } + +# INVARIANT: this gate always tests THIS CHECKOUT, never an installed build. Without +# this line the two tiers disagree -- pytest prepends .../Code to sys.path and gets the +# checkout, while a directly-run script gets RIFT/likelihood/ as sys.path[0] and falls +# through to whatever RIFT is installed. Must PREPEND: appending lets a caller's +# PYTHONPATH win and restores the split. If you need to validate a wheel or a container +# rather than the checkout, run its test files directly -- do not "fix" it here. +export PYTHONPATH="$PWD/MonteCarloMarginalizeCode/Code${PYTHONPATH:+:$PYTHONPATH}" + +PYTHON_BIN="${RIFT_SLOWROT_PYTHON:-${PYTHON:-python}}" +if ! command -v "${PYTHON_BIN}" >/dev/null 2>&1; then + PYTHON_BIN="$(command -v python3)" +fi + +# Guard the tool checks: a missing interpreter plus a redirected stderr is +# indistinguishable from a clean result. +"${PYTHON_BIN}" -c 'import pytest' || { echo "test-slowrot.sh: pytest unavailable" >&2; exit 1; } +"${PYTHON_BIN}" -c 'import numpy; print("numpy", numpy.__version__)' \ + || { echo "test-slowrot.sh: numpy unavailable" >&2; exit 1; } +"${PYTHON_BIN}" -c 'import lal, lalsimulation; print("lal", lal.__version__)' \ + || { echo "test-slowrot.sh: lal/lalsimulation unavailable" >&2; exit 1; } + +export OMP_NUM_THREADS="${OMP_NUM_THREADS:-1}" +export MKL_NUM_THREADS="${MKL_NUM_THREADS:-1}" + +SLOWDIR="MonteCarloMarginalizeCode/Code/RIFT/likelihood" + +# --------------------------------------------------------------------------------- +# TIER 1: files whose tests pytest can collect and count. +# +# Per-file counts are deliberately NOT listed: they need maintenance on every test added, +# and `pytest --collect-only -q ` answers the question in seconds. EXPECTED_TESTS +# below is the pinned total, and the manifest check keeps the list complete. +# +# Two files carry guards whose whole deliverable is the guard, so do not drop them from +# this list to save time: +# test_slowrot_fd_ops.py the Nyquist derivative weight, zeroed at ODD p and left +# alone at EVEN p. The jax gate is structurally blind to +# this -- at p=1 the correct and the over-zeroing weights +# are bit-identical, because 1 is odd either way. (#163) +# test_slowrot_freqresponse.py the unpaired-Nyquist response weight and its Hermitian +# average. (#165) +FILES=( + "${SLOWDIR}/test_slowrot_fd_ops.py" + "${SLOWDIR}/test_slowrot_freqresponse.py" + "${SLOWDIR}/test_slowrot_harmonic_width.py" + "${SLOWDIR}/test_slowrot_headtohead.py" + "${SLOWDIR}/test_slowrot_likelihood_v1.py" + "${SLOWDIR}/test_slowrot_noloop.py" + "${SLOWDIR}/test_slowrot_pathB.py" + "${SLOWDIR}/test_slowrot_precompute_integration.py" + "${SLOWDIR}/test_slowrot_response.py" +) + +# DESELECTED, and EXPECTED_TESTS is one lower because of it. +# +# test_W5_jax_packer_loses_nothing catches ImportError on jax and RETURNS. That is not a +# pytest skip -- it COLLECTS, RUNS, ASSERTS NOTHING and REPORTS PASSED. This job installs +# no jax, so leaving it selected would raise the floor and the junit count while gating +# nothing, which is this script's own failure mode one level down. +# +# Gating W5 needs a jax install. jax-ile-check does NOT cover it either -- that manifest +# scans test/jax/ only. Stated gap, not a claim of coverage. +# +# CAUTION: --deselect is a PREFIX match, not an exact nodeid match. A future sibling named +# test_W5_jax_packer_loses_nothing_v2 would be swallowed silently, and a >= floor cannot see +# a test that was never selected. Name any successor differently. +DESELECT=( + "${SLOWDIR}/test_slowrot_harmonic_width.py::test_W5_jax_packer_loses_nothing" +) + +# --------------------------------------------------------------------------------- +# TIER 2: files that assert but define no test_* function, so pytest collects 0 from each +# and would exit 5 on any of them alone. Run as `python `, exit 0 required. +# +# The scope of each file's asserts decides how it is gated, and the three differ: +# +# test_slowrot_cauchy_schwarz.py asserts at MODULE scope +# test_slowrot_noloop_bruteforce.py asserts at MODULE scope +# test_slowrot_freqresponse_likelihood.py asserts inside a function, called from __main__ +# +# All three are gated ONLY by being executed here. They are not in FILES, so pytest never +# imports them and the collection floor cannot see a failed assert in any of them -- the +# scope differences above change WHEN each file's asserts run, not how many gates it has. +# +# ANTI-INSTRUCTION: do not "tidy" these asserts into functions. A function this tier never +# calls leaves `python ` exiting 0 having asserted nothing, and no count notices. +# +# cauchy_schwarz is the one that pins lnL <= (1/2), i.e. that and are +# evaluated for the SAME h. Both it and noloop_bruteforce fail on a dropped arrival-time +# post-phase; this tier stops at the first failing script, so a mutation run will normally +# only show you the first. Neither is redundant with the other. +SCRIPTS=( + "${SLOWDIR}/test_slowrot_cauchy_schwarz.py" + "${SLOWDIR}/test_slowrot_noloop_bruteforce.py" + "${SLOWDIR}/test_slowrot_freqresponse_likelihood.py" +) + +# EXCLUDED, with the reason each is out. The manifest check below fails if a +# test_slowrot_*.py is in none of FILES, SCRIPTS or EXCLUDED, so a new one forces a +# decision instead of being silently unrun -- this gate's own failure mode, one level up. +# +# test_slowrot_gpu.py Need a GPU. On a CPU runner they report as +# test_slowrot_freqresponse_gpu.py SKIPPED with exit 0, and the junit check below +# treats a skip as a failure. Run by hand on a GPU +# node. Same treatment as the GPU parity files in +# q-window-stencil-check. +# +# test_slowrot_pathB_groundtruth.py ZERO assert statements at any scope: both are +# test_slowrot_pathB_bruteforce.py print-only convergence studies, so running them +# can fail only on an exception, and the import +# surface is already covered by TIER 1 and TIER 2. +# If either grows an assert, move it to SCRIPTS. +EXCLUDED=( + "${SLOWDIR}/test_slowrot_gpu.py" + "${SLOWDIR}/test_slowrot_freqresponse_gpu.py" + "${SLOWDIR}/test_slowrot_pathB_groundtruth.py" + "${SLOWDIR}/test_slowrot_pathB_bruteforce.py" +) + +# The manifest globs test_slowrot_*.py, NOT test_*.py: this directory also holds +# test_q_window_interp.py, test_calmarg_stencil_gating.py and friends, which belong to +# q-window-stencil-check and are not this gate's business. A new slow-rotation test +# filed under some other prefix would escape the manifest; name it test_slowrot_*. +echo "== manifest check (every test_slowrot_*.py is gated or explicitly excluded) ==" +manifest_rc=0 +for f in "${SLOWDIR}"/test_slowrot_*.py; do + known=0 + for g in "${FILES[@]}" "${SCRIPTS[@]}" "${EXCLUDED[@]}"; do + [ "${f}" = "${g}" ] && { known=1; break; } + done + if [ "${known}" -eq 0 ]; then + echo "test-slowrot.sh: ${f} is neither gated nor explicitly excluded." >&2 + manifest_rc=1 + fi +done +if [ "${manifest_rc}" -ne 0 ]; then + echo " Add it to FILES (and raise EXPECTED_TESTS), or to SCRIPTS if it asserts" >&2 + echo " outside a test_* function, or to EXCLUDED with a reason." >&2 + exit 1 +fi + +# The pinned floor: the number TIER 1 collects after DESELECT, as of this commit. +# Re-derive with `pytest --collect-only -q` over FILES; never lower it without saying why +# in the commit message. A bare `pytest ${SLOWDIR}` would sweep up files that collect 0, +# and a partial loss still exits 0, which is what this pins against. +EXPECTED_TESTS=43 + +DESELECT_ARGS=() +for d in "${DESELECT[@]}"; do DESELECT_ARGS+=(--deselect "${d}"); done + +echo "== collection floor check (expect >= ${EXPECTED_TESTS} tests) ==" +collect_out="$("${PYTHON_BIN}" -m pytest --collect-only -q -p no:cacheprovider \ + "${DESELECT_ARGS[@]}" "${FILES[@]}" 2>&1)" +collect_rc=$? +if [ "${collect_rc}" -ne 0 ]; then + printf '%s\n' "${collect_out}" + echo "test-slowrot.sh: pytest collection failed (exit ${collect_rc})" >&2 + exit 1 +fi +# Anchor to '.py::' at line start. An unanchored grep -c '::' also counts merged +# stderr and warning text, and because the floor is a >= test, OVER-counting is the +# dangerous direction: one stray line masks exactly one lost test. +n_collected="$(printf '%s\n' "${collect_out}" | grep -cE '^[^[:space:]]+\.py::')" +echo "collected ${n_collected} tests from ${#FILES[@]} files" +if [ "${n_collected}" -lt "${EXPECTED_TESTS}" ]; then + printf '%s\n' "${collect_out}" + echo "test-slowrot.sh: collected ${n_collected} tests, expected at least ${EXPECTED_TESTS}." >&2 + echo " A file was renamed/moved, or a test_* entry point was dropped and pytest is" >&2 + echo " now passing on fewer tests than this gate promises. Fix the file, or update" >&2 + echo " EXPECTED_TESTS in this script and say why." >&2 + exit 1 +fi + +# A deselect that stops matching is silent: pytest warns nothing and the count simply +# goes UP, which a >= floor cannot see. Assert each one still selects something. +for d in "${DESELECT[@]}"; do + if ! "${PYTHON_BIN}" -m pytest --collect-only -q -p no:cacheprovider "${d}" >/dev/null 2>&1; then + echo "test-slowrot.sh: DESELECT entry ${d} no longer resolves to a test." >&2 + echo " It was renamed or removed; drop it from DESELECT and lower EXPECTED_TESTS," >&2 + echo " or fix the nodeid. Left as is, the deselect is a no-op." >&2 + exit 1 + fi +done + +junit="$(mktemp -t slowrotci-junit-XXXXXX.xml)" || { echo "test-slowrot.sh: mktemp failed" >&2; exit 1; } +trap 'rm -f "${junit}"' EXIT + +echo "== TIER 1: pytest ==" +"${PYTHON_BIN}" -m pytest -q -p no:cacheprovider --durations=10 --junit-xml="${junit}" \ + "${DESELECT_ARGS[@]}" "${FILES[@]}" +rc=$? +if [ "${rc}" -ne 0 ]; then + # rc 5 == "no tests ran"; it is a FAILURE here, not a pass. + echo "test-slowrot.sh: pytest exited ${rc}" >&2 + exit "${rc}" +fi + +# OUTCOME check. The floor above counts COLLECTION, which cannot see a test that +# collects, runs, and asserts nothing: one pytest.skip() or importorskip() disables a +# gate while both the collected count and the pytest exit status stay green. That is +# the very shape this script exists to prevent, so assert what the RUN did. +"${PYTHON_BIN}" - "${junit}" "${EXPECTED_TESTS}" <<'PYCHECK' +import sys, xml.etree.ElementTree as ET +path, expected = sys.argv[1], int(sys.argv[2]) +root = ET.parse(path).getroot() +ts = root if root.tag == "testsuite" else root.find("testsuite") +if ts is None: + sys.stderr.write("test-slowrot.sh: no in the junit report\n"); sys.exit(1) +g = lambda k: int(ts.get(k, 0) or 0) +tests, skipped, failures, errors = g("tests"), g("skipped"), g("failures"), g("errors") +print("junit: tests=%d skipped=%d failures=%d errors=%d" % (tests, skipped, failures, errors)) +bad = [] +if tests < expected: + bad.append("ran %d tests, expected at least %d" % (tests, expected)) +if skipped: + bad.append("%d SKIPPED -- a skip silently disables a gate here; if a skip is " + "legitimate, exclude the file in FILES and say why" % skipped) +if failures or errors: + bad.append("%d failures, %d errors" % (failures, errors)) +if bad: + sys.stderr.write("test-slowrot.sh: " + "; ".join(bad) + "\n"); sys.exit(1) +PYCHECK +if [ $? -ne 0 ]; then exit 1; fi + +echo "== TIER 2: assert scripts ==" +for s in "${SCRIPTS[@]}"; do + echo "-- ${s}" + "${PYTHON_BIN}" "${s}" + src="$?" + if [ "${src}" -ne 0 ]; then + echo "test-slowrot.sh: ${s} exited ${src}" >&2 + echo " This file asserts outside any test_* function, so a nonzero exit here is" >&2 + echo " a failed assertion, not a harness problem." >&2 + exit 1 + fi +done + +echo "slowrot CPU regression gate: PASS (${n_collected} tests + ${#SCRIPTS[@]} assert scripts)" diff --git a/CHANGES.rst b/CHANGES.rst index 4502b5b9e..2fb1f329a 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -19,6 +19,19 @@ development tree is rift_O4d. exact-regression fallback. See https://git.ligo.org/rapidpe-rift/rift/-/merge_requests/ (TBD) and the project notes at 20260513-Me-ParsimoniousPlacementOptions/parsimonious_placement_plan.md. + - **CHANGES lnL VALUES** (issue #146) time-marginalization window grid: the two extrinsic ILE + drivers built different grids. bin/integrate_likelihood_extrinsic_batchmode used + linspace(-iwh, iwh, int(2*iwh/deltaT)) at ten sites; RIFT.likelihood.jax_ile (and so + bin/integrate_likelihood_extrinsic_jax) used arange(-Nw, Nw)*deltaT. Both likelihoods consume + only tvals[0] and len(tvals) -- each steps by deltaT and integrates with dx=deltaT -- so the + grids differed in origin by 0.2 samples, enough to round ifirst to a different integer sample + per detector, and in length at srate 1024/2048/16384. Both now call the single constructor + factored_likelihood.marginalization_time_grid(iwh, deltaT), spaced exactly deltaT with + npts = int(2*iwh/deltaT). Batchmode's window LENGTH is unchanged at every sample rate; its + grid ORIGIN moves by +4.88e-5 s, which shifts lnL by up to ~0.5 nats at the injected + parameters at srate 4096 (less at 16384). Marginalized lnZ moves sub-nat. Anyone comparing + against archived runs should expect a shift at that scale. The JAX driver's window gains one + sample at srate 1024/2048/16384; measured effect on its lnL is < 4e-3 nats. - generic worfklow backend (condor, slurm, htcondor, etc) via dag_utils_generic - simulation_manager framework: interface requirements for external adaptive simulations - CIP hyperpipe improvements (initialize_me; enable population and EOS params in using_eos file with arbitrary diff --git a/MonteCarloMarginalizeCode/Code/RIFT/asimov/README.md b/MonteCarloMarginalizeCode/Code/RIFT/asimov/README.md index f2b805f46..9290ca086 100644 --- a/MonteCarloMarginalizeCode/Code/RIFT/asimov/README.md +++ b/MonteCarloMarginalizeCode/Code/RIFT/asimov/README.md @@ -7,3 +7,16 @@ Based on See related documentation and examples in * https://asimov.docs.ligo.org/asimov/master/pipelines-dev.html * https://git.ligo.org/asimov/pipelines/gwdata/-/blob/master/datafind/asimov.py + +Compatibility notes +------------------- + +With ASIMOV versions that provide ``PESummaryPipeline``, RIFT retains the +legacy automatic PESummary completion job. ASIMOV 0.7 and newer manage +PESummary as a separate postprocessing analysis, so RIFT marks the PE analysis +finished and does not submit a duplicate postprocessing job. + +``Rift.collect_assets(absolute=True)`` publishes the ``rift-assets/v1`` +contract for separate postprocessing adapters: samples (always a list), the +RIFT configuration, PSDs, calibration envelopes, likelihood products, and +basic event/analysis provenance. Consumers should tolerate additional keys. diff --git a/MonteCarloMarginalizeCode/Code/RIFT/asimov/rift.ini b/MonteCarloMarginalizeCode/Code/RIFT/asimov/rift.ini index db21ac80d..d34121f81 100644 --- a/MonteCarloMarginalizeCode/Code/RIFT/asimov/rift.ini +++ b/MonteCarloMarginalizeCode/Code/RIFT/asimov/rift.ini @@ -68,7 +68,11 @@ types = { {% for ifo in ifos %}"{{ifo}}":"{{data['frame types'][ifo]}}",{% endfo channels = { {% for ifo in ifos %}"{{ifo}}":"{{data['channels'][ifo]}}",{% endfor %} } [lalinference] +{% if likelihood contains 'minimum frequency' %} +flow = { {% for ifo in ifos %}"{{ifo}}":{{likelihood['minimum frequency'][ifo]}},{% endfor %} } +{% else %} flow = { {% for ifo in ifos %}"{{ifo}}":{{quality['minimum frequency'][ifo]}},{% endfor %} } +{% endif %} fhigh = { {% for ifo in ifos %}"{{ifo}}":{{quality['maximum frequency'][ifo]}},{% endfor %} } [engine] diff --git a/MonteCarloMarginalizeCode/Code/RIFT/asimov/rift.py b/MonteCarloMarginalizeCode/Code/RIFT/asimov/rift.py index b373aa84c..8b70232db 100644 --- a/MonteCarloMarginalizeCode/Code/RIFT/asimov/rift.py +++ b/MonteCarloMarginalizeCode/Code/RIFT/asimov/rift.py @@ -12,7 +12,12 @@ from asimov.utils import set_directory from asimov.pipeline import Pipeline, PipelineException, PipelineLogger -from asimov.pipeline import PESummaryPipeline + +try: + from asimov.pipeline import PESummaryPipeline +except ImportError: + # ASIMOV >= 0.7 supplies PESummary as a separate pipeline plugin. + PESummaryPipeline = None from asimov.utils import update @@ -78,6 +83,18 @@ def _create_ledger_entries(self): for section_arg in required_args[section]: if section_arg not in section_data: section_data[section_arg] = {} + + def _get_psds(self, format="ascii"): + """Return PSD assets across the ASIMOV 0.5 and 0.7 APIs.""" + legacy_getter = getattr(self.production, "get_psds", None) + if callable(legacy_getter): + assets = legacy_getter(format) + else: + attribute = "xml_psds" if format == "xml" else "psds" + assets = getattr(self.production, attribute, {}) or {} + if format == "xml" and isinstance(assets, dict): + return list(assets.values()) + return assets # Top-level groups a PESummary metafile carries that are not analysis labels _PESUMMARY_RESERVED = ('version', 'history') @@ -192,10 +209,19 @@ def _find_posterior(self): for production in self.production.event.productions: productions[production.name] = production for previous_job in self.production.dependencies: - self.logger.info("RIFT: previous job assets" + str( productions[previous_job].pipeline.collect_assets())) try: - if "samples" in productions[previous_job].pipeline.collect_assets(): - posterior_file = productions[previous_job].pipeline.collect_assets()['samples'] + previous_assets = productions[previous_job].pipeline.collect_assets() + self.logger.info("RIFT: previous job assets" + str(previous_assets)) + if "samples" in previous_assets: + posterior_file = previous_assets['samples'] + if isinstance(posterior_file, (list, tuple)): + if len(posterior_file) != 1: + raise PipelineException( + "RIFT bootstrap: {} publishes {} sample files; " + "need exactly one PESummary metafile".format( + previous_job, len(posterior_file)), + production=self.production.name) + posterior_file = posterior_file[0] self.production.meta['dataset'] = self._dataset_label(posterior_file) return posterior_file except PipelineException: @@ -211,10 +237,36 @@ def _find_posterior(self): else: self.logger.error("Could not find an analysis providing posterior samples to analyse.") + def _reuse_existing_bootstrap(self, bootstrap_file, posterior_file): + """Fail closed unless reuse of an unprovenanced grid is explicit.""" + if not os.path.exists(bootstrap_file): + return False + if not self.production.meta['scheduler'].get( + 'bootstrap reuse existing', False): + raise PipelineException( + "RIFT bootstrap: existing grid {} may come from a different " + "posterior than {}. Remove the grid, use a new analysis name, " + "or explicitly set scheduler: bootstrap reuse existing: true." + .format(bootstrap_file, posterior_file), + production=self.production.name) + self.logger.warning( + "RIFT bootstrap: explicitly reusing existing grid {} without " + "source provenance validation".format(bootstrap_file)) + return True + def after_completion(self): + if PESummaryPipeline is None: + self.logger.info( + "Job has completed. PESummary is managed by a separate " + "ASIMOV postprocessing analysis." + ) + super().after_completion() + return - self.logger.info("Job has completed. Running PE Summary.") - post_pipeline = PESummaryPipeline(production=self.production) + self.logger.info("Job has completed. Running legacy PE Summary.") + post_pipeline = PESummaryPipeline( + production=self.production, category=self.category + ) cluster = post_pipeline.submit_dag() self.production.meta["job id"] = int(cluster) @@ -248,7 +300,7 @@ def before_config(self, dryrun=False): category = config.get("general", "calibration_directory") # XML PSDs self.logger.info("Checking for XML format PSDs") - if len(self.production.get_psds("xml")) == 0 and "psds" in self.production.meta: + if len(self._get_psds("xml")) == 0 and "psds" in self.production.meta: self.logger.info("Did not find XML format PSDs") for ifo in self.production.meta["interferometers"]: with set_directory(f"{event.work_dir}"): @@ -490,14 +542,8 @@ def build_dag(self, user=None, dryrun=False): ) bootstrap_file_ascii = str(bootstrap_file) + "_ascii" # test if bootstrap file already exists - if os.path.exists(bootstrap_file): - # Rebuilding an analysis under the same name reuses this - # silently, so a changed bootstrap source has no effect. - self.logger.warning( - "RIFT bootstrap: reusing existing grid {} and IGNORING {}; " - "delete it (and its _ascii) to rebuild".format( - bootstrap_file, posterior_file)) - if not(os.path.exists(bootstrap_file)): + if not self._reuse_existing_bootstrap( + bootstrap_file, posterior_file): import RIFT.misc.samples_utils RIFT.misc.samples_utils.dump_pesummary_samples_to_file_as_rift(posterior_file, self.production.meta['dataset'], bootstrap_file_ascii) extra_args ='' @@ -626,7 +672,7 @@ def build_dag(self, user=None, dryrun=False): ) if self.production.event.repository: # with set_directory(os.path.abspath(self.production.rundir)): - for psdfile in self.production.get_psds("xml"): + for psdfile in self._get_psds("xml"): ifo = psdfile.split("/")[-1].split("-")[1].split(".")[0] os.system(f"cp {psdfile} {ifo}-psd.xml.gz") @@ -669,7 +715,7 @@ def submit_dag(self, dryrun=False): This will be raised if the pipeline fails to submit the job. """ self.before_submit() - for psdfile in self.production.get_psds("xml"): + for psdfile in self._get_psds("xml"): ifo = psdfile.split("/")[-1].split("-")[1].split(".")[0] os.system(f"cp {psdfile} {ifo}-psd.xml.gz") @@ -680,12 +726,12 @@ def submit_dag(self, dryrun=False): "marginalize_intrinsic_parameters_BasicIterationWorkflow.dag", ] if dryrun: - for psdfile in self.production.get_psds("xml"): + for psdfile in self._get_psds("xml"): print(f"cp {psdfile} {self.production.rundir}/{psdfile.split('/')[-1]}") print("") print(" ".join(command)) else: - for psdfile in self.production.get_psds("xml"): + for psdfile in self._get_psds("xml"): os.system( f"cp {psdfile} {self.production.rundir}/{psdfile.split('/')[-1]}" ) @@ -845,7 +891,11 @@ def detect_completion(self): def collect_assets(self,absolute=False): """ - Gather all of the results assets for this job. + Gather result assets for downstream ASIMOV/PESummary analyses. + + ``samples`` is always a list, including calibration-reweighted output. + Consumers which run outside the RIFT working directory should request + absolute paths. """ if absolute: rundir = os.path.abspath(self.production.rundir) @@ -853,11 +903,58 @@ def collect_assets(self,absolute=False): rundir = self.production.rundir rift_all_lnL = os.path.join(rundir, 'all.net') samples_raw = os.path.join(rundir,'extrinsic_posterior_samples.dat') - dict_out = {"samples":self.samples(), "lnL_marg":rift_all_lnL, "samples_raw":samples_raw} + dict_out = { + "samples": self.samples(absolute=absolute), + "lnL_marg": rift_all_lnL, + "samples_raw": samples_raw, + "provenance": { + "pipeline": "rift", + "event": self.production.event.name, + "analysis": self.production.name, + }, + } rewt_file_name = os.path.join(rundir,'reweighted_posterior_samples.dat') if os.path.exists(rewt_file_name): dict_out['samples_calmarg'] = rewt_file_name - dict_out['samples'] = rewt_file_name + dict_out['samples'] = [rewt_file_name] + + try: + ini = self.production.get_configuration().ini_loc + if not os.path.isabs(ini): + ini = os.path.join( + self.production.event.repository.directory, + self.category, + ini, + ) + dict_out["config"] = os.path.abspath(ini) if absolute else ini + except (AttributeError, IndexError, TypeError, ValueError): + self.logger.warning("RIFT configuration asset is not available") + + psds = self._get_psds("ascii") + if psds: + dict_out["psds"] = { + ifo: (os.path.abspath(path) if os.path.isabs(path) else + os.path.abspath(os.path.join( + self.production.event.repository.directory, path))) + if absolute else path + for ifo, path in psds.items() + } + + calibration = self.production.meta.get("data", {}).get("calibration", {}) + if calibration: + dict_out["calibration"] = { + ifo: (os.path.abspath(path) if os.path.isabs(path) else + os.path.abspath(os.path.join( + self.production.event.repository.directory, path))) + if absolute else path + for ifo, path in calibration.items() + } + + if dict_out["samples"] and "config" in dict_out: + dict_out["asset_contract"] = "rift-assets/v1" + else: + self.logger.warning( + "RIFT assets are incomplete; not advertising rift-assets/v1") return dict_out diff --git a/MonteCarloMarginalizeCode/Code/RIFT/calmarg/adaptive.py b/MonteCarloMarginalizeCode/Code/RIFT/calmarg/adaptive.py index c4c48c9a7..a2dc82c41 100644 --- a/MonteCarloMarginalizeCode/Code/RIFT/calmarg/adaptive.py +++ b/MonteCarloMarginalizeCode/Code/RIFT/calmarg/adaptive.py @@ -217,7 +217,7 @@ def adaptive_cal(evaluate, prior_mean, prior_sigma, n_nodes_amp, Returns dict with the final realizations' `nodes`, `log_w` (prior/proposal, for the marginalization), `proposal` (mean,cov), and per-iteration `neff` history. """ - rng = rng or np.random.default_rng() + rng = rng or _gr._default_cal_rng('calmarg.adaptive_cal') dim = prior_mean.shape[0] if betas is None: # ramp tempering 0.3 -> 1.0 diff --git a/MonteCarloMarginalizeCode/Code/RIFT/calmarg/generate_realizations.py b/MonteCarloMarginalizeCode/Code/RIFT/calmarg/generate_realizations.py index 8b79ab1c2..a83c89e6e 100644 --- a/MonteCarloMarginalizeCode/Code/RIFT/calmarg/generate_realizations.py +++ b/MonteCarloMarginalizeCode/Code/RIFT/calmarg/generate_realizations.py @@ -18,6 +18,22 @@ import scipy.interpolate +def _default_cal_rng(stream): + """RNG for a cal draw whose caller did not supply one. + + The cal realizations ARE part of the likelihood -- the marginalized lnL is an + average over them -- so a `rng=None` fallback of np.random.default_rng() means + that caller's lnL is not reproducible under --seed, since default_rng() pulls + fresh OS entropy and nothing seed_everything does can reach it. The ILE driver + always passes an explicit rng, so this is a guard on the fallback rather than a + live defect; it exists so that adding a caller cannot silently reintroduce the + hole. Counter-advancing, so repeated draws (e.g. growing the cal set) stay + independent instead of appending copies. Unseeded runs keep fresh entropy. + """ + from RIFT.integrators.seeding import next_derived_rng + return next_derived_rng(stream) + + def retrieve_envelope_from_file(fname, frequency_array=None,**kwargs): """ retrieve_envelope_from_file @@ -218,7 +234,7 @@ def draw_prior_realizations_with_nodes(env_dir, dets, T_segment, dT, fmin, fmax, """ import os if rng is None: - rng = np.random.default_rng() + rng = _default_cal_rng('calmarg.draw_prior_realizations_with_nodes') priors = [] for ifo in dets: fmin_here = fmin @@ -275,7 +291,7 @@ def seed_realizations_from_breadcrumb(bc, T_segment, dT, fmin, fmax, n_spline_po from RIFT.calmarg import adaptive cal = bc["cal"] if (isinstance(bc, dict) and "cal" in bc) else bc if rng is None: - rng = np.random.default_rng() + rng = _default_cal_rng('calmarg.seed_realizations_from_breadcrumb') mean = np.asarray(cal["proposal_mean"], dtype=float) cov = np.asarray(cal["proposal_cov"], dtype=float) prior_mean = np.asarray(cal["prior_mean"], dtype=float) diff --git a/MonteCarloMarginalizeCode/Code/RIFT/calmarg/pilot.py b/MonteCarloMarginalizeCode/Code/RIFT/calmarg/pilot.py index c6cbb5149..d3453cb79 100644 --- a/MonteCarloMarginalizeCode/Code/RIFT/calmarg/pilot.py +++ b/MonteCarloMarginalizeCode/Code/RIFT/calmarg/pilot.py @@ -19,6 +19,7 @@ from scipy.special import logsumexp from RIFT.calmarg import adaptive, breadcrumbs +from RIFT.calmarg import generate_realizations as _gr # --------------------------------------------------------------------------- @@ -80,7 +81,7 @@ def seed_cal(cal_proposal, n_cal, rng=None): (nodes, log_weights) where log_weights = log prior - log proposal (Phase 0 importance weights for the marginalization). Feed nodes through adaptive.nodes_to_cal_factors(...) per detector to get the actual cal factors.""" - rng = rng or np.random.default_rng() + rng = rng or _gr._default_cal_rng('calmarg.seed_cal') mean = np.asarray(cal_proposal["proposal_mean"]) cov = np.asarray(cal_proposal["proposal_cov"]) nodes = rng.multivariate_normal(mean, cov, size=n_cal) diff --git a/MonteCarloMarginalizeCode/Code/RIFT/hyperpipe/config.py b/MonteCarloMarginalizeCode/Code/RIFT/hyperpipe/config.py index f9e067032..1fdfd9737 100644 --- a/MonteCarloMarginalizeCode/Code/RIFT/hyperpipe/config.py +++ b/MonteCarloMarginalizeCode/Code/RIFT/hyperpipe/config.py @@ -110,7 +110,8 @@ # Null values fall through to the updater's built-in defaults. settings: update-method: null # smc-mala-bd | smc-mala | birth-death | puffball - tracer-fit-method: null # rf | rbf | polynomial | quadratic + tracer-fit-method: null # rf | rbf | polynomial | quadratic | gp_linmean + tracer-lnl-floor-delta: null # clamp lnL at max-DELTA instead of cutting; null = off n-mala-steps: null # -> --n-mala-steps target-ess-frac: null # -> --target-ess-frac birth-death-rate: null # -> --birth-death-rate diff --git a/MonteCarloMarginalizeCode/Code/RIFT/integrators/DESIGN_rvs_naming.md b/MonteCarloMarginalizeCode/Code/RIFT/integrators/DESIGN_rvs_naming.md new file mode 100644 index 000000000..1bfb70211 --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/RIFT/integrators/DESIGN_rvs_naming.md @@ -0,0 +1,443 @@ +# DESIGN: give the retained set and the export resample separate names + +**Status: implemented (option A).** This began as a draft wired into a single sampler so the +shape could be argued about against something concrete rather than against prose. It is now +carried by all six backends and read by the ILE weight path in both drivers, with the +validation recorded in `VALIDATION_rvs_weight_migration.md` (tiers 0-3). + +Option B -- making `_rvs` itself an object -- was considered and DEFERRED as too invasive to +attempt near-term; it is recorded in full below so the reasoning survives, not because it is +scheduled. + +## The problem, stated once + +`sampler._rvs` means two different things at two different times in one function: + +```python +# ... integrate_log accumulates draws ... +self._rvs[key] -> the RETAINED SET: every draw the pass kept, with real importance weights + +if bFairdraw and n_extr < len(self._rvs[...]): + self._rvs[key] = self._rvs[key][indx_list] # WITH REPLACEMENT, proportional to weight + +# ... every consumer from here on ... +self._rvs[key] -> an EXPORT RESAMPLE: ~1.5*eff_samp equal-weight rows, built for writing out +``` + +Nothing in the name changes. Nothing in the type changes. A consumer written against the first +meaning keeps working, silently, against the second. + +## The evidence that this is a design problem and not a run of bad luck + +**Nine defects of this one shape.** Five before the audit (CIP posterior export; L0 rescue seed, +#78; rescue reject gate, #79; warm-seed reserve cap and its logarithm, #84), three found by the +mechanical sweep (#87: sequential warm-start seed; three double-weighting exporters; pooled +`n_eff`), and then **four more in review of the fix itself** — every one in the boolean +bookkeeping introduced to paper over the naming, not in the physics: + +| round | defect | +|---|---| +| 1 | a fix correct in isolation, wrong once pooling ran after it | +| 2 | one flag answering two questions (`rows resampled` vs `globally equal-weight`) | +| 2 | the CLI option used where "what this pass actually did" was needed | +| 3 | a marker cleared only on the normal return, surviving a raised event | + +Each was a second source of truth about `_rvs` that some site touching `_rvs` failed to +maintain. **That is what a naming problem looks like once you refuse to rename anything.** + +## Blast radius, measured + +From `test/expensive_before_merging/integrators/audit_rvs_fairdraw.py --summary`: + +- **306** reads of `_rvs` across 7 integrators, 3 ILE scripts, 2 CIP scripts, `distance_slices` +- **131** of them run after the rebind +- **7** rebind sites, one per sampler `integrate`/`integrate_log` + +So a flag-day rename is not on. Any proposal has to be incremental and has to leave every +unconverted consumer working unchanged. + +## Options + +### A. Two names, `_rvs` keeps its current meaning (recommended) + +`integrate_log` leaves **both**: + +```python +self._rvs # unchanged: the export resample when a fair draw fired, else the retained set +self._rvs_record # NEW: an RvsRecord carrying rows + provenance, and both views +``` + +`RvsRecord` answers the questions the four review rounds kept getting wrong, as *methods with +names*, rather than as booleans a caller has to combine correctly: + +```python +rec.rows_are_resampled() # were rows drawn proportional to w? (per block) +rec.is_equal_weight() # is the whole record uniform? (whole record) +rec.posterior_log_weights() # what to weight rows by to get the posterior +``` + +* **Pro:** no consumer breaks; migration is one call site at a time; the two questions can never + be conflated again because they are two methods with two names; provenance travels *with* the + rows instead of beside them, so it cannot be left stale by an exception. +* **Con:** two objects during the migration, and a rule that they stay in sync. + +### B. The fair draw returns a new object; `_rvs` stays the retained set + +**DECIDED 2026-08-13: parked as long-term, tracked in issue #95. Not in the next month or two.** +Reviewer's assessment -- "B sounds super dangerous" -- and agreed: there is no way to stage it +and no way to test it incrementally. It stays the end state, not the next step. + +The correct end state, and the only one that makes the error unrepresentable. + +* **Pro:** the bug becomes impossible rather than merely detectable. +* **Con:** every one of the 131 post-rebind reads must be told which object it wants, in one + change. The export path (`copy.deepcopy(sampler._rvs)`, the `.dat` writers, the LISA twin) + wants the resample; the seed and diagnostic paths want the retained set; and the two CIP + scripts want neither because they never fair-draw at all. That is a large, untestable-in-one-go + change to code that writes science products. + +### C. Keep the booleans, keep the CI gate, write nothing new + +Where #87 leaves things. The gate (`--check`) does catch new consumers, which is worth having +regardless. + +* **Pro:** no further risk today. +* **Con:** four review rounds say the booleans are hard to maintain *even for someone whose + whole task is maintaining them*. The next person edits one site and the invariant breaks + somewhere they were not looking. + +## Recommendation, and what was decided + +**A now, B parked, C regardless.** Agreed in review, 2026-08-13. + +* **A** is the direction: incremental, each step independently testable, and it subsumes the + flags, which is the specific thing that keeps going wrong. With the memory question settled + (below), the next step is to have the record **reference the existing bounded reserve** rather + than take its own copy of the retained rows. +* **B** is parked as long-term in **issue #95**, with the blast-radius numbers and a + definition-of-done. It stays the target; A is what makes it cheap later, by turning it from a + 306-site rename into a change of what the record's default view returns. +* **C**'s CI gate stays either way. It is the only mechanism that catches a *new* consumer + rather than fixing the current ones, and it has already caught an addition nobody wrote it + for: this draft's own. + +## What is in this branch (updated: option A started, 2026-08-13) + +**Status: A agreed and begun.** Still a small change, still reviewable in one sitting. + +* `RIFT/integrators/rvs_record.py` -- `RvsRecord` + `RvsProvenance`, the three named questions, + and `retained_points()` / `retained_lnL()` / `n_retained()`, which **reference the bounded + `_warm_seed_reserve`** rather than copy retained rows (decision from the memory measurement). +* `mcsamplerAdaptiveVolume` sets `self._rvs_record` on **both** paths -- `fair_draw` when the + draw fires, `retained` when it does not -- because "absent" and "not resampled" are different + statements, and a consumer that must tell them apart is back to combining conditions by hand. +* The ILE's replica pooling builds a **pooled** record carrying `_rep_fairdraw` PER BLOCK -- + the thing the two booleans cannot express, and the reason a raw/resampled mixture needed a + special case in `_pool_replica_rvs`. +* **First consumer migrated:** `ln_weights_for_posterior`. Chosen because it is the exact site + of the one-flag-two-questions defect, so the conversion demonstrates the point rather than + merely exercising the API. + +### How the migration is kept safe + +Two descriptions of one thing is the real cost of A, and four review rounds on #87 were all +"two descriptions drifted apart". So it is asserted, not promised: + +* **`test_the_record_and_the_flags_agree_in_every_state`** -- record vs flags across retained, + fair draw, pooled, pooled-mixed and pooled-raw. +* **`test_the_migration_changes_no_number`** -- on a real collapsed AV pass, the record path and + the flag path return **bit-identical** weights, on both branches. The conversion is a + refactor, not a behaviour change, and stays checkable until the flags are deleted. +* The migrated consumer only trusts a record whose `.columns is rvs`; `_rvs` is a mutable dict + that may have been replaced since the record was built, so a stale description falls back to + the flags instead of being believed. + +### Progress + +| step | state | +|---|---| +| 1. record + reserve-by-reference, first consumer migrated | **done** | +| 2. remaining consumers ask the record | **done** -- `.dgrid` and the breadcrumb via `ln_weights_for_posterior`; the `.dslice` guard and the pooled `n_eff` directly | +| 3. all seven rebind sites set the record | **done** -- one patcher against PR #87's own markers, so all seven are identical | +| 4. delete `_rvs_is_fairdraw` / `_rvs_is_pooled` | not yet: they are still the fallback, and the agreement tests are what make step 3 checkable | +| 5. issue #95 (option B) | unblocked only after step 4 | + +Every consumer now goes through **one** lookup, `_rvs_record_for(sampler, rvs)`, which declines +a record whose `.columns` is not the dict being held -- `_rvs` is replaced in place, so "the +sampler has a record" and "the record describes these rows" are different questions. The +producer at the pooling site asks a third one, `_sampler_keeps_records`, and has its own name +for the reason this whole document exists. + +### Two things found while doing the mechanical step + +* **`n_retained` had to be captured eagerly.** `RvsRecord.retained(self._rvs)` holds a + reference to the live column dict, which the fair draw then rebinds -- so `len(record)` after + the draw returns the *post*-draw count. Reading it made a collapsed pass report + `n_retained == rows`, i.e. "nothing was discarded", the exact opposite of the truth. This + project's own bug class, in the code written to prevent it. Pinned by + `test_n_retained_is_captured_eagerly_not_read_back_from_the_columns`. +* **`mcsampler` and `mcsamplerEnsemble` take a LINEAR integrand**, AV and the portfolio a log + one. Feeding the wrong kind makes the fair draw compute negative weights and raise. Verified + to fail identically on the pristine file, so it is a harness contract rather than a defect -- + recorded here because it cost time and will cost it again. + +## The backend divergence, made visible (2026-08-14) + +Raised in review: *"the code is pretty messy in that we have structurally different things for +each backend, which is a huge landmine for developers."* Agreed, and it is a **separate** problem +from the naming one -- the record does not fix it, so the first step is to stop it being +invisible. `audit_backend_contracts.py` prints it, and `--check` (in CI) fails when a contract +changes without the recorded table changing with it. + +| backend | entry | `_rvs['integrand']` holds | reserve | rebinds | +|---|---|---|---|---| +| `mcsampler` | `integrate` | **linear L** | no | 1 | +| `mcsamplerGPU` | both | **linear L** | no | 2 | +| `mcsamplerAdaptiveVolume` | both | **lnL** (aliased) | yes | 1 | +| `mcsamplerNFlow` | both | **lnL** (aliased) | no | 1 | +| `mcsamplerPortfolio` | both | **lnL** (aliased) | yes | 1 | +| `mcsamplerEnsemble` | both | **L *or* lnL**, per the `return_lnI` kwarg | no | 1 | + +Three different meanings for one column name, and for `mcsamplerEnsemble` the meaning is a +**runtime property of how the pass was called** -- no amount of reading the consumer tells you +which it is. That is why `ln_weights_from_rvs` demands `use_lnL` explicitly, and why it must be +the *stored* convention rather than `opts.internal_use_lnL`. + +The failure is asymmetric, which is what makes it a landmine rather than a nuisance: feeding a +log callable to a linear entry point makes the fair draw compute negative weights and **raise**; +making the same mistake downstream does **not** raise -- it takes `log()` of a log and returns a +plausible, almost-flat weight vector. It cost time twice in one afternoon while wiring the +record, which is the only reason it is documented rather than rediscovered. + +Two other differences the table records, because consumers have to cope with them: + +* only AV and the portfolio keep a `_warm_seed_reserve`, so `retained_points()` answers `None` + for the other four and the L0 rescue / sequential warm start keep their fallbacks; +* the portfolio's `_rvs` holds **every** draw, AV's only the retained subset -- ~92 MB vs + ~0.9 MB per million `nmax` -- so `n_retained` means different things per backend. + +**This gate does not forbid the differences.** Some are load-bearing and none should be +"tidied" without a decision. It makes a change to one show up as a diff. + +## The universal output API (2026-08-14) + +Review made the framing sharper than the original draft had it: + +> *"`_rvs` is an internal variable -- consumers should be accessing a first-class non-internal +> API with clear meaning, not reaching inside for something that is different. If we add a +> universal API for the output format, we can fully disambiguate and then leave `return_lnI` as +> stale historical material."* + +That is the right shape, and it subsumes the backend divergence rather than merely documenting +it. So `_rvs` stays internal and this is what consumers call: + +```python +rec = sampler.samples() # RvsRecord, or None if the pass never ran + +rec.log_likelihood() # ln L -- same meaning on every backend +rec.log_prior() # ln pi +rec.log_sampling_prior() # ln q +rec.log_weights() # lnL + ln pi - ln q, NO use_lnL argument + +rec.rows_are_resampled() # provenance, as before +rec.is_equal_weight() +rec.blocks_were_flattened() +``` + +Everything is **log space**, because it is the only convention all six backends can express +without loss -- the linear column underflows to 0 at ~745 nats, which is precisely the regime +this whole line of work is about. + +### How `return_lnI` becomes historical + +`log_likelihood()` prefers the unambiguous `log_integrand` column, which covers AV, NFlow, the +portfolio, `mcsamplerGPU.integrate_log`, and mcsamplerEnsemble *when it ran under `use_lnL`*. +Three cases have a bare `integrand` column, and each states its own convention: + +* `mcsampler` -- writes no log columns at all, so it records `integrand_is_log=False`; +* `mcsamplerGPU.integrate` -- likewise linear, and unambiguously so: a `use_lnL=True` call is + handed off to `integrate_log` before any column is written, so everything reaching the record + is linear `L`. It records `integrand_is_log=False`. **This was missed in the first version**, + which left the default GPU mode raising from its own public accessor; +* `mcsamplerEnsemble` -- records `integrand_is_log=bool(return_lnI)`, **at the point where that + is known**. `return_lnI` and not `use_lnL`: `integrand` is `value_array`, which is + `cumulative_values` (always `lnL`, whatever convention the *callable* used) under `return_lnI` + and `exp()` of it otherwise. `use_lnL` decides only whether the log columns are written + *beside* it. The first version recorded `bool(use_lnL)`, which agrees on three of the four + combinations and mislabels `return_lnI=True, use_lnL=False` as linear -- sending every + negative-`lnL` row to zero weight and taking `log()` of a log on the rest. + +That is the whole trick. The convention was always a runtime property, recoverable only by the +sampler; now the sampler states it once instead of every caller threading `use_lnL` through and +one of them eventually passing `opts.internal_use_lnL` by mistake (a documented bug). Once every +consumer is on this API, `return_lnI` is an implementation detail of one backend rather than +something the ILE has to know about. + +When a record has a raw `integrand` column and no recorded convention, `log_likelihood()` +**raises**. Guessing would reproduce exactly the defect the backend audit documents, and a loud +failure is the rule this codebase already applies one layer down in `ln_weights_from_rvs`. + +### Delivered as a mixin + +`SamplerOutputMixin`, because the six `MCSampler` classes share no base today -- five are +`class MCSampler(object)` and only `mcsamplerNFlow` inherits `MCSamplerGeneric`. Giving them a +real common base is a bigger change than this draft should make, and the mixin gets the public +API onto all six without one. + +## Consumers now use the API (2026-08-14) + +`_rvs_record` is private to the sampler that owns it. Everyone else -- the ILE and the tests -- +goes through `samples()`, and the pooling step, which legitimately *produces* a record the +sampler cannot, goes through `set_samples()`. A writer needs an API as much as a reader does; +without one, that code had to assign another object's private attribute. + +Enumerated mechanically before touching anything: 7 sampler self-reads (the producer reading +its own attribute, which stays), 11 in the ILE (2 of them writes), 14 in the tests. + +**The boundary is now a test, not a convention.** `_attribute_reads` walks the AST and fails on +any `._rvs_record`, in either form. Two earlier versions of that guard were wrong in +ways worth recording, both preserved in its docstring: + +* a plain substring search counts the *comments* that explain the hazard -- most of the + occurrences in these files, and the same false alarm PR #87 hit; +* stripping comments and counting tokens **misses `getattr(sampler, '_rvs_record')`**, where + the name lives in a string literal -- precisely the form a consumer reaching inside would + use. That version **passed against a deliberately reintroduced violation**, i.e. it was worse + than no test at all. Caught only by revert-checking it. + +Both forms are now verified to fail the guard, and the LISA driver has its own case: it may +legitimately have none of this, but "none" and "half" are different, and half is how a fork +rots. + +## What is deliberately NOT in it + +* The other six samplers, and the other consumers. One worked example first, on purpose. +* No removal of `_rvs_is_fairdraw` / `_rvs_is_pooled`. They stay until the last consumer that + reads them is migrated, and the agreement test above holds them to the record in the meantime. +* Nothing in the LISA driver: it is being caught up separately, and its 36 post-rebind reads are + all `BENIGN`/`PER_ROW` (it never pools or reweights). + +## Review answers (2026-08-13) + +### 1. Naming -> `_rvs_record` (RESOLVED) + +Underscored, per review: these are local to the sampler even though the goal is to standardise +the *concept* across the different integrators. Applied throughout this branch. + +### 2. Should the record hold the RETAINED rows too? -> MEASURED, and the answer differs by sampler + +This is an operations question, so it was measured rather than argued. +`measure_retained_set_memory.py`, run with no fair draw so `_rvs` **is** the retained set +(log: `RETAINED_SET_MEMORY_2026-08-13.log`): + +| sampler | nmax | ntotal | retained rows | cols | record MB | +|---|---|---|---|---|---| +| AV | 200k | 200,886 | 7,934 | 9 | 0.5 | +| AV | 400k | 261,900 | 16,242 | 9 | 1.1 | +| AV | 800k | 322,587 | 25,374 | 9 | 1.7 | +| portfolio | 200k | 200,000 | 199,641 | 12 | 18.3 | +| portfolio | 400k | 400,000 | 399,639 | 12 | 36.6 | +| portfolio | 800k | 800,000 | 799,637 | 12 | 73.2 | + +Extrapolated: **AV ~0.9 MB per million `nmax`** (~4 MB at `nmax`=4e6); +**portfolio ~92 MB per million** (~**384 MB** at `nmax`=4e6). + +The two differ because AV keeps only the in-volume (retained) subset, which grows far more +slowly than `ntotal`, while the portfolio's `_rvs` holds **every draw** -- so its cost is set +by `nmax` directly, and 384 MB per ILE process is a real operational cost when many ILE jobs +share a node. + +**Recommendation: do not hold the raw retained set unbounded.** Note the portfolio's retained +set is mostly ballast: on the collapsed pass this work is about, the finite fraction is ~1e-5, +so the vast majority of those 384 MB is `-inf` rows that no consumer can use. +`make_warm_seed_reserve` already solves exactly this -- a bounded, finite-stratified copy +(`n_max=20000`) with the exact pre-cap weight total recorded alongside. So: + +* have `_rvs_record` **reference the existing reserve** rather than take its own copy; +* for AV, keeping the full retained set is essentially free (~4 MB) and could be an opt-in; +* revisit only if a consumer turns up that provably needs unbounded retained rows. + +That closes most of the value (the reserve is what #79's lnZ fallback wants) at a cost already +being paid today. + +### 3. "Does the LISA twin follow?" -> the question was badly posed; there is NO separate integrator + +Clarifying, because the original wording implied something untrue. **LISA uses the same +integrators.** Both drivers import exactly the same set: + +``` +mcsampler, mcsamplerEnsemble, mcsamplerGPU, mcsamplerAdaptiveVolume, mcsamplerPortfolio +``` + +So `_rvs_record` reaches LISA **for free** the moment the samplers set it -- there is no +LISA-side decision in this design, and no reason to have a separate integrator. + +The divergence is in the **driver script**, `bin/integrate_likelihood_extrinsic_batchmode_lisa` +(2,526 lines against the main driver's 4,563), which is a fork of an older ILE and has none of +the machinery this line of work touched: + +| helper / feature | main | lisa | +|---|---|---| +| `ln_weights_from_rvs` | 12 | **0** | +| `_pool_replica_rvs` | 2 | **0** | +| `_lnZ_of_rvs` / `_kish_neff_of_rvs` | 7 / 2 | **0** | +| L0 rescue (`sampler_warmstart_retry_neff`) | 3 | **0** | +| sequential warm start | 6 | **0** | +| replicas, `.dgrid`, proposal breadcrumb | 4 / 1 / 4 | **0** | + +So LISA has **no consumer that needs migrating**: its 36 post-rebind `_rvs` reads are all the +MAP-seed and export pattern, already classified `BENIGN`/`PER_ROW` in the audit ledger, and it +never pools or re-weights. + +**The real issue is driver duplication, not integrator divergence** -- two forks of one ILE, one +of which silently misses every fix. That is a separate and larger problem than this design, and +is called out here only so it is not mistaken for one. + +## Original open questions (superseded by the answers above) + +1. **`_rvs_record` vs `_rvs_record`.** Public reads better for something consumers are meant to + use, but every other sampler attribute of this kind is underscored. +2. **Should the record hold the RETAINED rows too?** It would close the remaining `BROKEN` entry + (#79's cross-source lnZ fallback) and let `.dslice` reweight properly instead of falling back + to all-fresh. It also costs memory on a portfolio, whose `_rvs` holds every draw. The + `_warm_seed_reserve` precedent says "a bounded copy, stratified by finite-ness" is affordable; + whether the full set is, is a real question and I have not measured it. +3. **Does the LISA twin follow, or diverge on purpose?** It carries 36 of the 131 post-rebind + reads and none of the helpers this work added. + +## Internal vs public records (2026-08-14) + +Replica pooling has to thread each block's record into `_pool_replica_rvs`, so that block's +weights are derived with *its* convention rather than one `use_lnL` asserted over the whole set. +Review's constraint on that: + +> *"per-replica record threading is fine, as long as it's clear some of those records are +> 'internal' and not exposed for the user -- just because we had to hand back the structure +> doesn't mean we want them to use it."* + +So "internal" is a marker with teeth, not a naming convention: + +* `RvsRecord.as_internal()` returns a **view** -- same columns, provenance and reserve by + reference, only the marker differs. Copying every replica's columns would reintroduce exactly + the memory cost the reserve-by-reference decision avoided. +* **`set_samples()` refuses an internal record**, raising rather than storing it. The public + accessor therefore *cannot* yield one, whatever a future caller tries. +* The marker survives `snapshot()`, so snapshot/restore cannot launder an internal record into a + publishable one. +* `_pool_replica_rvs` filters the threaded records in lockstep with `rep_rvs`/`rep_lnZ`, and + `_block_record()` uses one only when its `.columns` **is** that block's dict -- the same + identity guard as everywhere else, one level down. + +## Where `use_lnL` still survives, stated plainly + +Every ILE weight derivation that *can* consult a record now does. `use_lnL` remains as the +**fallback** in three places, so `return_lnI` is **not yet deletable**: + +1. `ln_weights_for_posterior`, when no record describes the columns (an unconverted sampler, or + a record that has fallen out of step); +2. `_lw_of`, the shared resolver behind `_lnZ_of_rvs` / `_kish_neff_of_rvs`, same reason; +3. `_pool_replica_rvs` rebuilding the cached `log_weights`/`weights` columns on the **pooled + output** -- no record can exist for it yet, since it is the thing being constructed. + +(1) and (2) disappear when every sampler and consumer is converted. (3) needs the pooled record +built inside the pooler rather than at its call site. None of that is done here. diff --git a/MonteCarloMarginalizeCode/Code/RIFT/integrators/VALIDATION_rvs_weight_migration.md b/MonteCarloMarginalizeCode/Code/RIFT/integrators/VALIDATION_rvs_weight_migration.md new file mode 100644 index 000000000..f5b628baf --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/RIFT/integrators/VALIDATION_rvs_weight_migration.md @@ -0,0 +1,334 @@ +# Validation plan: migrating the weight path to `rec.log_weights()` + +The remaining step of option A is to move consumers off +`ln_weights_from_rvs(rvs, use_lnL=...)` and onto `rec.log_weights()`. That is what finally +removes the reason for `use_lnL` to exist, and lets `return_lnI` become historical. + +It is also the step that touches the number every science product is built from, so this is the +plan **before** the change, not after. Nothing here has been started. + +## The key measured fact + +**At a fixed `--run-seed`, the shape gate is deterministic to the bit.** Two runs of +`shape_recovery.py --preset quick --samplers AV,GMM --dims 2 --ncomps 2 --target-seeds 101 +--run-seed 987654` on identical code differ in exactly one field: + +| field | run A | run B | +|---|---|---| +| `wallclock` | 8.455 | 3.753 | +| `js`, `n_eff`, `lnI`, `mean_pull`, `width_ratio`, `corr_diff_max`, `bias_ln`, `rel_err`, `n_ess`, `n_eval` | **identical** | **identical** | + +That was worth checking rather than assuming: the first comparison reported "DIFFER" and looked +like it had killed this whole approach, until the diff turned out to be the timer. + +**So the acceptance criterion for a pure refactor is BIT-IDENTITY, not "within tolerance."** +That is far more sensitive than the gate's own thresholds (`TOL_WORSE` js 0.005, pull 0.05, +width 0.05) and it removes the stochastic-flip problem `run_shape_recovery.sh` warns about at +length -- its `--confirm-repeats` machinery exists for cells sitting on the `n_eff >= 100` floor, +and a refactor should never produce a differing cell at all. **Any** non-`wallclock` difference +is a signal, and should be treated as one rather than compared against a tolerance. + +## Tiers, cheapest first + +### 0. Bit-identity on the shape gate (the main event) + +``` +run_shape_recovery.sh base.json +run_shape_recovery.sh cand.json +# then compare ALL metric fields for exact equality, ignoring wallclock +``` + +`compare_shape_results.py` applies tolerances, which is right for a behaviour change and too +weak here. For a refactor, compare exactly. Fall back to `compare_shape_results.py +--confirm-base-checkout ... --confirm-cand-checkout ... --confirm-repeats 5` only if a cell does +differ and the question becomes whether the difference is real. + +### 1. Independent-route cross-check (the falsification) + +`shape_recovery.py` carries **its own** `log_weights_from_rvs()` -- a third implementation, +independent of both `ln_weights_from_rvs` and `RvsRecord.log_weights()`, written to be "tolerant +of the heterogeneous `_rvs` conventions". Assert the three agree on the same records, per +backend, including `mcsamplerEnsemble` in **both** `use_lnL` modes. + +This is the check that can actually falsify the migration, as opposed to testing it against +itself. + +### 2. Fast integrator CI + +- `.travis/test-integrate.sh` -> `test/test_mcsamplerEnsemble_extended.py` (AC/GMM/AV recover + a known integral to ~1.0) +- `test_fairdraw_double_weighting.py`, `test_seq_warmstart_seed.py`, `test_l0_rescue_seed.py`, + `test_av_empty_live_volume.py`, `test_portfolio_fairdraw_backend.py`, `test_rvs_record.py` +- both audit gates (`audit_rvs_fairdraw.py --check`, `audit_backend_contracts.py --check`) + +### 3. Full ILE run + +`.travis/test-run.sh` and `test-run-alts.sh` clone `ILE-GPU-Paper` and run +`make test_workflow_batch_gpu_lowlatency`, plus `test-coord.sh` / `test-posterior.sh`. Needs +network access and is GPU-shaped; on CIT it must run on a **different host from the session**, +one campaign per host, with `OMP_NUM_THREADS=1`. + +## Sequencing, and the one trap in it + +**`shape_recovery.py` is itself an `_rvs` consumer** -- it reads `s._rvs` directly and derives +weights with its own helper. So it is both the ruler and a migration target. + +**Migrate the ILE weight path first; validate with the shape gate UNCHANGED; migrate the gate +only afterwards, as its own step with its own before/after.** Changing the ruler and the thing +being measured in one commit destroys exactly the independence that makes tier 1 worth anything. + +## What would make me stop + +* a cell differs and `--confirm-repeats 5` says the difference is real -> the migration is not a + refactor, and the change is wrong until that is explained; +* the three weight implementations disagree anywhere, in either Ensemble mode; +* tier 3 cannot be run at all -> say so plainly and mark the migration provisional rather than + shipping on tiers 0-2 and calling it validated. + +--- + +# RESULTS (2026-08-14), migration of `ln_weights_for_posterior` + +Base `1dcabd27`. Command, both arms identical: + +``` +shape_recovery.py --preset quick --samplers AV,GMM,AC,portfolio \ + --dims 2,4 --ncomps 1,2 --target-seeds 101,202 --run-seed 987654 --jobs 1 +``` + +| tier | result | +|---|---| +| 0. shape gate bit-identity | **PASS** -- 32 cells, 19 metrics each, byte-identical apart from `wallclock` | +| 1. independent third implementation | **PASS** -- AV, Ensemble (both `use_lnL` modes), mcsampler | +| 2. fast CI + both audit gates | **PASS** -- 276 passed, 4 skipped; AC/GMM/AV recover the known integral to 0.939 / 0.997 / 0.933 | +| 3. full ILE run | **NOT RUN** -- needs network + GPU; see below | + +## What the validation caught + +**A real defect in the new API, before it shipped.** `log_weights()` was first written as +`log_likelihood() + log_prior() - log_sampling_prior()`. That is wrong on the linear column +family: `ln_weights_from_rvs` applies a **conjunctive** keep-mask (`ig>0 & jp>0 & js>0`, whole +row to `-inf`), while evaluating the three terms independently gives `-inf - (-inf) = NaN`. A +NaN weight poisons every downstream sum; `-inf` is a real zero. Found by fuzzing the two +implementations against each other **before** switching -- 1200 randomized records, systematic +divergence on both linear families. + +**And a test that could not fail.** `test_three_independent_weight_implementations_agree` -- +tier 1, the end-to-end check -- **passes with that defect reintroduced**, because real sampler +records have positive priors and never reach the masked rows. It is a decoration for this +defect. The test with teeth is the randomized one +(`test_log_weights_matches_the_canonical_form_including_out_of_support_rows`), which was +revert-checked: bug in -> FAIL, bug out -> PASS. Both are kept; only the second is load-bearing. + +## Tier 3 is NOT discharged + +`.travis/test-run.sh` / `test-run-alts.sh` clone `ILE-GPU-Paper` and run +`make test_workflow_batch_gpu_lowlatency`. That needs network egress and is GPU-shaped, and on +CIT must run on a different host from the session. **It has not been run.** Per the plan's own +stopping rule, this migration is therefore **provisional** until it has: tiers 0-2 are strong +evidence that the change is a pure refactor, but they do not exercise a real waveform, a real +PSD, or the GPU code path. + +--- + +# ADVERSARIAL REVIEW (2026-08-14), and what it found + +A self-review of the full branch diff produced **6 findings, one of which was a production +regression this change had introduced**. All six are fixed, each with a regression test. + +**HIGH -- replicas on a linear backend would have DROPPED THE EVENT.** `_pool_replica_rvs` +keeps only the *intersection* of replica keys, so pooling `adaptive_cartesian` (or Ensemble +without `use_lnL`) replicas yields a bare `integrand` column. The ILE built the pooled record +with no `integrand_is_log`, so `log_weights()` correctly refused to guess -- and that +`ValueError` escaped the **unwrapped** `.dgrid` exporter, out of `analyze_event`, into the +per-event handler, which skips the event and writes an empty `.dat`. The convention is now +taken from the pre-pool record, falling back to `rvs_integrand_is_lnL`. + +Note what this says about the tiers: **tier 0 was bit-identical before and after the fix**, +because the shape gate does not run the ILE at all, and neither the fast tests nor tier 1 +exercise replica pooling on a linear backend. Bit-identity is a strong check of the code path +it covers and says nothing about the paths it does not. + +The other five: the caller's `convert` was silently dropped on the record path; the pooled +record's block provenance was built from *unfiltered* replica lists while `_pool_replica_rvs` +filters in lockstep; `_sampler_keeps_records` tested "has a record right now" while being named +and documented as "participates at all"; the record restored after an L0 reject could never +match its columns and was therefore inert rather than belt-and-braces; and an orphaned comment +fragment sat at all seven rebind sites. + +Tier 0 was re-run after the fixes: still **bit-identical** to base across all 32 cells. + +--- + +# TIER 3 (2026-08-18): the full ILE run, DISCHARGED + +Run on `ldas-pcdev12` (4x A100-SXM4-80GB), IGWN CVMFS python 3.11.14, cupy 12.0.0, lal 7.7.0. +Base = `364a22fd` (merge-base), candidate = `63b50062`, both commit-gated against the remote. +Raw data, scripts and the analysis are committed under +`test/expensive_before_merging/integrators/tier3/`. + +## The thing that had to be established first: this run is NOT deterministic + +Tier 0's acceptance criterion was bit-identity. **That criterion does not transfer here.** Two +runs of the *base* code, same `--seed 4242`, same host, same GPU: + +| | run 1 | run 2 | +|---|---|---| +| `lnL` | 66.2279 | 66.5573 | +| `neff` | 22.46 | 4.19 | +| `sigma_lnL` | 0.1376 | 0.2637 | + +The first base-vs-candidate comparison showed `dlnL` 0.11 and looked like a regression. It is +**smaller than the spread base shows against itself** (0.33). Had I stopped at the first diff I +would have reported a regression that does not exist; had I stopped at "it differs, GPU runs +differ, fine" I would have had no argument at all. So tier 3 is a comparison of DISTRIBUTIONS +with a MEASURED null, not a diff. + +## Getting a real run at all: three dead ends, all pre-existing + +The first four attempts failed, and none of the failures was mine -- **every one reproduces on +the unmodified base checkout**, which is the only reason they are not blockers: + +1. `TypeError: ... argument 1 of type 'REAL8'` in `ComputeYlms`. **My option set was wrong**, not + the code: without `--vectorized` the ILE takes the scalar loop at line ~3130 and hands an + ARRAY of inclinations to a scalar `lal.SpinWeightedSphericalHarmonic`. `--force-xpy` does not + help. `--vectorized --gpu` is the fix. +2. `--internal-use-lnL` + `adaptive_cartesian_gpu` (the DEFAULT sampler) dies in + `mcsamplerGPU.integrate_log` mixing a numpy `maxval` into a cupy expression. +3. `--internal-use-lnL` + `adaptive_cartesian` (CPU) dies with `'MCSampler' object has no + attribute 'identity_convert'`. +4. `portfolio` + `--internal-use-lnL` + replicas + `.dgrid` exits 1 with `'NoneType' object is + not iterable`. + +**(2) and (3) together mean the `.dgrid` export is currently UNREACHABLE on both +linear-integrand backends**, because the exporter is gated on `opts.internal_use_lnL`. That is +worth knowing independently of this branch: it bounds how much of the HIGH finding's blast radius +is reachable in production today. Only AV and GMM can emit a `.dgrid`. Filed separately; not +fixed here, because fixing them is not this branch's job and would have made the arms differ. + +## The NoLoop path was PROVEN, not assumed + +`noloop_probe.py` wraps the likelihood entry points and counts calls in a real run: + +``` +NOLOOP-PROBE: first call to DiscreteFactoredLogLikelihoodViaArrayVectorNoLoop time_interp='nearest' xpy=cupy +NOLOOP-PROBE COUNTS: {'...NoLoop': 20, '...NoLoopOrig': 0, 'FactoredLogLikelihoodTimeMarginalized': 0} +``` + +`xpy=cupy`, 20 calls, scalar path 0. Config D re-runs the whole ensemble with +`--interpolate-time True` for the cubic time interpolation as well. + +## Design: 5 configs x 2 arms x 30 replicates = 300 runs, 300 clean + +Arms are **interleaved within each replicate** so any drift in machine state hits both equally, +and `CUDA_VISIBLE_DEVICES` is pinned to an idle card (index 0 was at 100% from another user). + +| cfg | what it exercises | +|---|---| +| A | GPU linear backend (`integrand` = L), plain | +| B | linear backend + **replica pooling** | +| D | cubic NoLoop time interpolation | +| AV | AV (lnL family) + pooling + **`.dgrid` export** | +| GMM | GMM (lnL family) + pooling + **`.dgrid` export** | + +B/AV/GMM are the point: **tiers 0-2 were structurally blind to replica pooling** -- that is +exactly how the adversarial review's HIGH finding survived a bit-identical tier 0. + +## Result + +19 metric comparisons (lnL, sigma_lnL, neff, and the `.dgrid` grid statistics), two-sided +**permutation test** on the arm labels (20000 shuffles, no normality assumption): + +**0 of 19 comparisons reach p<0.05. Expected by chance at alpha=0.05: ~1.** Smallest p is 0.262. + +And the null was measured rather than trusted: an **A/A control** that splits the base runs into +two pseudo-arms of identical code and runs the same test gives **0 of 19** as well -- so the test +is not simply insensitive to everything. + +## What this does NOT establish + +"No significant difference" is only as strong as the sensitivity behind it. Minimum detectable +shift in `lnL` at 80% power, n=30/arm: + +| cfg | MDE (nats) | observed abs(d) | +|---|---|---| +| A | 0.150 | 0.049 (32%) | +| B | 0.087 | 0.019 (22%) | +| D | 0.141 | 0.021 (15%) | +| AV | 0.045 | 0.018 (41%) | +| GMM | 0.354 | 0.073 (21%) | + +So this ensemble rules out a systematic bias larger than **~0.05 nats on AV** and **~0.15 nats on +the noisy GPU-linear config** -- not a bias below that. Every observed difference sits well +inside its own detection floor. It is also ONE event, ONE waveform (SEOBNRv4), `l-max 2`, zero +noise. + +**Tier 3 is discharged and the migration is no longer provisional.** The stopping rule in the +plan above -- "tier 3 cannot be run at all -> mark the migration provisional" -- no longer +applies. + +--- + +# RE-VALIDATION (2026-08-18) after merging 155 commits of `rift_O4d` + +The tiers above ran against `364a22fd`. Taking the PR out of draft required merging current +`rift_O4d`, which had moved **155 commits**, so every tier was re-run against the new base +`36ec85ae`. "The base did not move under it" is exactly the kind of assumption this document +exists to distrust. + +## The base changed the EXPERIMENT, not just the code + +**The full ILE run is now DETERMINISTIC at fixed `--seed`.** On the old base it was not -- two +runs of identical code differed by dlnL 0.33 with `neff` 4.19 vs 22.46, which is why tier 3 was +built as a distribution comparison with a permutation test. The new base carries the seeding +work (notably "one counter registry, not two"), and at fixed seed the run now reproduces. + +So the tier-3 rerun answers a **stronger** question than the original could: + +| | old base `364a22fd` | new base `36ec85ae` | +|---|---|---| +| same code, same seed, twice | dlnL **0.33**, neff 4.2 vs 22.5 | reproduces | +| what tier 3 can therefore test | distributions (permutation test) | **near bit-identity** | + +## Tier 3 rerun: 150 runs, 5 configs x 2 arms x 15 replicates, all clean + +Base and candidate agree to **floating-point round-off**, per replicate: + +| config | max relative base-vs-cand difference | in ulps | +|---|---|---| +| AV (lnL family + pooling + `.dgrid`) | **0 -- exactly identical** | 0 | +| GMM (lnL family + pooling + `.dgrid`) | **0 -- exactly identical** | 0 | +| A (GPU linear, plain) | 5.6e-14 (`neff`), 2.1e-16 (`lnL`) | ~254 / 1 | +| B (GPU linear + replica pooling) | 6.2e-14 (`sigma`), 4.3e-16 (`lnL`) | ~281 / 2 | +| D (cubic NoLoop time interpolation) | 2.9e-13 (`neff`), 2.8e-15 (`lnL`) | ~1301 / 12 | + +**This is not bit-identity and should not be reported as such.** On the linear-integrand +configs the candidate reaches the same weights by a different summation order -- the record path +and the canonical derivation add the same terms in a different sequence -- and the residual +amplifies through the sums and ratios that produce `neff` and `sigma_lnL`. `lnL` itself moves by +1-12 ulp. The two lnL-family configs, which exercise the record path hardest (pooling plus the +`.dgrid` export), come out exactly equal. + +The permutation analysis was re-run anyway and is now uninformative by construction: 0 of 19 +comparisons reach p<0.05 because the two arms are the same numbers. + +## Tier 0 rerun: bit-identical + +32 cells, all metric rows **byte-identical** between arms. One cell (`AV mix_d4_n1_s202`, +lnZ bias -0.265) is a strict FAILURE -- **identically in both arms**, so it is a property of the +new base, not of this change. + +A process note worth keeping: the first tier-0 attempt had two copies of the runner writing the +same output files concurrently, because a `nohup` I believed had been killed was still alive. +The metric rows agreed, but agreement from possibly-interleaved output is not evidence. It was +re-run once, cleanly, sequentially, and that is the run reported here. + +## Everything else re-run against the new base + +| check | result | +|---|---| +| integrator CI lane (incl. `test_rvs_record.py`) | 245 passed, 4 skipped | +| `.travis/test-lisa.sh` | 263 passed | +| `audit_rvs_fairdraw.py --check` | OK, 160 post-rebind reads classified | +| `audit_backend_contracts.py --check` | OK, 6 backend contracts | +| both generated ledgers vs their generators | in sync | diff --git a/MonteCarloMarginalizeCode/Code/RIFT/integrators/mcsampler.py b/MonteCarloMarginalizeCode/Code/RIFT/integrators/mcsampler.py index 2f01140dd..ab4a447ed 100644 --- a/MonteCarloMarginalizeCode/Code/RIFT/integrators/mcsampler.py +++ b/MonteCarloMarginalizeCode/Code/RIFT/integrators/mcsampler.py @@ -33,13 +33,15 @@ rosDebugMessages = True +from RIFT.integrators.rvs_record import RvsRecord, SamplerOutputMixin # see DESIGN_rvs_naming.md + class NanOrInf(Exception): def __init__(self, value): self.value = value def __str__(self): return repr(self.value) -class MCSampler(object): +class MCSampler(SamplerOutputMixin, object): """ Class to define a set of parameter names, limits, and probability densities. """ @@ -452,6 +454,9 @@ def integrate(self, func, *args, **kwargs): # flag is not the same predicate, since the draw is skipped when it would not # shrink the record. Reset per pass: samplers are reused across events. self._rvs_is_fairdraw = False + # The record describes THIS pass only. Cleared with the flag above and set + # below, so it can never survive into a pass it does not describe. + self._rvs_record = None n_extr = kwargs["igrand_fairdraw_samples_max"] if "igrand_fairdraw_samples_max" in kwargs else None tripwire_fraction = kwargs["tripwire_fraction"] if "tripwire_fraction" in kwargs else 2 # make it impossible to trigger @@ -783,6 +788,16 @@ def integrate(self, func, *args, **kwargs): print(" mcsampler: MC-error diagnostics failed ({}); continuing.".format(_e_diag), file=sys.stderr) # Do a fair draw of points, if option is set + # (DESIGN_rvs_naming.md) _rvs is the RETAINED set at this point -- pruned, + # perhaps, but never resampled. Record that before the draw below can change what it + # means, so "not resampled" is a statement the record makes rather than the absence of + # one. The reserve rides along BY REFERENCE where the sampler keeps one (AV and the + # portfolio); None elsewhere is the honest answer, not a gap. + # This backend writes NO log columns: `integrand` is always linear L, so the + # record is told so and log_likelihood() is unambiguous for it too. + self._rvs_record = RvsRecord.retained( + self._rvs, reserve=getattr(self, '_warm_seed_reserve', None), + integrand_is_log=False) if bFairdraw and not(n_extr is None): n_extr = int(numpy.min([n_extr,1.5*eff_samp,1.5*neff])) print(" Fairdraw size : ", n_extr) @@ -798,6 +813,15 @@ def integrate(self, func, *args, **kwargs): self._rvs[key] = self._rvs[key][indx_list] self._rvs_is_fairdraw = True # _rvs is now an EXPORT resample, rows already ~ w + # ...and now it is an export resample. n_retained comes from that record's + # PROVENANCE, which captured the count eagerly -- NOT from len(), which reads + # self._rvs and would return the POST-draw length: the retained record holds a + # REFERENCE to the live dict this block has just replaced in place. That is + # this project's own bug class, so it is spelled out rather than assumed. + self._rvs_record = RvsRecord.fair_draw( + self._rvs, n_retained=self._rvs_record.n_retained(), + reserve=getattr(self, '_warm_seed_reserve', None), + integrand_is_log=False) # Create extra dictionary to return things dict_return ={} if convergence_tests is not None: diff --git a/MonteCarloMarginalizeCode/Code/RIFT/integrators/mcsamplerAdaptiveVolume.py b/MonteCarloMarginalizeCode/Code/RIFT/integrators/mcsamplerAdaptiveVolume.py index 865a09917..afe08135c 100644 --- a/MonteCarloMarginalizeCode/Code/RIFT/integrators/mcsamplerAdaptiveVolume.py +++ b/MonteCarloMarginalizeCode/Code/RIFT/integrators/mcsamplerAdaptiveVolume.py @@ -12,6 +12,7 @@ import numpy np=numpy #import numpy as np from RIFT.precision import RiftFloat # platform-portable replacement for np.float128 +from RIFT.integrators.rvs_record import RvsRecord, SamplerOutputMixin # see DESIGN_rvs_naming.md from scipy import integrate, interpolate, special import itertools import functools @@ -111,6 +112,27 @@ def _av_trace(msg): print(" [AV trace] " + msg) sys.stdout.flush() +def _warm_seed_rng(seed, stream): + """RNG for the warm-start seed clouds (the bootstrap_from_* family). + + `seed=None` used to mean np.random.RandomState(None), which takes fresh OS + entropy and is reached by NOTHING that seed_everything touches -- so with the + driver's warm-start options on (their coverage floors default to 0.5, i.e. the + uniform cover cloud is drawn on every warm start) two invocations with the same + --seed built DIFFERENT live volumes, hence different draws and a different lnZ. + A warm seed cannot bias the integral, but it certainly moves it, which is + exactly what --seed exists to pin down. + + So derive the stream from the run's seed instead, advancing a counter per call + so successive warm starts (one per intrinsic point) stay independent rather than + all sharing one coverage cloud. An explicit integer `seed` still wins, and an + unseeded run still gets fresh entropy. + """ + if seed is not None: + return np.random.RandomState(seed) + from RIFT.integrators.seeding import next_derived_rng + return next_derived_rng(stream) + class NanOrInf(Exception): def __init__(self, value): self.value = value @@ -539,7 +561,7 @@ def warm_seed_scale_from_finite_points(points, lnL, box_lo, box_hi, axes, def build_warm_seed(points, lnL, box_lo, box_hi, axes, deltalnL=15.0, puff_width_frac=1.0 / 200, puff_scale='auto', puff_factor=2.0, - n_puff=2000, seed=0): + n_puff=2000, seed=None): """Build the L0 rescue's warm seed from a pass's own samples -> (seed, info). `points` (n, ndim) and `lnL` (n,) are the completed pass's draws. The seed is the @@ -594,7 +616,13 @@ def build_warm_seed(points, lnL, box_lo, box_hi, axes, deltalnL=15.0, used = 'fixed' cov_u = np.diag(np.full(len(ax), float(puff_width_frac) ** 2)) cov_u = cov_u * (float(puff_factor) ** 2) - rng = np.random.RandomState(seed) + # The puff was RandomState(0): deterministic, so it never broke same-seed + # reproducibility -- it broke the other half of what --seed means. Both driver call + # sites (L0 auto-rescue, sequential warm-start) omit `seed`, so EVERY intrinsic point + # of a run was puffed with the same standard normal deviates, and --seed 101 and + # --seed 202 got the identical cloud. A replicate-seed study then has its rescue arm + # frozen across arms, which understates exactly the run-to-run spread it is measuring. + rng = _warm_seed_rng(seed, 'av.build_warm_seed.puff') n_puff = int(n_puff) # scaled draws on the adaptive axes; the remaining axes get the isotropic width (the # grid puts one bin on them, so their only job is to not be a single repeated value) @@ -645,7 +673,7 @@ def sample_from_bins(xrange, dx, bu, ninbin, reject_out_of_range=False): return x -class MCSampler(object): +class MCSampler(SamplerOutputMixin, object): # COMPACT SUPPORT: this sampler's density is EXACTLY ZERO outside its contracted live volume, # so once seeded or contracted it cannot serve as the mixture's coverage guarantee. # mcsamplerPortfolio reads this to decide whether it must hold one member cold. @@ -1321,7 +1349,7 @@ def bootstrap_from_samples(self, samples, params=None, loglkl=None, enc_prob=0.9 cover_frac = float(np.clip(cover_frac, 0.0, 1.0)) _core = X # the concentrated proposal; sets the grid RESOLUTION if cover_frac > 0: - rng = np.random.RandomState(seed) + rng = _warm_seed_rng(seed, 'av.bootstrap_from_samples.cover') n_cover = max(int(cover_frac / (1.0 - cover_frac) * len(X)), 1) Xc = rng.uniform(self.my_ranges.T[0], self.my_ranges.T[1], size=(n_cover, len(self.params_ordered))) @@ -1356,7 +1384,7 @@ def bootstrap_from_gaussian(self, mean, cov, n=None, params=None, enc_prob=0.999 possibly-misspecified seed. Default 0.""" if not hasattr(self, 'my_ranges'): self.setup() - rng = np.random.RandomState(seed) + rng = _warm_seed_rng(seed, 'av.bootstrap_from_gaussian') mean = np.asarray(mean, dtype=float) cov = np.atleast_2d(np.asarray(cov, dtype=float)) if params is not None: @@ -1394,7 +1422,7 @@ def bootstrap_from_gaussian_mixture(self, means, covs, weights=None, n=None, unbiased.""" if not hasattr(self, 'my_ranges'): self.setup() - rng = np.random.RandomState(seed) + rng = _warm_seed_rng(seed, 'av.bootstrap_from_gaussian_mixture') means = [np.asarray(m, dtype=float) for m in means] covs = [np.atleast_2d(np.asarray(c, dtype=float)) for c in covs] k = len(means) @@ -1575,6 +1603,9 @@ def integrate_log(self, lnF, *args, xpy=xpy_default,**kwargs): # flag is not the same predicate, since the draw is skipped when it would not # shrink the record. Reset per pass: samplers are reused across events. self._rvs_is_fairdraw = False + # The record describes THIS pass only. Cleared with the flag above and set + # below, so it can never survive into a pass it does not describe. + self._rvs_record = None n_extr = kwargs["igrand_fairdraw_samples_max"] if "igrand_fairdraw_samples_max" in kwargs else None bShowEvaluationLog = kwargs['verbose'] if 'verbose' in kwargs else False @@ -1924,6 +1955,13 @@ def _eval_integrand(samples): # rel_var = np.exp(outvals[1]/2 - outvals[0] - np.log(self.ntotal)/2 ) # Do a fair draw of points, if option is set. CAST POINTS BACK TO NUMPY, IDEALLY + # (DESIGN_rvs_naming.md) _rvs is the RETAINED set at this point -- pruned, + # perhaps, but never resampled. Record that before the draw below can change what it + # means, so "not resampled" is a statement the record makes rather than the absence of + # one. The reserve rides along BY REFERENCE where the sampler keeps one (AV and the + # portfolio); None elsewhere is the honest answer, not a gap. + self._rvs_record = RvsRecord.retained( + self._rvs, reserve=getattr(self, '_warm_seed_reserve', None)) if bFairdraw and not(n_extr is None): n_extr = int(numpy.min([n_extr,1.5*identity_convert(eff_samp),1.5*neff])) print(" Fairdraw size : ", n_extr) @@ -1950,9 +1988,21 @@ def _eval_integrand(samples): self._rvs[key] = arr[:,indx_host] else: self._rvs[key] = arr[indx_host] - - + # (see DESIGN_rvs_naming.md) the same rows, under a name that says what + # they are, carrying their own provenance. Written HERE because this is the + # moment the meaning of _rvs changes -- from the retained set to an export + # resample -- and the whole point is that the change of meaning is recorded + # where it happens rather than reconstructed later from a flag someone else + # has to maintain. NOTHING READS THIS YET; it is a no-op on every output. self._rvs_is_fairdraw = True # _rvs is now an EXPORT resample, rows already ~ w + # ...and now it is an export resample. n_retained comes from that record's + # PROVENANCE, which captured the count eagerly -- NOT from len(), which reads + # self._rvs and would return the POST-draw length: the retained record holds a + # REFERENCE to the live dict this block has just replaced in place. That is + # this project's own bug class, so it is spelled out rather than assumed. + self._rvs_record = RvsRecord.fair_draw( + self._rvs, n_retained=self._rvs_record.n_retained(), + reserve=getattr(self, '_warm_seed_reserve', None)) # perform type conversion of all stored variables. VERY LARGE -- should only do this if we need it! if cupy_ok: for name in self._rvs: diff --git a/MonteCarloMarginalizeCode/Code/RIFT/integrators/mcsamplerEnsemble.py b/MonteCarloMarginalizeCode/Code/RIFT/integrators/mcsamplerEnsemble.py index a590caa4e..d075de11f 100755 --- a/MonteCarloMarginalizeCode/Code/RIFT/integrators/mcsamplerEnsemble.py +++ b/MonteCarloMarginalizeCode/Code/RIFT/integrators/mcsamplerEnsemble.py @@ -46,13 +46,15 @@ rosDebugMessages = True +from RIFT.integrators.rvs_record import RvsRecord, SamplerOutputMixin # see DESIGN_rvs_naming.md + class NanOrInf(Exception): def __init__(self, value): self.value = value def __str__(self): return repr(self.value) -class MCSampler(object): +class MCSampler(SamplerOutputMixin, object): @property def has_unbounded_support(self): @@ -642,6 +644,9 @@ def integrate(self, func, *args,**kwargs): # flag is not the same predicate, since the draw is skipped when it would not # shrink the record. Reset per pass: samplers are reused across events. self._rvs_is_fairdraw = False + # The record describes THIS pass only. Cleared with the flag above and set + # below, so it can never survive into a pass it does not describe. + self._rvs_record = None n_extr = kwargs["igrand_fairdraw_samples_max"] if "igrand_fairdraw_samples_max" in kwargs else None self.func = func @@ -762,6 +767,23 @@ def integrate(self, func, *args,**kwargs): - self.xpy.log(p_array) ) + # (DESIGN_rvs_naming.md) _rvs is the RETAINED set at this point -- pruned, + # perhaps, but never resampled. Record that before the draw below can change what it + # means, so "not resampled" is a statement the record makes rather than the absence of + # one. The reserve rides along BY REFERENCE where the sampler keeps one (AV and the + # portfolio); None elsewhere is the honest answer, not a gap. + # THE return_lnI CASE. What `integrand` holds is decided by return_lnI and by + # NOTHING ELSE: value_array above is `cumulative_values` (always lnL, whichever + # convention the CALLABLE used) when return_lnI, and exp() of it when not. use_lnL + # governs a different question -- whether the log columns were written alongside -- + # so recording it here would mislabel the supported return_lnI=True, use_lnL=False + # pass as linear, sending its negative-lnL rows to zero weight and taking log() of a + # log on the rest. Recording the convention HERE, once, where it is known, is what + # lets every consumer stop caring -- and lets return_lnI become historical material + # rather than something a caller must thread through. + self._rvs_record = RvsRecord.retained( + self._rvs, reserve=getattr(self, '_warm_seed_reserve', None), + integrand_is_log=bool(return_lnI)) if bFairdraw and not(n_extr is None): # scalars: use Python min on floats. self.xpy.min([list]) fails on cupy # (cupy.min has no list overload -> "'list' object has no attribute 'min'"), @@ -784,6 +806,15 @@ def integrate(self, func, *args,**kwargs): self._rvs[key] = self._rvs[key][indx_list] self._rvs_is_fairdraw = True # _rvs is now an EXPORT resample, rows already ~ w + # ...and now it is an export resample. n_retained comes from that record's + # PROVENANCE, which captured the count eagerly -- NOT from len(), which reads + # self._rvs and would return the POST-draw length: the retained record holds a + # REFERENCE to the live dict this block has just replaced in place. That is + # this project's own bug class, so it is spelled out rather than assumed. + self._rvs_record = RvsRecord.fair_draw( + self._rvs, n_retained=self._rvs_record.n_retained(), + reserve=getattr(self, '_warm_seed_reserve', None), + integrand_is_log=bool(return_lnI)) dict_return = {} if dict_return_q: dict_return["integrator"] = integrator diff --git a/MonteCarloMarginalizeCode/Code/RIFT/integrators/mcsamplerGPU.py b/MonteCarloMarginalizeCode/Code/RIFT/integrators/mcsamplerGPU.py index e247ee719..a1926465f 100644 --- a/MonteCarloMarginalizeCode/Code/RIFT/integrators/mcsamplerGPU.py +++ b/MonteCarloMarginalizeCode/Code/RIFT/integrators/mcsamplerGPU.py @@ -65,6 +65,8 @@ cupy_ok = False cupy_pi = np.pi +from RIFT.integrators.rvs_record import RvsRecord, SamplerOutputMixin # see DESIGN_rvs_naming.md + def set_xpy_to_numpy(): xpy_default=numpy identity_convert = lambda x: x # trivial return itself @@ -105,7 +107,7 @@ def __init__(self, value): def __str__(self): return repr(self.value) -class MCSampler(object): +class MCSampler(SamplerOutputMixin, object): """ Class to define a set of parameter names, limits, and probability densities. """ @@ -676,6 +678,9 @@ def integrate_log(self, lnF, *args, xpy=xpy_default,**kwargs): # flag is not the same predicate, since the draw is skipped when it would not # shrink the record. Reset per pass: samplers are reused across events. self._rvs_is_fairdraw = False + # The record describes THIS pass only. Cleared with the flag above and set + # below, so it can never survive into a pass it does not describe. + self._rvs_record = None n_extr = kwargs["igrand_fairdraw_samples_max"] if "igrand_fairdraw_samples_max" in kwargs else None bShowEvaluationLog = kwargs['verbose'] if 'verbose' in kwargs else False @@ -937,6 +942,13 @@ def inner(arg): print(" mcsamplerGPU: MC-error diagnostics failed ({}); continuing.".format(_e_diag)) # Do a fair draw of points, if option is set. CAST POINTS BACK TO NUMPY, IDEALLY + # (DESIGN_rvs_naming.md) _rvs is the RETAINED set at this point -- pruned, + # perhaps, but never resampled. Record that before the draw below can change what it + # means, so "not resampled" is a statement the record makes rather than the absence of + # one. The reserve rides along BY REFERENCE where the sampler keeps one (AV and the + # portfolio); None elsewhere is the honest answer, not a gap. + self._rvs_record = RvsRecord.retained( + self._rvs, reserve=getattr(self, '_warm_seed_reserve', None)) if bFairdraw and not(n_extr is None): n_extr = int(numpy.min([n_extr,1.5*identity_convert(eff_samp),1.5*neff])) print(" Fairdraw size : ", n_extr) @@ -955,6 +967,14 @@ def inner(arg): self._rvs_is_fairdraw = True # _rvs is now an EXPORT resample, rows already ~ w + # ...and now it is an export resample. n_retained comes from that record's + # PROVENANCE, which captured the count eagerly -- NOT from len(), which reads + # self._rvs and would return the POST-draw length: the retained record holds a + # REFERENCE to the live dict this block has just replaced in place. That is + # this project's own bug class, so it is spelled out rather than assumed. + self._rvs_record = RvsRecord.fair_draw( + self._rvs, n_retained=self._rvs_record.n_retained(), + reserve=getattr(self, '_warm_seed_reserve', None)) # Create extra dictionary to return things dict_return ={} if convergence_tests is not None: @@ -1096,6 +1116,9 @@ def integrate(self, func, *args, **kwargs): # flag is not the same predicate, since the draw is skipped when it would not # shrink the record. Reset per pass: samplers are reused across events. self._rvs_is_fairdraw = False + # The record describes THIS pass only. Cleared with the flag above and set + # below, so it can never survive into a pass it does not describe. + self._rvs_record = None n_extr = kwargs["igrand_fairdraw_samples_max"] if "igrand_fairdraw_samples_max" in kwargs else None bShowEvaluationLog = kwargs['verbose'] if 'verbose' in kwargs else False @@ -1371,6 +1394,18 @@ def inner(arg): self._rvs[key] = self._rvs[key][indx_list] # Do a fair draw of points, if option is set. CAST POINTS BACK TO NUMPY, IDEALLY + # (DESIGN_rvs_naming.md) _rvs is the RETAINED set at this point -- pruned, + # perhaps, but never resampled. Record that before the draw below can change what it + # means, so "not resampled" is a statement the record makes rather than the absence of + # one. The reserve rides along BY REFERENCE where the sampler keeps one (AV and the + # portfolio); None elsewhere is the honest answer, not a gap. + # THIS ENTRY POINT IS UNAMBIGUOUSLY LINEAR. A use_lnL=True call was handed off to + # integrate_log at the top, so everything reaching here stored `integrand` = the linear + # value and wrote no log columns at all. Say so on the record: without it a consumer + # calling log_likelihood()/log_weights() gets a raise for the DEFAULT GPU mode. + self._rvs_record = RvsRecord.retained( + self._rvs, reserve=getattr(self, '_warm_seed_reserve', None), + integrand_is_log=False) if bFairdraw and not(n_extr is None): n_extr = int(numpy.min([n_extr,1.5*eff_samp,1.5*neff])) print(" Fairdraw size : ", n_extr) @@ -1386,6 +1421,15 @@ def inner(arg): self._rvs[key] = identity_convert(self._rvs[key][indx_list]) self._rvs_is_fairdraw = True # _rvs is now an EXPORT resample, rows already ~ w + # ...and now it is an export resample. n_retained comes from that record's + # PROVENANCE, which captured the count eagerly -- NOT from len(), which reads + # self._rvs and would return the POST-draw length: the retained record holds a + # REFERENCE to the live dict this block has just replaced in place. That is + # this project's own bug class, so it is spelled out rather than assumed. + self._rvs_record = RvsRecord.fair_draw( + self._rvs, n_retained=self._rvs_record.n_retained(), + reserve=getattr(self, '_warm_seed_reserve', None), + integrand_is_log=False) # Create extra dictionary to return things dict_return ={} if convergence_tests is not None: diff --git a/MonteCarloMarginalizeCode/Code/RIFT/integrators/mcsamplerNFlow.py b/MonteCarloMarginalizeCode/Code/RIFT/integrators/mcsamplerNFlow.py index a0e716ae1..9bce9bdbe 100644 --- a/MonteCarloMarginalizeCode/Code/RIFT/integrators/mcsamplerNFlow.py +++ b/MonteCarloMarginalizeCode/Code/RIFT/integrators/mcsamplerNFlow.py @@ -104,6 +104,8 @@ cupy_ok = False cupy_pi = np.pi +from RIFT.integrators.rvs_record import RvsRecord, SamplerOutputMixin # see DESIGN_rvs_naming.md + def set_xpy_to_numpy(): xpy_default=numpy identity_convert = lambda x: x # trivial return itself @@ -397,7 +399,7 @@ def train_flow(self, samples_in: List[List[float]], return losses -class MCSampler(MCSamplerGeneric): +class MCSampler(SamplerOutputMixin, MCSamplerGeneric): """ Class to define a set of parameter names, limits, and probability densities. """ @@ -813,6 +815,9 @@ def integrate_log(self, lnF, *args, xpy=xpy_default,**kwargs): # flag is not the same predicate, since the draw is skipped when it would not # shrink the record. Reset per pass: samplers are reused across events. self._rvs_is_fairdraw = False + # The record describes THIS pass only. Cleared with the flag above and set + # below, so it can never survive into a pass it does not describe. + self._rvs_record = None n_extr = kwargs["igrand_fairdraw_samples_max"] if "igrand_fairdraw_samples_max" in kwargs else None bShowEvaluationLog = kwargs['verbose'] if 'verbose' in kwargs else True @@ -965,6 +970,13 @@ def _eval_integrand(cols): # rel_var = np.exp(outvals[1]/2 - outvals[0] - np.log(self.ntotal)/2 ) # Do a fair draw of points, if option is set. CAST POINTS BACK TO NUMPY, IDEALLY + # (DESIGN_rvs_naming.md) _rvs is the RETAINED set at this point -- pruned, + # perhaps, but never resampled. Record that before the draw below can change what it + # means, so "not resampled" is a statement the record makes rather than the absence of + # one. The reserve rides along BY REFERENCE where the sampler keeps one (AV and the + # portfolio); None elsewhere is the honest answer, not a gap. + self._rvs_record = RvsRecord.retained( + self._rvs, reserve=getattr(self, '_warm_seed_reserve', None)) if bFairdraw and not(n_extr is None): n_extr = int(numpy.min([n_extr,1.5*identity_convert(eff_samp),1.5*neff])) print(" Fairdraw size : ", n_extr) @@ -983,6 +995,14 @@ def _eval_integrand(cols): self._rvs_is_fairdraw = True # _rvs is now an EXPORT resample, rows already ~ w + # ...and now it is an export resample. n_retained comes from that record's + # PROVENANCE, which captured the count eagerly -- NOT from len(), which reads + # self._rvs and would return the POST-draw length: the retained record holds a + # REFERENCE to the live dict this block has just replaced in place. That is + # this project's own bug class, so it is spelled out rather than assumed. + self._rvs_record = RvsRecord.fair_draw( + self._rvs, n_retained=self._rvs_record.n_retained(), + reserve=getattr(self, '_warm_seed_reserve', None)) # perform type conversion of all stored variables. VERY LARGE -- should only do this if we need it! if cupy_ok: for name in self._rvs: diff --git a/MonteCarloMarginalizeCode/Code/RIFT/integrators/mcsamplerPortfolio.py b/MonteCarloMarginalizeCode/Code/RIFT/integrators/mcsamplerPortfolio.py index 579584a0e..24aadeb4c 100644 --- a/MonteCarloMarginalizeCode/Code/RIFT/integrators/mcsamplerPortfolio.py +++ b/MonteCarloMarginalizeCode/Code/RIFT/integrators/mcsamplerPortfolio.py @@ -59,6 +59,8 @@ cupy_ok = False cupy_pi = np.pi +from RIFT.integrators.rvs_record import RvsRecord, SamplerOutputMixin # see DESIGN_rvs_naming.md + def set_xpy_to_numpy(): xpy_default=numpy identity_convert = lambda x: x # trivial return itself @@ -136,7 +138,7 @@ def portfolio_default_weights(n_ess_list, wt_previous, portfolio_probability_flo ### -class MCSampler(object): +class MCSampler(SamplerOutputMixin, object): """ Class to define a set of parameter names, limits, and probability densities. """ @@ -1296,6 +1298,9 @@ def integrate_log(self, lnF, *args, xpy=xpy_default,**kwargs): # flag is not the same predicate, since the draw is skipped when it would not # shrink the record. Reset per pass: samplers are reused across events. self._rvs_is_fairdraw = False + # The record describes THIS pass only. Cleared with the flag above and set + # below, so it can never survive into a pass it does not describe. + self._rvs_record = None n_extr = kwargs["igrand_fairdraw_samples_max"] if "igrand_fairdraw_samples_max" in kwargs else None bShowEvaluationLog = kwargs['verbose'] if 'verbose' in kwargs else False @@ -1919,6 +1924,13 @@ def _eval_integrand(cols): self._rvs[key] = self._rvs[key][indx_list] # Do a fair draw of points, if option is set. CAST POINTS BACK TO NUMPY, IDEALLY + # (DESIGN_rvs_naming.md) _rvs is the RETAINED set at this point -- pruned, + # perhaps, but never resampled. Record that before the draw below can change what it + # means, so "not resampled" is a statement the record makes rather than the absence of + # one. The reserve rides along BY REFERENCE where the sampler keeps one (AV and the + # portfolio); None elsewhere is the honest answer, not a gap. + self._rvs_record = RvsRecord.retained( + self._rvs, reserve=getattr(self, '_warm_seed_reserve', None)) if bFairdraw and not(n_extr is None): n_extr = int(numpy.min([n_extr,1.5*identity_convert(eff_samp),1.5*neff])) print(" Fairdraw size : ", n_extr) @@ -1957,6 +1969,14 @@ def _eval_integrand(cols): self._rvs_is_fairdraw = True # _rvs is now an EXPORT resample, rows already ~ w + # ...and now it is an export resample. n_retained comes from that record's + # PROVENANCE, which captured the count eagerly -- NOT from len(), which reads + # self._rvs and would return the POST-draw length: the retained record holds a + # REFERENCE to the live dict this block has just replaced in place. That is + # this project's own bug class, so it is spelled out rather than assumed. + self._rvs_record = RvsRecord.fair_draw( + self._rvs, n_retained=self._rvs_record.n_retained(), + reserve=getattr(self, '_warm_seed_reserve', None)) # Create extra dictionary to return things dict_return ={} # if convergence_tests is not None: diff --git a/MonteCarloMarginalizeCode/Code/RIFT/integrators/rvs_record.py b/MonteCarloMarginalizeCode/Code/RIFT/integrators/rvs_record.py new file mode 100644 index 000000000..2b38d4ec4 --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/RIFT/integrators/rvs_record.py @@ -0,0 +1,462 @@ +"""A sampler's sample record, carrying its own provenance. + +See DESIGN_rvs_naming.md in this directory for the design and its alternatives. + +WHY THIS EXISTS +--------------- +`sampler._rvs` means two things at two times in one function: the RETAINED SET while +`integrate_log` accumulates, and an EXPORT RESAMPLE afterwards, once the fair draw has +rebound every key to ~1.5*eff_samp rows drawn WITH REPLACEMENT proportional to weight. The +name does not change and neither does the type, so a consumer written against the first +meaning keeps working, silently, against the second. + +Nine defects of that shape are on record, and four of them were found reviewing the fix for +the other five -- every one of the four in the BOOLEAN BOOKKEEPING introduced to describe +`_rvs` from outside, rather than in the physics: + + * a fix correct in isolation, wrong once pooling ran after it; + * one flag answering two questions ("rows resampled" and "record is equal-weight"); + * the CLI option used where "what this pass actually did" was needed; + * a marker cleared only on the normal return, surviving a raised event. + +The common cause is that provenance lived BESIDE the rows instead of WITH them, so every site +that touched the rows had to remember to update something else. This record puts the two +together, and replaces the booleans with named questions: + + rec.rows_are_resampled() per-BLOCK property; survives pooling + rec.is_equal_weight() whole-RECORD property; pooling destroys it + rec.posterior_log_weights() what to weight rows by to represent the posterior + +Those first two are the pair that a single boolean kept conflating. They are deliberately +separate methods with separate names, because the failure mode was not that the answer was +hard to compute -- it was that one name suggested one question while a caller asked another. +""" +from __future__ import absolute_import + +import copy + +import numpy as np + + +class RvsProvenance(object): + """How a sample record came to be -- travels WITH the rows, never beside them. + + `resampled_blocks` is a list, one entry per block, not a single boolean. A pooled record + can mix raw and resampled replicas: the fair draw is skipped per pass when it would not + shrink that pass's record, so a run near the n_extr boundary really does produce both. A + scalar cannot express that, and using the CLI option in its place either flattens a + replica whose importance weights are genuine or leaves a resampled one double-weighted. + """ + + __slots__ = ("resampled_blocks", "block_sizes", "pooled", "n_retained") + + def __init__(self, resampled_blocks=None, block_sizes=None, pooled=False, n_retained=None): + self.resampled_blocks = list(resampled_blocks or []) + self.block_sizes = list(block_sizes or []) + self.pooled = bool(pooled) + self.n_retained = n_retained # rows BEFORE the draw, when known + + def __repr__(self): + return ("RvsProvenance(resampled_blocks={}, block_sizes={}, pooled={}, n_retained={})" + .format(self.resampled_blocks, self.block_sizes, self.pooled, self.n_retained)) + + +class RvsRecord(object): + """Sample columns plus the provenance describing them. + + Deliberately NOT a dict subclass. Consumers that want the old behaviour should reach for + `.columns`, which makes the read visible in a diff and greppable by the audit script; a + dict subclass would let every existing `sampler._rvs[...]` keep working against an object + whose meaning it does not check, which is the whole problem restated. + """ + + __slots__ = ("columns", "provenance", "reserve", "integrand_is_log", "internal") + + def __init__(self, columns, provenance=None, reserve=None, integrand_is_log=None, + internal=False): + self.columns = columns + self.provenance = provenance if provenance is not None else RvsProvenance() + # WHAT THE RAW `integrand` COLUMN MEANS ON THIS BACKEND, recorded once by the sampler + # that wrote it. True = lnL, False = linear L, None = unknown. + # + # This is where `return_lnI` goes to die. Today that kwarg's value is a RUNTIME + # property of how mcsamplerEnsemble was called, and no consumer can recover it -- which + # is why ln_weights_from_rvs has to demand `use_lnL` from every caller and why passing + # opts.internal_use_lnL instead is a documented bug. The sampler knows; it now says so + # once, here, and log_likelihood() below is unambiguous on every backend. + self.integrand_is_log = integrand_is_log + # INTERNAL PLUMBING, NOT PUBLIC SURFACE. Replica pooling has to hand each block's + # record back into _pool_replica_rvs so the weights can be derived per block with the + # right convention -- but having had to pass the structure around does NOT mean callers + # should reach for it. An internal record is refused by set_samples(), so it can never + # come back out of the public samples() accessor. + self.internal = bool(internal) + # REFERENCE, not a copy. See retained_* below for why this is a reference and why it + # is the bounded reserve rather than the raw retained rows. + self.reserve = reserve + + # -- construction ------------------------------------------------------------------ + @classmethod + def retained(cls, columns, n_retained=None, reserve=None, integrand_is_log=None): + """A record whose rows are the pass's own draws, with real importance weights.""" + n = _n_rows(columns) + return cls(columns, RvsProvenance(resampled_blocks=[False], block_sizes=[n], + pooled=False, + n_retained=n if n_retained is None else n_retained), + reserve=reserve, integrand_is_log=integrand_is_log) + + @classmethod + def fair_draw(cls, columns, n_retained=None, reserve=None, integrand_is_log=None): + """A record whose rows were drawn WITH REPLACEMENT proportional to weight.""" + n = _n_rows(columns) + return cls(columns, RvsProvenance(resampled_blocks=[True], block_sizes=[n], + pooled=False, n_retained=n_retained), + reserve=reserve, integrand_is_log=integrand_is_log) + + @classmethod + def pooled(cls, columns, resampled_blocks, block_sizes, reserve=None, + integrand_is_log=None): + """A concatenation of replica blocks, weighted between blocks by their evidences.""" + return cls(columns, RvsProvenance(resampled_blocks=list(resampled_blocks), + block_sizes=list(block_sizes), pooled=True), + reserve=reserve, integrand_is_log=integrand_is_log) + + # -- the questions ----------------------------------------------------------------- + def rows_are_resampled(self): + """Were any rows drawn proportional to weight? A PER-BLOCK property. + + True for a plain fair draw AND for a pooled record built from fair-drawn replicas -- + pooling concatenates blocks, it does not un-resample their rows. Anything that must + not re-weight such rows (the .dslice reweight core) asks THIS. + + `any`, not `all`: with a mixture, a consumer that cannot weight rows differently by + provenance must treat the whole record as unsafe to reweight. + """ + return any(self.provenance.resampled_blocks) + + def is_equal_weight(self): + """Does EVERY row carry the same posterior weight? A WHOLE-RECORD property. + + A single fair draw: yes. A pooled record: NO, even though each of its blocks is + internally equal-weight -- blocks differ by exactly the replica evidences Z_k/K, and + flattening them would mix replicas by row count instead of by evidence. + + This is the question `ln_weights_for_posterior` asks, and the one a single + `_rvs_is_fairdraw` boolean answered wrongly for a pooled record. + """ + return (not self.provenance.pooled) and self.rows_are_resampled() + + def blocks_were_flattened(self): + """Did pooling force equal weights within any block? + + The predicate for "is the Kish n_eff of this record meaningful": a flattened block's + rows carry its EXPORT SIZE, not its integration quality, so the pooled Kish becomes a + row count. Distinct from both questions above -- it is a fact about the pooling STEP. + """ + return self.provenance.pooled and any(self.provenance.resampled_blocks) + + # -- THE UNIVERSAL OUTPUT API --------------------------------------------------- + # + # `_rvs` is INTERNAL. These are what a consumer should call: one name per quantity, the + # same meaning on every backend, so nobody has to know that `integrand` holds lnL on three + # samplers, linear L on two, and either on a sixth depending on a kwarg (the table is in + # test/expensive_before_merging/integrators/audit_backend_contracts.py). + # + # Everything is returned in LOG space, because that is the only convention all six can + # express without loss -- the linear column underflows to 0 at ~745 nats, which is exactly + # the regime this whole line of work is about. + + def log_likelihood(self): + """ln L per row -> float array. The same thing on every backend. + + Prefers the unambiguous `log_integrand` column. Falls back to `integrand` ONLY when + the sampler stated what that column means; when it did not, this RAISES rather than + guess -- a loud failure beats a plausible wrong number, which is the same rule + ln_weights_from_rvs already applies one layer down. + """ + c = self.columns + if 'log_integrand' in c: + return np.asarray(_host(c['log_integrand']), dtype=float).ravel() + if 'integrand' not in c: + raise KeyError("record has neither 'log_integrand' nor 'integrand'") + ig = np.asarray(_host(c['integrand']), dtype=float).ravel() + if self.integrand_is_log is True: + # NON-FINITE ROWS BECOME -inf, matching ln_weights_from_rvs's `keep = isfinite(ig)`. + # Not cosmetic: a NaN here propagates into every downstream sum (lnZ, Kish n_eff, + # the exported weights), while -inf is a real zero weight that sums correctly. The + # difference was found by diffing the two implementations before migrating the + # weight path onto this one -- a NaN integrand came back NaN here and -inf there. + return np.where(np.isfinite(ig), ig, -np.inf) + if self.integrand_is_log is False: + out = np.full(len(ig), -np.inf) + pos = ig > 0 + out[pos] = np.log(ig[pos]) # non-positive means a rejected/underflowed row + return out + raise ValueError( + "this record has only a raw 'integrand' column and the sampler did not record " + "whether it holds L or lnL, so its meaning is unrecoverable. Pass " + "integrand_is_log= when building the record (see DESIGN_rvs_naming.md).") + + def log_prior(self): + """ln pi per row -> float array.""" + return self._log_of('log_joint_prior', 'joint_prior') + + def log_sampling_prior(self): + """ln q per row -> float array.""" + return self._log_of('log_joint_s_prior', 'joint_s_prior') + + def _log_of(self, log_key, lin_key): + c = self.columns + if log_key in c: + return np.asarray(_host(c[log_key]), dtype=float).ravel() + if lin_key not in c: + raise KeyError("record has neither {!r} nor {!r}".format(log_key, lin_key)) + v = np.asarray(_host(c[lin_key]), dtype=float).ravel() + out = np.full(len(v), -np.inf) + pos = v > 0 + out[pos] = np.log(v[pos]) + return out + + def log_weights(self, convert=None): + """THE importance log-weight per row: lnL + ln pi - ln q -> float array. + + No `use_lnL` argument, because the record already knows. That parameter exists on + ln_weights_from_rvs only because a bare `_rvs` dict cannot say what its own columns + mean; a consumer on this API cannot get it wrong. + + NOT `log_likelihood() + log_prior() - log_sampling_prior()`. That was the first + implementation and it is WRONG on the linear column family, systematically rather than + in a corner: `ln_weights_from_rvs` applies a CONJUNCTIVE keep-mask there -- + `(ig > 0) & (jp > 0) & (js > 0)`, whole row to -inf otherwise -- whereas evaluating the + three terms independently yields `-inf - (-inf) = NaN` whenever both a prior and a + sampling prior are non-positive. A NaN weight then poisons every downstream sum, while + -inf is a real zero. Found by fuzzing the two implementations against each other before + migrating the weight path onto this one; 600 randomized records diverged. + + So this mirrors the established contract branch for branch. The per-quantity accessors + above remain correct in isolation -- conjunctiveness is a property of the WEIGHT, not of + the prior. + """ + # `convert` is the CALLER's host-transfer hook (the ILE passes identity_convert). It + # was silently ignored in the first version, which happened to be harmless because + # _host and cupy.asnumpy coincide for cupy arrays -- but "happens to coincide" is not a + # contract, and the GPU path is exactly where it would not be noticed. Honour it. + _cv = convert if convert is not None else _host + c = self.columns + if all(k in c for k in ('log_integrand', 'log_joint_prior', 'log_joint_s_prior')): + # log family: a plain sum, no mask, exactly as ln_weights_from_rvs does + return (np.asarray(_cv(c['log_integrand']), dtype=float).ravel() + + np.asarray(_cv(c['log_joint_prior']), dtype=float).ravel() + - np.asarray(_cv(c['log_joint_s_prior']), dtype=float).ravel()) + if all(k in c for k in ('integrand', 'joint_prior', 'joint_s_prior')): + ig = np.asarray(_cv(c['integrand']), dtype=float).ravel() + jp = np.asarray(_cv(c['joint_prior']), dtype=float).ravel() + js = np.asarray(_cv(c['joint_s_prior']), dtype=float).ravel() + out = np.full(len(ig), -np.inf) + if self.integrand_is_log is True: + keep = np.isfinite(ig) & (jp > 0) & (js > 0) + out[keep] = ig[keep] + np.log(jp[keep]) - np.log(js[keep]) + elif self.integrand_is_log is False: + keep = (ig > 0) & (jp > 0) & (js > 0) + out[keep] = np.log(ig[keep]) + np.log(jp[keep]) - np.log(js[keep]) + else: + raise ValueError( + "raw 'integrand' column with no recorded convention; pass " + "integrand_is_log= when building the record (see DESIGN_rvs_naming.md).") + return out + raise KeyError("cannot build importance weights from this record (columns={})".format( + sorted(c))) + + # -- weights ----------------------------------------------------------------------- + def posterior_log_weights(self, ln_weights_from_columns): + """Weights to represent the posterior -> float array. + + Uniform when the record is globally equal-weight; otherwise the caller's canonical + importance weight, derived from the columns. The derivation is injected rather than + imported so this module stays free of the ILE's convention handling. + """ + if self.is_equal_weight(): + return np.zeros(_n_rows(self.columns), dtype=float) + return np.asarray(ln_weights_from_columns(self.columns), dtype=float) + + # -- the rows the pass actually drew ------------------------------------------------- + def has_retained(self): + """Is a usable record of the pre-draw rows available?""" + r = self.reserve + return isinstance(r, dict) and 'X' in r and 'lnL' in r + + def retained_points(self): + """(n, ndim) of the points the pass RETAINED, or None. + + A REFERENCE to the bounded warm-seed reserve, deliberately, not the raw retained rows. + Measured (measure_retained_set_memory.py): holding the raw set costs ~0.9 MB per + million nmax for AV -- nothing -- but ~92 MB per million for a PORTFOLIO, whose _rvs + holds every draw, i.e. ~384 MB at nmax=4e6 per ILE process. And it would be mostly + ballast: on the collapsed pass this work is about, the portfolio's finite fraction is + ~1e-5, so almost all of it is -inf rows no consumer can use. + + make_warm_seed_reserve already keeps the affordable thing -- bounded at n_max rows, + stratified by finite-ness, with the EXACT pre-cap weight total recorded alongside so a + capped reserve still yields an unbiased lnZ. Pointing at it costs nothing and is + already paid for. + """ + return np.asarray(self.reserve['X'], dtype=float) if self.has_retained() else None + + def retained_lnL(self): + """lnL of the retained points, or None. Same reference as retained_points().""" + return (np.asarray(self.reserve['lnL'], dtype=float).ravel() + if self.has_retained() else None) + + def n_retained(self): + """Rows the pass retained BEFORE the draw, when known -- not len(self).""" + n = self.provenance.n_retained + if n is None and self.has_retained(): + n = self.reserve.get('n_retained') + return n + + # -- lifecycle --------------------------------------------------------------------- + def as_internal(self): + """A view of this record marked as internal plumbing -> RvsRecord. + + Same columns and provenance, by reference; only the marker differs. Used where a + record must be threaded through a helper (replica pooling) without becoming something + a consumer can obtain from samples(). + """ + out = RvsRecord(self.columns, self.provenance, reserve=self.reserve, + integrand_is_log=self.integrand_is_log, internal=True) + return out + + def snapshot(self): + """A copy that a rejected pass can be restored from, provenance included. + + The rows are copied shallowly (columns are replaced wholesale by the rebind, never + mutated in place) but the PROVENANCE is copied deeply, because restoring rows while + leaving provenance describing the rejected pass is one of the four defects this + record exists to prevent. + """ + # The reserve rides along BY REFERENCE: it is immutable once built (each pass builds a + # fresh one), and copying it would reintroduce the memory cost this design avoids. + return RvsRecord(dict(self.columns), copy.deepcopy(self.provenance), + reserve=self.reserve, integrand_is_log=self.integrand_is_log, + internal=self.internal) + + def __len__(self): + return _n_rows(self.columns) + + def __repr__(self): + return "RvsRecord({}{} rows, {})".format( + "INTERNAL, " if self.internal else "", len(self), self.provenance) + + +class SamplerOutputMixin(object): + """The public output API every backend gets by inheriting it. + + `_rvs` is an INTERNAL variable: it means different things at different times, and its raw + columns mean different things on different backends. Consumers should never reach inside + it -- they should call this. + + Kept as a mixin because the six MCSampler classes share no base class today (each is + `class MCSampler(object)`), and giving them one is a bigger change than this draft should + make. + """ + + def samples(self): + """This pass's samples, with provenance -> RvsRecord, or None if it never ran. + + THE public accessor. Everything a consumer needs -- log_likelihood(), log_prior(), + log_sampling_prior(), log_weights(), rows_are_resampled(), is_equal_weight() -- hangs + off the returned record and means the same thing on every backend. + """ + return getattr(self, '_rvs_record', None) + + def set_samples(self, record): + """Replace this pass's record -> the record, for chaining. + + PUBLIC because the ILE legitimately produces one: replica pooling builds a record the + sampler cannot (it is a mixture of several passes). Without this, that code would have + to assign `sampler._rvs_record` directly -- reaching into another object's private + attribute, which is the habit this whole design is trying to end. A writer needs an + API as much as a reader does. + """ + if record is not None and getattr(record, 'internal', False): + raise ValueError( + "refusing to publish an INTERNAL record through samples(): it is plumbing for " + "replica pooling, not part of the sampler's output contract. If a consumer " + "needs it, that is a design question, not a call to set_samples().") + self._rvs_record = record + return record + + +def _host(v): + """cupy -> numpy where needed, without importing cupy.""" + try: + return v.get() if hasattr(v, 'get') and not isinstance(v, np.ndarray) else v + except Exception: + return v + + +# Columns that are one scalar PER ROW on every backend, so any of them settles the row count +# without having to reason about a parameter's layout at all. Consulted first, in this order. +_ROW_COUNT_COLUMNS = ('log_integrand', 'integrand', 'log_joint_prior', 'joint_prior', + 'log_joint_s_prior', 'joint_s_prior') + + +def _column_shape(value): + """Shape of one column without pulling a GPU array to the host, or None.""" + shape = getattr(value, 'shape', None) + if shape is not None: + return tuple(shape) # numpy or cupy alike; counting rows must not copy + try: + return np.atleast_1d(np.asarray(value)).shape + except Exception: + return None + + +def _column_n_rows(key, value): + """Rows in one column, or None if it says nothing about the count. + + THE KEY SAYS WHERE THE ROW AXIS IS. A parameter registered under a TUPLE key is a + combined parameter stored (ndim, N); a plain key is one entry per row. That is not a + guess -- it is the convention every sampler already indexes by, `col[:, idx]` for a tuple + key against `col[idx]` otherwise (mcsampler.py and its five siblings). + """ + shape = _column_shape(value) + if not shape: # unreadable, or 0-d: not a per-row column + return None + return int(shape[-1]) if isinstance(key, tuple) else int(shape[0]) + + +def _n_rows(columns): + """Rows in a record's columns. + + Flattening whichever column came first was wrong for the ordinary case, not a corner: + `_rvs` is seeded parameters-first, so a run with a combined parameter -- (ndim, N) under a + tuple key -- put one at the front and reported ndim*N. That number became len(record), the + block size and n_retained in the provenance, and the LENGTH OF THE UNIFORM VECTOR + posterior_log_weights() hands back for a fair draw, i.e. an output-length failure ndim + times too long rather than a mislabelled count. + """ + columns = columns or {} + for key in _ROW_COUNT_COLUMNS: + if key in columns: + n = _column_n_rows(key, columns[key]) + if n is not None: + return n + for key, value in columns.items(): + n = _column_n_rows(key, value) + if n is not None: + return n + return 0 + + +def n_rows(columns): + """Rows in a plain `_rvs` column dict -> int. THE row count, for callers without a record. + + Public because the ILE drivers need this rule where no record exists yet: replica pooling + measures each block from a raw column dict, and the fair-draw consumers ask how long a + uniform weight vector must be. Their own `_rvs_len` flattened whichever column came first + and so reported ndim*N wherever a combined parameter was registered -- a second + implementation of a rule that already lives here, which is the failure this module exists + to stop. One definition, called from both drivers. + """ + return _n_rows(columns) diff --git a/MonteCarloMarginalizeCode/Code/RIFT/integrators/seeding.py b/MonteCarloMarginalizeCode/Code/RIFT/integrators/seeding.py new file mode 100644 index 000000000..f6cdbf80f --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/RIFT/integrators/seeding.py @@ -0,0 +1,217 @@ +""" +seeding.py: central RNG seeding for the RIFT drivers. + +WHY THIS EXISTS +--------------- +Historically ``--seed`` was implemented in the ILE drivers as a bare +``numpy.random.seed(opts.seed)``. That only covers the CPU code path. + +Every sampler in RIFT/integrators draws its variates through the *array +backend* it was configured with -- ``self.xpy`` on an instance, or the +module-level ``xpy_default`` -- and that backend is ``cupy`` whenever the job +runs on a GPU. The draws that decide the answer are therefore cupy draws: + + * ``self.xpy.random.uniform`` in mcsamplerGPU.draw_simplified (inverse-CDF + sampling: this is the main integrand proposal) + * ``xpy_default.random.uniform`` in mcsamplerAdaptiveVolume.sample_from_bins + * ``self.xpy.random.uniform`` in MonteCarloEnsemble + * ``self.xpy.random.choice`` in the fair-draw / extrinsic-resample paths of + mcsamplerGPU, mcsamplerAV, mcsamplerPortfolio, mcsamplerEnsemble, and in + gaussian_mixture_model's k-means++ initialization + +cupy keeps its own global generator, per device, which ``numpy.random.seed`` +does not touch. So a GPU run was irreproducible even when the user asked for a +seed: two byte-identical invocations of the ILE demo with ``--seed 101`` +returned lnL = 73.807 (n_eff 5.9) and lnL = 73.520 (n_eff 10.8). That silently +invalidates any paired / replicate-seed comparison design on GPU, because the +"same seed" arms are not in fact paired. + +``seed_everything`` seeds every backend a RIFT sampler can reach, so that the +meaning of ``--seed`` does not depend on which device the job landed on. + +CAVEAT (cupy is per-device) +--------------------------- +``cupy.random.seed`` seeds the generator of the *current* device only; cupy +holds a separate generator per device, created lazily. A single-device job -- +which is what an ILE process is -- is fully covered. If more than one device +is visible we say so, rather than implying a guarantee we are not making. +""" + +import zlib + +import numpy + + +__all__ = ['seed_everything', 'get_seed', 'derived_rng', 'next_derived_rng'] + + +# The seed the process was started with, or None if the run was never seeded. +# Exposed via get_seed() so that code needing its own independent stream (e.g. +# a bootstrap diagnostic) can derive one deterministically instead of pulling +# fresh entropy from the OS. +_seed_used = None + +# stream name -> number of generators already handed out under it, for the call +# sites that are reached more than once per process (see next_derived_rng). +_stream_counters = {} + + +def get_seed(): + """Return the seed passed to seed_everything, or None if never seeded.""" + return _seed_used + + +def derived_rng(stream, counter=0): + """Return a numpy Generator for an auxiliary draw, reproducible when seeded. + + ``numpy.random.default_rng()`` obtains fresh entropy from the OS, so a + Generator built that way is NOT covered by seed_everything -- seeding the + global RNGs does not reach it. Anything such a Generator decides therefore + still varies between two runs given the same ``--seed``; when it feeds the + likelihood (e.g. the calibration error probe, which chooses how many + calibration realizations to marginalize over) that changes the scientific + result. Derive the stream from the run's seed instead:: + + rng = derived_rng('calmarg.error_probe', counter) + + Parameters + ---------- + stream : str + Stable identifier for the call site. Different identifiers give + different streams, so unrelated call sites never share draws. + counter : int + Distinguishes repeated uses of the same identifier (successive probes, + successive rounds of draws), so a site that is called more than once + does not reuse its own draws. + + Distinct ``(stream, counter)`` pairs seed distinct, independent + SeedSequence streams -- independent also of the ``default_rng(seed)`` stream + the seed itself produces -- so this buys reproducibility without + correlating draws that are meant to be independent. A run that was never + seeded keeps fresh entropy, exactly as before. + """ + if _seed_used is None: + return numpy.random.default_rng() + # crc32 of the name rather than hash(): str hashing is salted per process, + # so hash() would silently make the "stable" identifier unstable. + label = zlib.crc32(str(stream).encode('utf-8')) + return numpy.random.default_rng([_seed_used, label, int(counter)]) + + +def next_derived_rng(stream): + """``derived_rng`` for a call site that is reached MORE THAN ONCE per process. + + ``derived_rng(stream)`` defaults to counter 0, so calling it twice under the + same name hands back the same draws. For a site inside a loop -- one warm + start per intrinsic point, one bootstrap per integral, one growth round per + probe -- that would replace "unseeded" with something worse: seeded and + self-correlated, e.g. every intrinsic point getting the *identical* uniform + coverage cloud, or a grown draw set appending copies of the draws already in + it. This advances the counter for you, so successive uses of one call site + are independent of each other, of every other site, and of the base stream, + while the sequence as a whole is fixed by ``--seed``. + + The counters are process state, reset by seed_everything: a run is + reproducible from its start, not from an arbitrary point in its middle. So + the property this buys is "two identical invocations agree", which is what + ``--seed`` promises; it is NOT "this call always returns the same numbers". + """ + n = _stream_counters.get(stream, 0) + _stream_counters[stream] = n + 1 + return derived_rng(stream, n) + + +def seed_everything(seed, verbose=True): + """Seed every RNG backend a RIFT sampler can draw from. + + Parameters + ---------- + seed : int + The seed. Applied to all backends, so that switching a run between CPU + and GPU changes which backend is used, not whether the run is seeded. + verbose : bool + Print a one-line report of what was actually seeded. Worth leaving on: + the failure mode this function exists to fix was invisible. + + Returns + ------- + dict + backend name -> status string, one of 'seeded', 'absent' (library not + installed) or 'failed: '. Backends that are absent are not an + error: a CPU-only install has no cupy, and only mcsamplerNFlow needs + torch. + """ + global _seed_used + + seed = int(seed) + _seed_used = seed + _stream_counters.clear() # a fresh seeding is a fresh run: restart the derived streams + status = {} + + # Python's stdlib RNG. Not used by the samplers today, but it is used + # incidentally elsewhere (and by some dependencies), and it is free. + import random as _pyrandom + _pyrandom.seed(seed) + status['python'] = 'seeded' + + # numpy: the CPU sampler path, and everything that reaches numpy's legacy + # global RandomState -- which includes scikit-learn estimators constructed + # with random_state=None, e.g. the KMeans init in weighted_gmm. + numpy.random.seed(seed) + status['numpy'] = 'seeded' + + # cupy: the GPU sampler path. Importing cupy on a machine with no working + # CUDA install raises, and seeding can raise even when the import succeeds + # (no device, or a device this cupy build cannot drive), so both steps are + # guarded -- an unseedable GPU backend must not take down a CPU run. + n_dev = 0 + try: + import cupy + except Exception: + status['cupy'] = 'absent' + else: + try: + n_dev = cupy.cuda.runtime.getDeviceCount() + cupy.random.seed(seed) + status['cupy'] = 'seeded' + except Exception as e: + status['cupy'] = 'failed: {}'.format(e) + + # torch: only mcsamplerNFlow needs it. + try: + import torch + except Exception: + status['torch'] = 'absent' + else: + try: + torch.manual_seed(seed) + if torch.cuda.is_available(): + torch.cuda.manual_seed_all(seed) + status['torch'] = 'seeded' + except Exception as e: + status['torch'] = 'failed: {}'.format(e) + + # Seeding the RNGs is necessary but not sufficient on GPU. The adapted + # sampling histogram is built with a weighted cupy.bincount, which sums + # through float atomicAdd; the ordering is set by thread scheduling, so the + # adapted CDF -- and hence every draw taken through it -- varies at the ULP + # level between otherwise identical runs. Switch that one reduction to a + # scheduler-independent summation order, so that "same seed" really does + # mean "same answer". Pushed from here rather than pulled from there so + # that RIFT.likelihood keeps no dependency on the integrators. + try: + from RIFT.likelihood import vectorized_general_tools as _vgt + _vgt.DETERMINISTIC_REDUCTIONS = True + status['gpu_reductions'] = 'deterministic' + except Exception as e: + status['gpu_reductions'] = 'failed: {}'.format(e) + + if verbose: + print(" Seeding RNGs with {}: {}".format( + seed, ", ".join("{}={}".format(k, status[k]) for k in sorted(status)))) + if status.get('cupy') == 'seeded' and n_dev > 1: + print(" NOTE: cupy generators are per-device; seeded the current" + " device only ({} visible). Pin one device (CUDA_VISIBLE_DEVICES)" + " for a fully reproducible GPU run.".format(n_dev)) + + return status diff --git a/MonteCarloMarginalizeCode/Code/RIFT/integrators/statutils.py b/MonteCarloMarginalizeCode/Code/RIFT/integrators/statutils.py index 3e98db873..d87394a37 100644 --- a/MonteCarloMarginalizeCode/Code/RIFT/integrators/statutils.py +++ b/MonteCarloMarginalizeCode/Code/RIFT/integrators/statutils.py @@ -320,7 +320,19 @@ def bootstrap_lnZ_quantiles(log_wt, n_total=None, n_boot=200, quantiles=(0.05, 0 return None if n_total is None: n_total = n - rng = numpy.random.default_rng(rng_seed) + # Reproducibility: default_rng(None) takes fresh OS entropy, so the printed + # interval moved between two invocations that agreed on lnZ to the last bit. + # Derive the stream from --seed instead. It MUST be a stream of its own and + # must not consume numpy's global RNG: the samplers draw from that global + # stream, so spending draws here would shift every subsequent sampler draw and + # this diagnostic -- which is not allowed to touch the answer -- would change + # lnL. The counter keeps the per-point/per-replica bootstraps from all + # resampling with the same indices. Unseeded runs keep fresh entropy. + if rng_seed is None: + from RIFT.integrators.seeding import next_derived_rng + rng = next_derived_rng('statutils.bootstrap_lnZ_quantiles') + else: + rng = numpy.random.default_rng(rng_seed) ref = lw.max() w = numpy.exp(lw - ref) out = numpy.empty(n_boot) diff --git a/MonteCarloMarginalizeCode/Code/RIFT/integrators/unreliable_oracle/resampling.py b/MonteCarloMarginalizeCode/Code/RIFT/integrators/unreliable_oracle/resampling.py index 82b65f93c..4c8f80a24 100644 --- a/MonteCarloMarginalizeCode/Code/RIFT/integrators/unreliable_oracle/resampling.py +++ b/MonteCarloMarginalizeCode/Code/RIFT/integrators/unreliable_oracle/resampling.py @@ -35,7 +35,15 @@ def setup(self, reference_samples=None, reference_params=None,**kwargs): if self.params_ordered and self.reference_params: print(self.params_ordered, self.reference_params) self.valid_params = [p for p in self.reference_params if p in self.params_ordered] # valid parameters to sample from - self.other_params = list( set(self.params_ordered) - set(self.valid_params)) # remainder, will be uniform + # remainder, will be uniform. ORDER MATTERS, so this is NOT a set difference: + # draw_simplified consumes a block of numpy's (seeded) global stream per entry, so + # set order decides which block lands on which parameter -- and str hashing is + # salted per process (PYTHONHASHSEED), so two runs with the SAME --seed drew + # different distances/inclinations. Measured: five processes at --seed 101 gave + # four distinct distance blocks. The oracle trains the sampling prior (and seeds + # the AV live volume), so that reaches lnZ. params_ordered order is stable. + _valid = set(self.valid_params) + self.other_params = [p for p in self.params_ordered if p not in _valid] def update_sampling_prior(self, *args, **kwargs): True diff --git a/MonteCarloMarginalizeCode/Code/RIFT/lalsimutils.py b/MonteCarloMarginalizeCode/Code/RIFT/lalsimutils.py index 7804f3162..ead18a87d 100644 --- a/MonteCarloMarginalizeCode/Code/RIFT/lalsimutils.py +++ b/MonteCarloMarginalizeCode/Code/RIFT/lalsimutils.py @@ -315,6 +315,20 @@ def check_FD_pending(code): lalIMRPhenomTP = -12 lalIMRPhenomTPHM = -13 +# TD-only aligned-spin members of the IMRPhenomT family. These MUST be routed to +# SimInspiralChooseTDModes in hlmoft: they are not FD-implemented, so they miss the +# hlmoft_FromFD_dict branch, and if left out of the ChooseTDModes list they fall +# through to a fallback that conditions them with a different epoch/merger placement +# (which breaks time-sensitive likelihoods). +# Sentinels: -2..-19 are used above and the pending_FD_approx block can consume +# -19,-20, so start at -21. +try: + lalIMRPhenomT = lalsim.IMRPhenomT + lalIMRPhenomTHM = lalsim.IMRPhenomTHM +except: + lalIMRPhenomT = -21 + lalIMRPhenomTHM = -22 + MsunInSec = lal.MSUN_SI*lal.G_SI/lal.C_SI**3 def modes_to_k(modes): @@ -3454,7 +3468,7 @@ def hlmoft(P, Lmax=2,nr_polarization_convention=False, fixed_tapering=False, sil if lalsim.SimInspiralImplementedFDApproximants(P.approx)==1: print("Passing model through hlmoft_FromFD_dict") hlms = hlmoft_FromFD_dict(P,Lmax=Lmax) - elif (P.approx == lalsim.TaylorT1 or P.approx==lalsim.TaylorT2 or P.approx==lalsim.TaylorT3 or P.approx==lalsim.TaylorT4 or P.approx == lalsim.EOBNRv2HM or P.approx==lalsim.EOBNRv2 or P.approx==lalsim.SpinTaylorT1 or P.approx==lalsim.SpinTaylorT2 or P.approx==lalsim.SpinTaylorT3 or P.approx==lalsim.SpinTaylorT4 or P.approx == lalSEOBNRv4P or P.approx == lalSEOBNRv4PHM or P.approx == lalNRSur7dq4 or P.approx == lalNRSur7dq2 or P.approx==lalNRHybSur3dq8 or P.approx == lalIMRPhenomTPHM) or (P.approx ==lalsim.TEOBResumS and not(has_external_teobresum) and not(info_use_resum_polarizations)): + elif (P.approx == lalsim.TaylorT1 or P.approx==lalsim.TaylorT2 or P.approx==lalsim.TaylorT3 or P.approx==lalsim.TaylorT4 or P.approx == lalsim.EOBNRv2HM or P.approx==lalsim.EOBNRv2 or P.approx==lalsim.SpinTaylorT1 or P.approx==lalsim.SpinTaylorT2 or P.approx==lalsim.SpinTaylorT3 or P.approx==lalsim.SpinTaylorT4 or P.approx == lalSEOBNRv4P or P.approx == lalSEOBNRv4PHM or P.approx == lalNRSur7dq4 or P.approx == lalNRSur7dq2 or P.approx==lalNRHybSur3dq8 or P.approx == lalIMRPhenomTPHM or P.approx == lalIMRPhenomT or P.approx == lalIMRPhenomTHM) or (P.approx ==lalsim.TEOBResumS and not(has_external_teobresum) and not(info_use_resum_polarizations)): # approximant likst: see https://git.ligo.org/lscsoft/lalsuite/blob/master/lalsimulation/lib/LALSimInspiral.c#2541 extra_params = P.to_lal_dict_extended(extra_args_dict=extra_waveform_args) # prevent segmentation fault when hitting nyquist frequency violations @@ -3466,11 +3480,25 @@ def hlmoft(P, Lmax=2,nr_polarization_convention=False, fixed_tapering=False, sil raise NameError(" Nyquist frequency error for v4P/v4PHM, check srate") # extra phase factor of pi/2 added to fix consistency issue with our reconstruction code and other convention; easily demonstrated with precessing binaries, and also in docs phiref_shift_convention =np.pi/2 + approx_here = P.approx + if P.approx == lalIMRPhenomT and P.approx > 0: + # IMRPhenomT (22-mode-only) provides no TD-modes generator method in lalsimulation, + # so ChooseTDModes raises for it. Its (2,2) mode is identical to IMRPhenomTHM's + # (machine precision; THM is built on the T 22 mode), so generate via THM with a + # ModeArray restricted to (2,+-2). This keeps the same conditioning/epoch as the + # other ChooseTDModes approximants. + approx_here = lalIMRPhenomTHM + mode_array = lalsim.SimInspiralCreateModeArray() + lalsim.SimInspiralModeArrayActivateMode(mode_array, 2, 2) + lalsim.SimInspiralModeArrayActivateMode(mode_array, 2, -2) + if extra_params is None: + extra_params = lal.CreateDict() + lalsim.SimInspiralWaveformParamsInsertModeArray(extra_params, mode_array) hlms = lalsim.SimInspiralChooseTDModes(P.phiref, P.deltaT, P.m1, P.m2, \ P.s1x, P.s1y, P.s1z, \ P.s2x, P.s2y, P.s2z, \ P.fmin, P.fref, P.dist, extra_params, \ - Lmax, P.approx) + Lmax, approx_here) elif P.approx ==lalsim.TEOBResumS and has_external_teobresum and not(info_use_resum_polarizations): # don't call external if fallback to polarizations print("Using TEOBResumS hlms") modes_used = [] diff --git a/MonteCarloMarginalizeCode/Code/RIFT/likelihood/DESIGN_q_window_stencil.md b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/DESIGN_q_window_stencil.md new file mode 100644 index 000000000..2c7587039 --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/DESIGN_q_window_stencil.md @@ -0,0 +1,187 @@ +# Q_lm sub-sample time-interpolation stencil: measurements and decisions + +**Status as of 2026-08-16.** Investigation record for `RIFT.likelihood.time_interp_choice` and the +`--interpolate-time` / `--internal-ile-interpolate-time` flags. + +This file is a **record of measurements**, not a specification. It is expected to be superseded. +The **live decision** is the single constant `CROSSOVER_GUIDANCE` in +`RIFT/likelihood/time_interp_choice.py`, which every user-facing help string interpolates and +which `test_interpolate_time_cli.py` pins across all entry points. **If this document and that +constant ever disagree, the constant is authoritative and this document is stale.** + +Numbers here were measured against PR #97 (merged as `c1a2e2df`) and PR #109. + +--- + +## 1. The decision, in one line + +The crossover in total mass **rises with fmin**: + +| fmin | crossover | below it | above it | +|---|---|---|---| +| ≤ 50 Hz | 20–35 M☉ | `sinc` | `cubic` | +| 100 Hz | 35–55 M☉ | `sinc` | `cubic` | +| 150 Hz | above 55 M☉ | `sinc` at every mass measured (9–55) | *unmeasured* | + +**Measured range is 9–55 M☉ at srate 4096.** There is no high-fmin evidence at 80 or 120 M☉. The +fmin-30 ladder puts those in cubic's regime, but **do not extrapolate that to high fmin**: the +whole finding of §4 is that the crossover rises with fmin, and it moved M=35 and M=55 across it. +Extrapolating a fmin-30 result is the exact error that made #97 wrong. 80 and 120 M☉ at fmin ≥ 100 +are simply **unmeasured**. Likewise do not read the fmin-150 row as "sinc at any mass" — it is +"sinc everywhere we looked, and we stopped at 55". + +`nearest` is never competitive: 200–443 nats throughout, crossing 1 nat of error by SNR 2–6, i.e. +already unusable at O4 SNRs. + +--- + +## 2. Why there is no automatic selection + +Three successive candidate rules were built and **all three were disproved by measurement**. + +**Rule 1 — key on `fNyq/fmax`.** Wrong quantity: that number is identical for every system at +fixed settings, but the right stencil is not. `Q^a_lm(t) = ` is band-limited by +whichever is lower, `fmax` or the *template's* own highest frequency. + +**Rule 2 — key on `fNyq / min(fmax, f_ISCO(M))`.** Mis-selected at 2 of 8 masses. Fatally, the +correct stencil depends on **fmin** as strongly as on mass: at M = 5 M☉, srate 4096 / fmax 1700, +the winner flips from cubic (fmin 30) to sinc (fmin 150) with mass, srate and fmax all identical. +Those two cases require disjoint threshold ranges — (1.21, 2.33) and (2.33, 4.66) — so **no +threshold can make a `(srate, fmax, mass)` signature correct**. The signature is wrong, not the +constant. + +**Rule 3 — key on `fNyq /` a PSD-integrated bandwidth (`RIFT.misc.psd_bandwidth`).** Looked clean +at quantile 0.99 on the fmin-30 points: sinc ≤ 2.99, cubic ≥ 4.33, a 45% gap. But all 9 of those +points were at **one fmin**. Across the fmin sweep the classes **overlap** over [4.21, 6.01] with 5 +points inside, one sinc winner ranking above four cubic winners. A quantile sweep from 0.50 to +0.99999 finds **no** separating value (best 0.95, still 1.18× overlap). The estimator moves the +M=55 score only −7% over fmin 20→150 while the physics flips the winner. + +A wrong automatic choice here is **silent** — it does not raise, it just makes the likelihood less +accurate. That is exactly the kind of error that should not be guessed at, so the flag requires an +explicit stencil name and the retired "choose for me" spelling raises. + +--- + +## 3. Mass ladder (fmin 30) + +SEOBNRv4, an IMR model. Against an exact FFT-zero-padded reference; paired, K=2000, 3 seeds; each +mass normalised to SNR_lik = 100. srate 4096, fmax 1700, fmin 30, Lmax 2. max|ΔlnL| in nats: + +| M/M☉ | nearest | cubic | sinc | winner | +|---|---|---|---|---| +| 9 | 369 | 8.70 | **3.90** | sinc, 2.2× | +| 10 | 286 | 12.2 | **4.11** | sinc, 3.0× | +| 20 | 284 | 7.85 | **3.65** | sinc, 2.2× | +| 35 | 200 | **1.67** | 3.51 | cubic, 2.1× | +| 55 | 443 | **1.31** | 3.88 | cubic, 3.0× | +| 80 | 437 | **0.346** | 3.15 | cubic, 9.1× | +| 120 | 433 | **0.143** | 7.89 | cubic, 55× | + +At srate 16384 (SEOBNRv4 cannot be generated at 4096 below M ≈ 8): M = 5 → cubic 21×, M = 2.6 → +cubic 34×. + +**Those two rows are at a HIGHER srate, and that is why they read the other way.** The same binary +is far more oversampled at srate 16384, and oversampling — not mass alone — is what sets the +answer. Do not read them as "cubic wins at low mass"; read them as "srate moves the crossover +as surely as fmin does". Every crossover quoted in this document is **at srate 4096**. + +**Do not reintroduce inspiral-only numbers here.** An earlier version of this table used TaylorT4, +which terminates at ISCO and carries no merger-ringdown. It named the **wrong stencil** at M = 9, +10 and 20, and overstated cubic's high-mass margins by up to 99×. + +--- + +## 4. fmin sweep + +Same method, 20 points, 3 seeds each; marginal winners replicated with 3 fresh seeds (all 12 +identical). srate 4096, fmax 1700 throughout. Winner and margin; **capitals** mark where the +fmin-blind rule shipped in #97 named the worse stencil: + +| M \ fmin | 20 | 30 | 50 | 100 | 150 | +|---|---|---|---|---|---| +| 9 | sinc 2.1× | sinc 2.2× | sinc 2.5× | sinc 6.1× | sinc 12.4× | +| 20 | sinc 1.8× | sinc 2.2× | sinc 2.9× | sinc 8.6× | sinc 15.9× | +| 35 | cubic 2.3× | cubic 2.1× | cubic 1.7× | **SINC 2.5×** | **SINC 5.6×** | +| 55 | cubic 2.4× | cubic 3.0× | cubic 4.4× | cubic 1.1× | **SINC 1.2×** | + +The M=35 / fmin=150 mis-call costs 5.6×, and at 15.8 nats is a *larger absolute error than +anything cubic does at fmin 30 anywhere over 9–120 M☉* — not a bookkeeping difference. + +**Conservative rule inside the measured range:** over fmin ≥ 100 **and** M ≤ 55, always choosing +sinc costs at most 1.12× (at M=55, fmin=100, the single point where cubic still wins), against **15.9×** for always choosing cubic (M=20, fmin=150 — the largest sinc-win margin in +that region; the 5.58× quoted in an earlier draft was a different quantity, the worst harm of the +old fmin-blind rule). That asymmetry is why a flat "prefer sinc" is defensible there — +bounded by the measurement, not universal. + +--- + +## 5. Mechanism + +`sinc`'s error is **flat** — 3.1–7.9 nats across the fmin-30 mass ladder and both approximants, +2.3–5.6 nats across the 20-point fmin sweep. Flat in *both* sweeps is exactly what a +window-limited, oversampling-independent error must do. + +All the variation is `cubic`'s: it degrades **~6.5–9.6×** as fmin goes 20 → 150 at fixed mass (M=9: +10.7 → 69.3 nats, 6.5×; M=20: 4.7 → 45.2, 9.6×). Note this is an ENDPOINT ratio, not a +monotone trend — cubic at M=9 is 10.7 at fmin 20 but 8.70 at fmin 30. Raising fmin cuts the long low-frequency inspiral out of +band, broadening Q relative to Nyquist — exactly sinc's regime. That is why the crossover rises. + +**Margins are scoped, and the two scopes are not interchangeable.** At fmin 30, every margin either +way over M = 9–55 is 2.1–3.0× and the worst below 120 is 9.1×. Across the fmin sweep the range is +1.1× to 15.9×. Quote whichever matches the configuration you are describing. + +The "330× penalty for picking sinc wrongly" quoted in pre-IMR revisions was a TaylorT4 artifact +and is gone either way — there is no longer a strong safety reason to break ties toward cubic. + +**Error grows as SNR²** (measured exponent 1.999–2.006 over two decades), so the choice matters +more at 3G sensitivities. + +--- + +## 6. Why bandwidth is hard to estimate at build time + +What actually sets the answer is `fNyq` divided by the true Q bandwidth. Estimating that bandwidth +is the open problem: + +- **f_ISCO is not a usable proxy.** measured/f_ISCO drifts 15.8× across 2.6–120 M☉ with IMR (worse + than the 7.4× seen with TaylorT4) and *reverses sign* near M ≈ 10. +- **A 99.99%-power quantile of the measured spectrum is not either.** With IMR points it is + non-monotone — sinc still wins at fNyq/f_Q = 4.63 while cubic already wins at 4.23 — because an + IMR spectrum has a ringdown bump rather than a smooth roll-off. +- **`RIFT.misc.psd_bandwidth` does not separate the winners** once fmin varies. See Rule 3 above. + +--- + +## 7. Cost + +Measured. `sinc` relative to `cubic` in the Q product: **~4.2–4.5× on CPU** (16 taps against 4; +tap-count bound), **~1.6–3.0× on GPU** (bandwidth bound). End-to-end on CPU at fixed n_max: +nearest 9.3 s, cubic 25.1 s, sinc 85.3 s. On GPU the difference is not resolvable in wall time. + +--- + +## 8. Limitations, and which axes have been swept + +Zero noise, analytic ZDHP PSD, Lmax 2, non-spinning, equal mass except 2.6, one sky location, one +srate/fmax/PSD combination, 3 seeds. SEOBNRv4 is unreachable at srate 4096 below M ≈ 8, so the +low-fmin crossover is bracketed 20 < M < 35 but not resolved further, and the high-fmin crossover +only as "> 55". + +**Swept: mass and fmin. Both moved the answer — and the second moved it *after* the first had been +published as settled.** `srate`, `fmax` and `Lmax` have **not** been swept and should be presumed +load-bearing until they are; on this heuristic that presumption has been correct twice out of two. + +`srate` deserves special suspicion: it is the numerator of the fNyq/bandwidth ratio this whole +document says sets the answer, and the two srate-16384 rows in §3 already show it flipping the +winner. The entire fmin sweep is at srate 4096. + +--- + +## 9. Provenance + +The fmin sweep was measured against a pinned `git archive` of the #97 merge commit `c1a2e2df`, +not a shared checkout, so a branch switch could not move code mid-run. Its fmin-30 column +reproduces #97's shipped numbers bit-for-bit, and the analysis code was validated by re-deriving +#97's published bracket from the original 9 points alone. No row is reference-limited (per-stencil +reference floors ≥ 400× below the smallest measured error; M→2M reference checks ≤ 5.7e-5 nats). diff --git a/MonteCarloMarginalizeCode/Code/RIFT/likelihood/Q_inner_product.py b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/Q_inner_product.py index 42b8cd02e..5b715944c 100644 --- a/MonteCarloMarginalizeCode/Code/RIFT/likelihood/Q_inner_product.py +++ b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/Q_inner_product.py @@ -108,3 +108,108 @@ def Q_inner_product_cubic_cupy(Q, A, start_indices, fractional_offsets, window_s ) return out + + +def Q_inner_product_sinc_cupy(Q, A, start_indices, fractional_offsets, window_size, + halfwidth=None): + """Band-limited (Lanczos windowed-sinc) Q inner product for fractional detector-time offsets. + + Same contract as ``Q_inner_product_cubic_cupy``: ``start_indices`` are the integer floor + indices of the first requested time sample, ``fractional_offsets`` the corresponding + fractional parts in [0, 1). The stencil is 2*halfwidth taps wide (default + ``factored_likelihood.SINC_HALFWIDTH_DEFAULT``) with zero extension outside the precomputed + Q buffer. + + The tap weights come from the same ``factored_likelihood._sinc_lanczos_weight_matrix`` the + CPU window builder uses, evaluated with the cupy backend so they are built ON THE DEVICE: + deriving them a second time in CUDA would put two independent definitions of the stencil in + the tree, and pulling the offsets back to the host to use the numpy path would move tens of + MB per detector per call at production n_extrinsic. The weight work is O(n_ex * 2a) against + the kernel's O(n_ex * window * n_lms * 2a), so it is negligible either way. + + WHICH STENCIL TO USE IS NOT RESTATED HERE. An earlier version of this docstring said the + choice depends on fNyq/fmax and that production "sits near Nyquist" and so favours sinc. + Both halves were measured to be wrong: fmax is not what band-limits Q, and the right choice + depends on the masses, on fmin and on srate. Live recommendation: + RIFT.likelihood.time_interp_choice.CROSSOVER_GUIDANCE. Measured tables: + RIFT/likelihood/DESIGN_q_window_stencil.md. + + COST, measured on an RTX 2080 Ti against ``Q_inner_product_cubic_cupy``, ms per call at + (n_extrinsic, window, n_lms, n_time): + + (1e4, 50, 5, 4096) 0.83 -> 1.35 1.6x + (4e4, 50, 5, 4096) 2.95 -> 5.41 1.8x + (1.6e5, 50, 5, 4096) 11.39 -> 20.66 1.8x + (4e4, 100, 9, 8192) 6.11 -> 18.08 3.0x + + i.e. well under the 4x the 16-vs-4 tap ratio would suggest, because the kernel is bandwidth + and latency bound rather than tap bound. (The CPU window builder, which is tap bound, does + show the full ~4.2-4.5x.) + """ + # Deferred import: factored_likelihood imports this module, so a top-level import would be + # circular. By call time factored_likelihood is always fully imported (it is the caller). + from .factored_likelihood import _sinc_lanczos_weight_matrix, SINC_HALFWIDTH_DEFAULT + + if halfwidth is None: + halfwidth = SINC_HALFWIDTH_DEFAULT + + num_time_points, num_lms = Q.shape + num_extrinsic_samples, _ = A.shape + + assert not cupy.isfortran(Q) + assert not cupy.isfortran(A) + + _offsets, tap_weights = _sinc_lanczos_weight_matrix( + cupy.asarray(fractional_offsets), halfwidth, xpy=cupy) + # Derived from halfwidth, NOT read back off _offsets: indexing a cupy array to get a Python + # int forces a device sync, and this runs once per detector per likelihood call. The two + # must agree, so assert it rather than trusting the comment -- cheap, host-side only. + n_taps = 2 * halfwidth + tap_first = -halfwidth + 1 + assert _offsets.shape == (n_taps,), \ + "weight-matrix stencil width %r disagrees with 2*halfwidth=%d" % (_offsets.shape, n_taps) + # ascontiguousarray alone: _sinc_lanczos_weight_matrix already builds float64, and cupy's + # astype copies even when the dtype already matches (copy=True is its default). At + # n_chunk=1.6e5 that extra (n_ex, 2a) float64 buffer is ~20 MB of transient device memory per + # detector per call, on the resource that already caps how large n_chunk can be. + tap_weights_d = cupy.ascontiguousarray(tap_weights) + + out = cupy.empty( + (num_extrinsic_samples, window_size), + dtype=cupy.complex128, + order="C", + ) + + global _cuda_code + if _cuda_code is None: + path = os.path.join(os.path.dirname(__file__), 'cuda_Q_inner_product.cu') + if not (os.path.isfile(path)): + path = os.path.join(os.path.split(os.path.dirname(__file__))[0], 'cuda_Q_inner_product.cu') + with open(path, 'r') as f: + _cuda_code = f.read() + Q_prod_fn = cupy.RawKernel(_cuda_code, "Q_inner_sinc") + else: + Q_prod_fn = cupy.RawKernel(_cuda_code, "Q_inner_sinc") + + # 2a taps against the cubic's 4, so this kernel is heavier still; keep the same conservative + # default block shape and the same env-tunable override. + num_threads_x = int(os.environ.get("RIFT_Q_SINC_THREADS_X", "4")) + num_threads_y = int(os.environ.get("RIFT_Q_SINC_THREADS_Y", "128")) + block_size = num_threads_x, num_threads_y, 0 + grid_size = ( + (num_extrinsic_samples+num_threads_x-1)//num_threads_x, + 0, + 0, + ) + args = ( + Q, A, start_indices, tap_weights_d, n_taps, tap_first, window_size, + num_time_points, num_extrinsic_samples, num_lms, + out, + ) + Q_prod_fn( + grid_size, block_size, args, + # one double per tap per threadIdx.x, staged so the innermost loop reads shared not global + shared_mem=cupy.int32(num_threads_x*n_taps*8), + ) + + return out diff --git a/MonteCarloMarginalizeCode/Code/RIFT/likelihood/SLOWROT_HANDOFF.md b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/SLOWROT_HANDOFF.md index fc6821058..41d761f9a 100644 --- a/MonteCarloMarginalizeCode/Code/RIFT/likelihood/SLOWROT_HANDOFF.md +++ b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/SLOWROT_HANDOFF.md @@ -12,10 +12,64 @@ convention floor), at an inflated sidereal rate so the delay drift is large. At 90-min-BNS rate (= x340 inflation on a 16s test): p_max=0 deficit 3.43 -> p_max=1 0.23 -> p_max=2 0.207 -> p_max=3 0.207: CONVERGES, bound-respected, NO blow-up. So Path B recovers the delay drift and is production-ready for the target signals with p_max<=2. -KNOWN LIMIT: the p>=3 catastrophic cancellation (huge high-f U terms x tiny delta_tau^p -coefficients) only bites at x1000+ inflation (>2.6x faster than any physical signal): x1000 -gives p=2 deficit 5.9 but p=3 blows to 1e5. So the band-limit fix (low-pass the p>=1 -derivative templates) is a robustness nicety, NOT a blocker for real signals. +(2026-08-19: that verdict predates #163, which fixed a second, independent defect in the FD +derivative weight's Nyquist bin affecting every odd-p band. Path B numbers taken before it are +not reliable; the post-#163 re-measurement is in the paper repo at +analyses/slowrot_bound_violation/ section 4b -- which is on an unmerged branch of that repo at +the time of writing, so this pointer resolves only once it lands.) +RETRACTED (2026-08-18) -- there is NO p>=3 catastrophic cancellation. This file used to say it +"only bites at x1000+ inflation (>2.6x faster than any physical signal)". That was an artefact of +a bug in the likelihood, not a property of the expansion: term2 dropped the arrival-time post-phase +e^{i n Omega (t-tref)} and term1 concealed it by moving the modulation onto the DATA, using + == -- an identity that is FALSE for the noise-weighted overlap, +because a frequency shift does not commute with 1/S(f). Fixed in PR #117. + The 2.6x itself was never measured: only x340 and x1000 were ever run, and 1000/340 = 2.94. + Re-measured after the fix (SEOBNRv4, fmin=50, seglen=16 s, srate=16384), deficits by p_max: + 0.5x 2.697 0.00543 0.000172 0.000182 <- x = multiple of the 90-min-BNS rate + 1.0x 4.323 0.01555 0.000166 0.000258 + 1.5x 10.040 0.06892 0.000450 0.000049 + 2.0x 51.041 2.85329 0.101903 0.001989 + 3.0x 333.19 152.282 43.01335 7.563424 + SUPERSEDED BY #163 -- every number in this block was measured with the DEFECTIVE FD + derivative weight (the Nyquist bin, see below). Re-measure before quoting any of it. + For scale, that weight violates the bound by 8.0e-03 / 8.0e-03 / 34.4 nats at p_max=1/2/3 + -- but those are from the JAX Cauchy-Schwarz ladder at INFL=1350, fmax=1700, a DIFFERENT + configuration from this table (SEOBNRv4, fmin=50, seglen=16 s, srate=16384), where the same + defect is worth ~1e-4 nats. They are not error bars on the rows below. + The p_max=1 entry was flagged 2026-08-19 as possibly the p_max=2 number copied up a row, since + issue #159 records 4.108e-03 at that same INFL=1350, fmax=1700, p_max=1. RESOLVED by + re-measurement the same day: reintroducing the Nyquist defect ALONE on the shipped tree gives + overshoot +8.0024e-03 nats there (relative residual 1.5701e-07 of 0.5 = 50991.267), so + 8.0e-03 is correct and independently reproduced. #159's 4.108e-03 is not in conflict -- it + is quoted here as a MAGNITUDE; #159 prints it as a deficit (-4.108e-03) while the decomposition + quotes the same figure as an overshoot (+4.108112e-03) -- that decomposition now lives in + RIFT_roboto_paper analyses/slowrot_bound_violation/, not in the jax test. One number, two sign conventions, no disagreement. And it + was taken with BOTH defects present, and they partly cancelled. The caveat is withdrawn. + As recorded at the time: the Cauchy-Schwarz bound is respected at EVERY rate, and p=3 + IMPROVES on p=2 at 1.5x/2x/3x (by + 9x, 51x, 5.7x). The expansion converges monotonically; high rates simply need more orders. At + the physical rate the p=2 residual (1.7e-4) sits at the test's rotation-off noise floor (1.5e-4), + so it is an UPPER LIMIT, not a measurement -- fractional agreement 1.6e-7. + CONSEQUENCE: the "band-limit the p>=1 derivative templates" fix proposed further down this file + is a fix for a problem that does not exist. Do not implement it. + +ALSO RETRACTED: the "~0.1-0.2 resolution floor from NoLoop nearest-neighbour time sampling" below. +That floor was a TRUNCATION artefact -- the test was running a 48.5 s chirp in a 16 s segment, so +the delayed lookup ran off the array end and nan_to_num deleted the loudest samples. + DECISIVE EVIDENCE, and it is clean: run INFL=1. With the rotation switched off the floor is + still there (0.205), and INFL=1 is IMMUNE to the PR #117 bug because that error scales with + Omega. A floor that survives turning the rotation off cannot be the rotation likelihood; it also + fails to improve under a 4x finer time grid or under cubic sub-sample interpolation, so it was + not the time lookup either. With a segment the waveform actually fits, the ROTATION-OFF floor is + 1.5e-4 (measured both before and after #117 -- they agree, as they must). + CAUTION on the supporting scans: the srate and cubic comparisons quoted in the analysis notes + were run at INFL=340 with rotation ON in the truncated configuration, so their ABSOLUTE numbers + contain the #117 bug. Only the INFL=1 comparison and the qualitative "does not improve with grid + refinement" trend survive; do not quote those absolute values. + NOTE the 1.5e-4 above is the rotation-off floor. It is NOT what a fitting segment alone buys at + the physical rate: pre-#117, with a fitting segment, that was still 0.053. Reaching 1.7e-4 at + the physical rate needed BOTH a fitting segment AND the #117 fix. + Separately validated vs LAL's SimDetectorStrainREAL8TimeSeries (`test_slowrot_pathB_groundtruth.py`): baseline/PathA/PathB all agree with Jolien's full delay map to ~0.07 at fmax=256 (the ~26 deficit at fmax=1024 was SimDetectorStrain's high-f TD delay-INTERPOLATION, not a bug -- @@ -78,6 +132,12 @@ precompute-and-marginalize architecture. Two effects, both implemented (Path A + elementary template `a=(p,n)`: `Q^a(t)`, `U^{(a,a')}`, `V^{(a,a')}`. - `rotation_coefficients` / `rotation_coefficients_vector` — the analytic scalars `C_{(p,ntilde)} = (1/p!) sum_{n+m=ntilde} A_tilde_n [(-D)^{*p}]_m` (Path A: `{(0,n): A_tilde_n}`). + **Harmonic width (issue #142).** That convolution widens the harmonic index by one per + derivative order (`|n|<=2` antenna * `|m|<=1` delay-drift), so the bank must carry + `|ntilde| <= required_harmonic_width(p_max) = 2 + p_max` — **not** the `|n|<=2` of the + antenna alone. The precompute's `harmonics=(-2..2)` default is the `p_max=0` answer only; + it now widens itself (and warns) rather than letting the evaluators drop the missing + coefficients, which they both do silently. Guarded by `test_slowrot_harmonic_width.py`. - `FactoredLogLikelihoodWithRotation(...)` — scalar lnL (per-sample); term1 = `Re[sum_lm conj(Ylm) sum_a conj(C_a) Q^a(t_det)]`, term2 with `U^{(a,a')}` (coef `conj(C_a)C_a'`) and `V^{(a,a')}` (coef `C_{(p,-nu)} C_a'`). @@ -99,10 +159,12 @@ precompute-and-marginalize architecture. Two effects, both implemented (Path A + python RIFT/likelihood/test_slowrot_likelihood_v1.py # scalar Path A vs baseline + brute force python RIFT/likelihood/test_slowrot_noloop.py # vectorized Path A vs baseline NoLoop python RIFT/likelihood/test_slowrot_noloop_bruteforce.py # vectorized Path A vs brute force + python RIFT/likelihood/test_slowrot_cauchy_schwarz.py # lnL <= 0.5 + explicit-model value python RIFT/likelihood/test_slowrot_pathB.py # Path B reduction + bound python RIFT/likelihood/test_slowrot_headtohead.py # matched-sample rotation vs baseline (cubic) python RIFT/likelihood/test_slowrot_freqresponse.py # [Path D] finite-size response vs LAL python RIFT/likelihood/test_slowrot_freqresponse_likelihood.py # [Path D] likelihood: V1/V3 + V4 positive control + python RIFT/likelihood/test_slowrot_harmonic_width.py # bank covers every C_{(p,ntilde)} (#142) VALUE DEMOS (verify-anywhere, no condor/GPU) -- consolidated in the RIFT tree: cd demo/rift/slowrot && make demo # rotation (Path A/B) + finite-size (Path D) @@ -122,8 +184,33 @@ End-to-end ILE head-to-head (ILE-GPU-Paper demo data), baseline vs rotation vs f ## Validation status (all PASSING) - Response harmonics vs LAL: ~1e-16. FD ops vs LAL round trips: ~1e-13. - Path A scalar: V1a (Omega=0 vs baseline) 2.7e-12; V1b (real vs brute force) 2.6e-9. -- Path A vectorized: vs baseline NoLoop 3.6e-12; vs brute force 3.2e-10; V0 (precompute - recovery on real data) exact. +- Path A vectorized: vs baseline NoLoop 3.6e-12; vs brute force 3.9e-10 (against the REWRITTEN, + convention-free brute force -- see below; the old figure 3.2e-10 was against a reference that + shared the implementation's conventions); V0 (precompute recovery on real data) exact. +- jax_ile (issue #131, ported 2026-08-18): the JAX rotation contraction now carries the + arrival-time post-phase in BOTH terms, so its rho_sq is arrival-time dependent (rank-1 in + (sample, time bin), bucketed by m = n_a' - n_a, as the NoLoop does). test_jax_slowrot.py + rotation gate (a) vs the NoLoop: max|rel| 1.33e-05 -> 2.14e-15 (max|abs| 5.37e-02 -> 5.46e-12) + at p_max=0, and 7.75e-14 (2.62e-10 nats) at p_max=1, which the file now also runs -- Path B is + a distinct branch here because several p share a harmonic, so the m buckets mix p and the V + reflection must resolve within p. Gate restored to 1e-10. Path D (freqresponse) has no + post-phase and is unchanged at 1.6e-14. Value pinned independently by + test/jax/test_jax_slowrot_cauchy_schwarz.py (see below). +- Cauchy-Schwarz (test_slowrot_cauchy_schwarz.py, 2026-08-17): lnL sits ON 0.5 to 0 nats + with the data equal to the exact Path-A model, and matches an explicit time-domain + -(1/2) to 5e-11. Before the rotation_post_phase fix the same test overshot the + bound by 83.6 nats. +- Cauchy-Schwarz, JAX (test/jax/test_jax_slowrot_cauchy_schwarz.py, 2026-08-18): the same ladder + against jax_ile, at p_max=0 AND p_max=1, with the data equal to the exact model at each p_max + so lnL sits ON the bound. p_max=0: (A) 4.99 nats, (B) deficit 0.0, (C) 6.5e-11 vs an explicit + time-domain -(1/2), (D) 5.8e-11 vs the numpy NoLoop. p_max=1: (A) 36.4 nats, + (B) deficit 5.1e-04 of 3.2e+05, (C) 1.36e-01 = 4.2e-07 of 0.5 -- and the numpy NoLoop + disagrees with the SAME explicit reference by the identical 1.36e-01, so that residual is the + reference's conditioning (a divergent delay Taylor series at INFL=1350), not the port -- + (D) 1.3e-09. Mutation-tested at both p_max: dropping the post-phase from both terms is + self-consistent (bound NOT violated) and (C) catches it at 95.3 nats (p_max=0) / 965.7 nats + (p_max=1); dropping it from the model norm only overshoots the bound by 10.6 / 1122.5 nats and + (B) catches it. - Path B: scalar reduce-to-baseline 9e-13; respects 0.5; vectorized reduce 6.4e-12. - Path D (finite-size, --freqresponse): response Sum_p b_p W_p == antenna_response_fd to 6e-11 on both +/-f; likelihood L->0 reduces to baseline NoLoop 3e-9; Cauchy-Schwarz respected; @@ -143,6 +230,27 @@ convention, so `vec == brute-force` PASSED while both were wrong. **Always cross against the Cauchy-Schwarz bound 0.5, not only against a reference that can share conventions.** +### The same lesson fired again, and this time the bound caught it (2026-08-17) +Referencing the modulation to the intrinsic epoch is necessary but NOT sufficient. It leaves a +residual `exp(i n Omega (t_arrival - tref))` -- the post-phase -- which the implementation +dropped from the model norm, and it hid behind a second shortcut: term1 pushed the modulation +onto the DATA (` == `), an identity that is **false for a +noise-weighted overlap**, because a frequency shift does not commute with the 1/S(f) band +weight. term1 and term2 were therefore evaluating different templates, and lnL exceeded +0.5 by ~1e-4 of -- growing linearly with `Omega * (t_arrival - tref)`. + +Both are fixed: `chi_a` now goes into the data-term overlap directly (data untouched), and +`rotation_post_phase()` applies `C~_a = C_a exp(i n_a Omega (t - tref))` to BOTH terms. Patching +term2 alone does NOT work -- measured, it still violates by 73 nats where the full fix sits on +the bound exactly. + +**And, exactly as the lesson above predicted, `test_slowrot_noloop_bruteforce` certified the bug +at 3e-10 because its reference took the same two shortcuts.** That reference has been rewritten +to build the real strain `Re[F(t') hY(t'-t_arr)]` in the time domain at every arrival sample and +take both inner products of that one series -- sharing no convention with the implementation. It +now fails against the old code (2.5e-3) and passes against the new one (3.9e-10). +`test_slowrot_cauchy_schwarz.py` guards the bound itself. + ## PATH B STATUS (findings 2026-07-04, the systematic pass in progress) - Matched-seed head-to-head DONE (test_slowrot_headtohead.py): rot(f_sid=0)==baseline 9e-13; evidence shift ln Z_rot - ln Z_base = -1.1e-3 (MC-noise-free) for the short signal. @@ -151,7 +259,10 @@ conventions.** lnL(p_max=0..3) = [1794.39, 1781.31, 1781.63, 928.30]: p=0->1->2 captures a real ~13-in-lnL delay effect and appears to converge (~1781.6) and respects 0.5=1938 -- BUT p_max=3 BLOWS UP (increment 853). -- ROOT CAUSE of the p>=3 blow-up (likely): the FD derivative weight (2 pi i f)^p amplifies high +- ROOT CAUSE of the p>=3 blow-up: SETTLED, and it was NOT this. See the RETRACTED note at the + top: it was the missing arrival-time post-phase (PR #117), not the derivative weight. The + paragraph below is kept only as a record of what was believed. +- (superseded) the FD derivative weight (2 pi i f)^p amplifies high frequencies; in the model norm the integrand ~ (2 pi f)^{2p} |h(f)|^2 / S grows like f^{11/3} for a chirp (|h|^2 ~ f^{-7/3}), so high-order terms are dominated by the f_max edge, not the physical low-frequency delay drift. FIX for the systematic pass: BAND-LIMIT the delay- @@ -164,7 +275,7 @@ conventions.** ## OPEN / NEXT (the one remaining systematic pass — do it all together) 1. **Path B rigorous validation** (TWO parts, do together): - (a) FIX the p>=3 high-frequency derivative blow-up by band-limiting the delay-derivative + (a) DONE/MOOT -- there is no p>=3 blow-up to fix (PR #117). Formerly: band-limit the delay-derivative terms to low frequency (see PATH B STATUS above); re-check convergence is monotone. (b) Validate vs an INDEPENDENT ground truth that uses LAL's OWN full delay-time map -- lalsim.SimDetectorStrainREAL8TimeSeries (Jolien's code). KEY FACTS FOUND 2026-07-04: diff --git a/MonteCarloMarginalizeCode/Code/RIFT/likelihood/_gpu_test_support.py b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/_gpu_test_support.py new file mode 100644 index 000000000..c2f2b33aa --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/_gpu_test_support.py @@ -0,0 +1,31 @@ +"""Shared helper for the GPU consistency tests: skip HONESTLY when there is no GPU. + +These files are dual-use -- runnable as plain scripts on a GPU node, and collectable by pytest. +Their original no-GPU path printed a line and `return`ed, which is right for the script mode but +WRONG under pytest: a test that returns without asserting is reported as PASSED. A CI run on a +machine with no GPU would then show green for the GPU parity checks while having verified +nothing, which is precisely the false-green that hid the zero-collection bug in +test_q_window_interp.py. + +skip_without_gpu() reports a real pytest skip when running under pytest, and falls back to the +printed message when the file is run as a script. +""" +from __future__ import print_function + +import sys + + +def skip_without_gpu(have_gpu, why, label="GPU"): + """Return True if the caller should bail out because no GPU is available. + + Under pytest this raises Skipped instead of returning, so the test is recorded as SKIPPED + rather than PASSED. Run as a script, it prints and returns True. + """ + if have_gpu: + return False + msg = "cupy/GPU unavailable (%s)" % (why,) + if "pytest" in sys.modules: # collected by pytest -> real skip + import pytest + pytest.skip(msg) + print("(%s) SKIPPED: %s" % (label, msg)) # run as a script -> just say so + return True diff --git a/MonteCarloMarginalizeCode/Code/RIFT/likelihood/cuda_Q_inner_product.cu b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/cuda_Q_inner_product.cu index 1c73cb7ea..8e0c9982c 100644 --- a/MonteCarloMarginalizeCode/Code/RIFT/likelihood/cuda_Q_inner_product.cu +++ b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/cuda_Q_inner_product.cu @@ -121,4 +121,81 @@ extern "C" { } } } // Q_inner_cubic + + /* Band-limited (Lanczos windowed-sinc) sub-sample stencil. + + Unlike Q_inner_cubic, the tap weights are NOT recomputed here: they are + precomputed on the host by RIFT.likelihood.factored_likelihood. + _sinc_lanczos_weight_matrix and passed in as tap_weights, shape + (num_extrinsic_samples, n_taps) row-major. That is deliberate -- the CPU + and GPU stencils then share ONE definition of the weights, so any parity + failure is a kernel bug and never a re-derived-formula bug. The cost is + negligible: O(n_ex * n_taps) host work against O(n_ex * window * n_lms * + n_taps) device work. + + tap i sits at time index (index_start + i_time) + tap_first + i, with + tap_first = -a+1 for a half-width a (n_taps = 2a). + + The weights are normalised to sum to one over the FULL stencil on the host, + BEFORE the bounds guard below drops any tap that falls outside the + precomputed Q buffer. Dropped taps are not renormalised away, exactly as in + the CPU _sinc_Q_window_numpy, so the two agree in the zero-extension region + as well as the interior. */ + __global__ void Q_inner_sinc( + const double2 * Q, const double2 * A, + const int * index_start, + const double * tap_weights, + int n_taps, + int tap_first, + int window_size, + int num_time_points, + int num_extrinsic_samples, + int num_lms, + double2 * out + ){ + /* Weights depend only on the extrinsic sample, so stage them once per + threadIdx.x rather than re-reading global memory in the innermost loop. */ + extern __shared__ double w_sh[]; + + size_t sample_idx = threadIdx.x + blockDim.x*blockIdx.x; + size_t t_idx = threadIdx.y + blockDim.y * blockIdx.y; + + if (sample_idx < num_extrinsic_samples) { + for (int i = threadIdx.y; i < n_taps; i += blockDim.y) { + w_sh[threadIdx.x*n_taps + i] = tap_weights[sample_idx*(size_t)n_taps + i]; + } + } + /* Outside the bounds check: every thread in the block must reach this. */ + __syncthreads(); + + if (sample_idx < num_extrinsic_samples) { + int i_first_time = index_start[sample_idx]; + const double * w = w_sh + threadIdx.x*n_taps; + + for (size_t i_time = t_idx; i_time < window_size; i_time+=blockDim.y) { + size_t i_output = sample_idx*window_size + i_time; + int q_time = i_first_time + (int)i_time; + double out_re = 0.0; + double out_im = 0.0; + + for (size_t i_lm = 0; i_lm < num_lms; ++i_lm) { + double q_re = 0.0; + double q_im = 0.0; + for (int i_tap = 0; i_tap < n_taps; ++i_tap) { + int q_idx = q_time + tap_first + i_tap; + if (q_idx >= 0 && q_idx < num_time_points) { + double2 q = Q[((size_t)q_idx)*num_lms + i_lm]; + q_re += w[i_tap] * q.x; + q_im += w[i_tap] * q.y; + } + } + double2 a = A[sample_idx*num_lms + i_lm]; + out_re += a.x*q_re - a.y*q_im; + out_im += a.x*q_im + a.y*q_re; + } + + out[i_output] = make_double2(out_re, out_im); + } + } + } // Q_inner_sinc } // extern diff --git a/MonteCarloMarginalizeCode/Code/RIFT/likelihood/factored_likelihood.py b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/factored_likelihood.py index 7a287f958..1ab3f86c9 100644 --- a/MonteCarloMarginalizeCode/Code/RIFT/likelihood/factored_likelihood.py +++ b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/factored_likelihood.py @@ -73,6 +73,70 @@ def profile(fn): __author__ = "Evan Ochsner , R. O'Shaughnessy" +def marginalization_time_grid(integration_window_half, deltaT, xpy=np): + """The time-marginalization window grid, shared by every ILE likelihood path. + + THE ONE CONSTRUCTOR. Both extrinsic drivers + (``bin/integrate_likelihood_extrinsic_batchmode`` and + ``bin/integrate_likelihood_extrinsic_jax``, the latter via + ``RIFT.likelihood.jax_ile.wrapper``) must obtain their grid here, or they + silently evaluate different likelihoods -- see issue #146, which measured + up to 67.8 nats per sample between the two conventions that preceded this. + + Convention:: + + npts = int(2*integration_window_half/deltaT) + tvals = (arange(npts) - npts//2) * deltaT + + Two properties, and both matter: + + 1. **Spacing is EXACTLY deltaT.** Every consumer of this grid + (``DiscreteFactoredLogLikelihoodViaArrayVector*``, the JAX + ``fused_log_likelihood*``) reads only ``tvals[0]`` and ``len(tvals)``: + it gathers ``rho[ifirst:ifirst+npts]``, i.e. steps by one *sample*, and + integrates with ``dx=deltaT``. So ``tvals[k]`` is a LABEL for a sample + the code reaches by stepping deltaT from ``tvals[0]``, and the label is + only truthful if the grid is deltaT-spaced. The former batchmode + ``linspace(-iwh, iwh, npts)`` is spaced ``2*iwh/(npts-1)``, which + mislabelled its own samples by up to 1.4 samples (3.4e-4 s at + iwh=0.075 s, srate=4096) at the window edge -- visible wherever tvals is + used as a time label, e.g. the time-resampling export in batchmode. + + 2. **npts comes from ``int(2*iwh/deltaT)``, not ``2*int(iwh/deltaT)``.** + These differ whenever ``2*iwh/deltaT`` has a fractional part below 0.5: + at iwh=0.075 s they disagree at srate 1024, 2048 and **16384** (the + low-mass production rate) and agree at 4096 and 8192. Taking the + ``int(2*iwh/deltaT)`` form preserves batchmode's window LENGTH at every + rate; it lengthens the former JAX default by one sample at the rates + above, which is the deliberate choice made here -- a window that is a + sample too SHORT truncates the time marginalization, and matching the + longer-standing production length is the lower-risk direction. + + With ``npts`` even the grid is ``[-npts/2, npts/2)`` samples, exactly + reproducing the former JAX ``arange(-Nw, Nw)*deltaT``; with ``npts`` odd it + is symmetric, ``[-(npts//2), +(npts//2)]``. Either way ``t=0`` -- the + fiducial epoch -- is on the grid exactly, which the old linspace only + achieved for odd npts. + + Parameters + ---------- + integration_window_half : float + Half-width of the marginalization window in seconds (the drivers' + ``t_ref_wind`` / ``--data-integration-window-half``). + deltaT : float + Sample spacing in seconds. + xpy : module + ``numpy`` or ``cupy``; the array is built with ``xpy.arange``. + + Returns + ------- + array of shape (npts,), spacing exactly ``deltaT``, containing 0.0. + """ + deltaT = float(deltaT) + npts = int(2*float(integration_window_half)/deltaT) + return (xpy.arange(npts) - npts//2)*deltaT + + has_GWS=False # make sure defined in top-level scope try: if not('RIFT_NO_GWSIGNAL' in os.environ): @@ -2144,6 +2208,190 @@ def _cubic_Q_window_numpy(Q_block, start_indices, fractional_offsets, npts): return Qlms +SINC_HALFWIDTH_DEFAULT = 8 # taps per side for time_interp='sinc' (stencil 2a); see + # _sinc_Q_window_numpy for the accuracy-vs-oversampling crossover + + +def _sinc_lanczos_weight_matrix(u, a=SINC_HALFWIDTH_DEFAULT, xpy=np): + """Lanczos (windowed-sinc) interpolation weights for an ARRAY of fractional offsets. + + THIS IS THE SINGLE DEFINITION OF THE STENCIL. The CPU window builder and the GPU kernel + wrapper both come here for their weights, so the two paths cannot drift apart by someone + re-deriving the formula in CUDA; a GPU/CPU parity failure is then unambiguously a kernel bug. + + ``xpy`` selects the array backend: pass cupy and the weights are built ON THE DEVICE, so the + GPU path needs no host round trip for the per-sample offsets (at production n_extrinsic that + round trip would move tens of MB per detector per likelihood call). The arithmetic is the + same source expression either way; only the underlying sin() differs, at the 1e-16 level. + + Returns (offsets, weights): offsets has shape (2a,) and holds the integer tap positions + [-a+1, a] relative to the sample below the target; weights has shape (len(u), 2a). Both are + in the requested backend. + + L(x) = sinc(x) sinc(x/a) with the normalised sinc, so L(0)=1 and L(k)=0 at nonzero integer + k: at u=0 this reduces to the identity and reproduces the original samples exactly, as the + cubic stencil does. Weights are renormalised to sum to unity, which is a no-op at u=0 and + makes the interpolation exact for constants. + """ + u = xpy.atleast_1d(xpy.asarray(u, dtype=float)) + k = xpy.arange(-a + 1, a + 1) + x = u[:, None] - k[None, :] + w = xpy.sinc(x) * xpy.sinc(x / float(a)) + w = xpy.where(xpy.abs(x) >= a, 0.0, w) + total = w.sum(axis=1) + # A zero row cannot happen for u in [0,1) (the u=0 row is a unit vector), but guard anyway + # rather than emit NaNs into the likelihood. + total = xpy.where(total == 0, 1.0, total) + return k, w / total[:, None] + + +def _sinc_lanczos_weights(u, a=SINC_HALFWIDTH_DEFAULT): + """Scalar-offset convenience wrapper over _sinc_lanczos_weight_matrix. + + Returns (offsets, weights) with weights of shape (2a,). + """ + k, w = _sinc_lanczos_weight_matrix(u, a) + return k, w[0] + + +def _sinc_Q_window_numpy(Q_block, start_indices, fractional_offsets, npts, + a=SINC_HALFWIDTH_DEFAULT): + """Return band-limited-interpolated Q windows with zero extension. + + Same contract as _cubic_Q_window_numpy: Q_block has shape (n_time, n_lm), result has shape + (n_extrinsic, npts, n_lm). a is the number of taps per side (stencil 2a). + + WHEN THIS WINS, AND WHEN IT DOES NOT. Q^a_lm(t) is band-limited to fmax, sampled at 1/deltaT, + so what matters is the oversampling factor fNyq/fmax. The two stencils fail differently: + + * 'cubic' is a four-point Lagrange polynomial. Its error is O(h^4) and so falls FAST with + oversampling -- but it is poor near Nyquist, where a cubic cannot follow the signal. + * 'sinc' (this) is a Lanczos-windowed sinc. Its error is set by the window, NOT by h, so it + PLATEAUS: more oversampling does not help it, but neither does less hurt it. + + Measured max relative error on a synthetic band-limited signal (test_q_window_interp.py): + + fNyq/fmax cubic sinc a=8 sinc a=32 + 1.5 6.2e-2 1.2e-3 9.9e-5 + 2 2.7e-2 7.9e-4 4.7e-5 + 4 2.2e-3 4.3e-4 2.8e-5 + 8 9.0e-5 2.7e-4 2.0e-5 + 16 1.0e-5 3.3e-4 2.2e-5 + + Re-measured with 12 seeds per point, the crossover (cubic error = sinc error) sits at + fNyq/fmax ~= 5.3, with the seed-to-seed spread bracketing 1.0 only over 5-6. That crossover is stated in + fNyq/FMAX and is NOT directly usable -- see the paragraph below, which supersedes it. (An + earlier version of this docstring argued from fmax alone that production runs sit near + Nyquist at fNyq/fmax ~ 1.2 and therefore favour sinc. fmax is not what band-limits Q, so + that reasoning was wrong; the mass/fmin-based guidance in + RIFT/likelihood/DESIGN_q_window_stencil.md replaces it.) + + THE TABLE ABOVE IS FOR A SYNTHETIC SIGNAL BAND-LIMITED TO fmax, AND REAL Q IS NOT. Q^a_lm(t) + is band-limited by whichever is lower, fmax or the TEMPLATE's own cutoff, so the operative + oversampling depends on the masses AND on fmin AND on srate -- not on fmax alone. THE + GUIDANCE IS NOT REPRODUCED HERE, deliberately: it has been superseded twice and copies in + docstrings went stale both times. The live recommendation is + RIFT.likelihood.time_interp_choice.CROSSOVER_GUIDANCE, and the measured tables are in + RIFT/likelihood/DESIGN_q_window_stencil.md. Automatic selection was removed as measurably + unreliable. + + NO stencil is applied by default -- time_interp defaults to 'nearest', as does + --interpolate-time when omitted, so a caller who asks for nothing gets the nearest-bin gather + and neither interpolating stencil; 'cubic' is only the legacy truthy --interpolate-time + mapping. + + COST, measured (not estimated from the tap count): + CPU ~4.2-4.5x cubic -- 2a=16 taps against 4, and this path IS tap-count bound. + GPU ~1.6-3.0x cubic -- Q_inner_sinc is bandwidth/latency bound, so it does far better + than the naive 4x. See Q_inner_product.Q_inner_product_sinc_cupy. + """ + npts_extrinsic = len(start_indices) + n_lms_det = Q_block.shape[1] + Qlms = np.zeros((npts_extrinsic, npts, n_lms_det), dtype=np.complex128) + tgrid = np.arange(npts) + n_time = Q_block.shape[0] + # All 2a weights for all samples in one shot -- the same call the GPU wrapper makes. The + # per-sample scalar wrapper costs ~0.4 s at n_extrinsic=8000 against ~7 ms vectorized; in + # situ it saves rather more than that (0.61-0.67 s, i.e. 13% of this path at npts=64 + # falling to 6% at npts=512), because its half-dozen small temporaries per sample were + # churning the allocator against a working set of hundreds of MB. Interleaved A/B in one + # process, min of 5, ldas-pcdev13, 2026-08-16. + # + # Bit-identical to the per-sample form -- verified, not assumed, since this is a core + # likelihood path: 48060 weight rows over a in 2..64 and batch sizes 1..8000, plus 33M + # output elements compared with tobytes(). The one thing that could have differed is the + # axis=1 reduction (numpy is free to block a (1,2a) sum differently from row i of an + # (n,2a) sum); it does not. + offsets, weight_matrix = _sinc_lanczos_weight_matrix(fractional_offsets, a) + for i in range(npts_extrinsic): + idxs = int(start_indices[i]) + tgrid + weights = weight_matrix[i] + for offset, weight in zip(offsets, weights): + if weight == 0.0: + continue + idxs_here = idxs + offset + valid = (idxs_here >= 0) & (idxs_here < n_time) + if np.any(valid): + Qlms[i, valid] += weight * Q_block[idxs_here[valid]] + return Qlms + + +TIME_INTERP_CHOICES = ('nearest', 'cubic', 'sinc') + + +def validate_time_interp(time_interp, on_gpu=False): + """Reject unknown stencils loudly. + + All three stencils now have both a CPU and a GPU implementation ('sinc' via the Q_inner_sinc + kernel added alongside Q_inner and Q_inner_cubic), so on_gpu no longer restricts the choice. + It is kept in the signature because the callers pass it and because it documents, at each + call site, that the stencil has to be legal on the backend actually in use. + """ + if time_interp not in TIME_INTERP_CHOICES: + raise ValueError("time_interp must be one of %r, got %r" + % (TIME_INTERP_CHOICES, time_interp)) + return time_interp + + +def _q_window_numpy_interp(Q_block, start_indices, fractional_offsets, npts, time_interp, + xpy=np): + """CPU Q-window dispatch. start_indices must already match the stencil: 'nearest' rounds, + the interpolating stencils floor and carry the fractional part separately.""" + if time_interp == 'nearest': + return _nearest_Q_window_numpy(Q_block, start_indices, npts, xpy=xpy) + if time_interp == 'sinc': + return _sinc_Q_window_numpy(Q_block, start_indices, fractional_offsets, npts) + if time_interp == 'cubic': + return _cubic_Q_window_numpy(Q_block, start_indices, fractional_offsets, npts) + # Named explicitly rather than falling through to cubic. A bare `return cubic` here would + # reinstate exactly the silent-wrong-stencil behaviour this work exists to remove: callers + # reaching the dispatcher directly (the tests do) would get cubic for a typo and never find + # out. Driver callers are validated upstream; this is the backstop for everyone else. + raise ValueError("unknown time_interp %r; expected one of %r" + % (time_interp, TIME_INTERP_CHOICES)) + + +def _q_inner_product_gpu(Q, A, start_indices, fractional_offsets, npts, time_interp): + """GPU Q-product dispatch: the device-side counterpart of _q_window_numpy_interp. + + Same stencil contract as the CPU dispatch, deliberately: the four GPU call sites (here x2, + plus _with_rotation and _freqresponse) all route through this one function so a new stencil + cannot be wired into three of them and forgotten in the fourth. Note this returns the + CONTRACTED (n_extrinsic, npts) product, not the (n_extrinsic, npts, n_lm) window the CPU + builder returns -- the device kernels fuse the lm contraction to avoid the large temporary.""" + if time_interp == 'nearest': + return Q_inner_product.Q_inner_product_cupy(Q, A, start_indices, npts) + if time_interp == 'sinc': + return Q_inner_product.Q_inner_product_sinc_cupy( + Q, A, start_indices, fractional_offsets, npts) + if time_interp == 'cubic': + return Q_inner_product.Q_inner_product_cubic_cupy( + Q, A, start_indices, fractional_offsets, npts) + # Explicit, for the same reason as the CPU dispatcher above: no silent fallthrough to cubic. + raise ValueError("unknown time_interp %r; expected one of %r" + % (time_interp, TIME_INTERP_CHOICES)) + + def _nearest_Q_window_numpy(Q_block, start_indices, npts, xpy=np): """Return nearest-grid Q windows with zero extension.""" npts_extrinsic = len(start_indices) @@ -2209,17 +2457,33 @@ def DiscreteFactoredLogLikelihoodViaArrayVectorNoLoop(tvals, P_vec, lookupNKDic Distance-marginalization table+params for the fused distmarg kernel; see RIFT.likelihood.Q_fused_calmarg.Q_fused_calmarg_distmarg_cupy. - time_interp : {'nearest', 'cubic'} + time_interp : {'nearest', 'cubic', 'sinc'} Detector-time sampling convention for the data term. 'nearest' preserves the historical NoLoop integer-bin gather. 'cubic' evaluates the precomputed Q_lm time series at the fractional detector arrival time using a four-sample cubic Lagrange stencil, with zero extension outside the precomputed buffer. + + CHOOSING BETWEEN 'cubic' AND 'sinc': neither is uniformly better. 'cubic' (4-point + Lagrange) has O(h^4) error, so it improves fast with oversampling and is poor near + Nyquist; 'sinc' (Lanczos) is window-limited, so its error is flat in oversampling. + The operative quantity is NOT fNyq/fmax: Q^a_lm(t) is band-limited by whichever is + lower, fmax or the TEMPLATE's own cutoff, so the right choice depends on the masses, + on fmin AND on srate. The recommendation is NOT restated here -- it has been superseded + twice and docstring copies went stale both times. Live value: + RIFT.likelihood.time_interp_choice.CROSSOVER_GUIDANCE; measured tables: + RIFT/likelihood/DESIGN_q_window_stencil.md. THE DEFAULT IS 'nearest', NOT 'cubic': this argument defaults to + 'nearest', and the batch-mode CLI's --interpolate-time defaults to off, which also + resolves to 'nearest'. Omitting either therefore keeps the historical nearest-bin + behavior, whose errors the measured guidance calls scientifically significant (200-443 + nats at SNR 100, reaching 1 nat by SNR 2-6); 'cubic' is only what a legacy truthy + --interpolate-time value maps to. Ask for a stencil explicitly if you want one. + All three stencils have CPU and GPU implementations. See _sinc_Q_window_numpy and + RIFT/likelihood/DESIGN_q_window_stencil.md for the measured tables. """ global distMpcRef - if time_interp not in ('nearest', 'cubic'): - raise ValueError("time_interp must be 'nearest' or 'cubic'") + validate_time_interp(time_interp, on_gpu=not (xpy is np)) if time_interp != 'nearest' and cal_method == 'fused': raise NotImplementedError("time_interp='{}' is not implemented for cal_method='fused'".format(time_interp)) @@ -2443,23 +2707,13 @@ def DiscreteFactoredLogLikelihoodViaArrayVectorNoLoop(tvals, P_vec, lookupNKDic # Shape Q = (npts_time_full, nlms) # Shape A=FY_conj = (npts_extrinsic, nlms) # shape result = (npts_extrinsic, npts_time_*window* = npts) - if time_interp == 'nearest': - Q_prod_result = Q_inner_product.Q_inner_product_cupy( - Q, FY_conj, - ifirst, npts, - ) - else: - Q_prod_result = Q_inner_product.Q_inner_product_cubic_cupy( - Q, FY_conj, - ifirst, frac_first, npts, - ) + Q_prod_result = _q_inner_product_gpu( + Q, FY_conj, ifirst, frac_first, npts, time_interp) else: # Use old code completely unchanged ... very wasteful on memory management! Q_block = rholmsArrayDict[det].T - if time_interp == 'nearest': - Qlms = _nearest_Q_window_numpy(Q_block, ifirst, npts, xpy=xpy) - else: - Qlms = _cubic_Q_window_numpy(Q_block, ifirst, frac_first, npts) + Qlms = _q_window_numpy_interp(Q_block, ifirst, frac_first, npts, time_interp, + xpy=xpy) if phase_marginalization: Qlms[:, :, 1] = xpy.conj(Qlms[:, :, 1]) @@ -2611,19 +2865,11 @@ def DiscreteFactoredLogLikelihoodViaArrayVectorNoLoop(tvals, P_vec, lookupNKDic Q_block = Q_det[c*N_window_block:(c+1)*N_window_block] # (N_window, n_lms) ifirst_within = ifirst_det.astype(np.int32) if not (xpy is np): - if time_interp == 'nearest': - Q_prod_result = Q_inner_product.Q_inner_product_cupy( - Q_block, FY_conj_det, ifirst_within, npts, - ) - else: - Q_prod_result = Q_inner_product.Q_inner_product_cubic_cupy( - Q_block, FY_conj_det, ifirst_within, frac_first_det, npts, - ) + Q_prod_result = _q_inner_product_gpu( + Q_block, FY_conj_det, ifirst_within, frac_first_det, npts, time_interp) else: - if time_interp == 'nearest': - Qlms = _nearest_Q_window_numpy(Q_block, ifirst_within, npts, xpy=xpy) - else: - Qlms = _cubic_Q_window_numpy(Q_block, ifirst_within, frac_first_det, npts) + Qlms = _q_window_numpy_interp(Q_block, ifirst_within, frac_first_det, npts, + time_interp, xpy=xpy) # Q_det and FY_conj_det already encode any phase-marg conjugation Q_prod_result = np.einsum("ej,etj->et", FY_conj_det, Qlms) kappa_sq_c += Q_prod_result * invDistMpc[..., np.newaxis] diff --git a/MonteCarloMarginalizeCode/Code/RIFT/likelihood/factored_likelihood_freqresponse.py b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/factored_likelihood_freqresponse.py index b6d3667bf..4d12cc8f2 100644 --- a/MonteCarloMarginalizeCode/Code/RIFT/likelihood/factored_likelihood_freqresponse.py +++ b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/factored_likelihood_freqresponse.py @@ -386,6 +386,11 @@ def _L_of(det): # -- this likelihood is CPU-only but runs inside the GPU cvmfs container). t_det = float(P_vec.tref - float(t_ref)) + FL.TimeDelayFromEarthCenter( detector_location, RA, DEC, gmst_tref, xpy=np) + # NOTE: this file previously had NO validation, so an unknown time_interp + # silently executed the cubic branch below. Gate it. (An earlier revision of this + # comment also said 'sinc' was rejected on GPU; that stopped being true when + # Q_inner_sinc landed -- all three stencils now have both backends.) + FL.validate_time_interp(time_interp, on_gpu=not (xpy is np)) sample_first = (t_det + float(tvals[0])) / P_vec.deltaT # float(): tvals may be a cupy array on GPU if time_interp == 'nearest': ifirst = (np.round(sample_first) + 0.5).astype(int) @@ -406,10 +411,7 @@ def _L_of(det): frac_d = None if time_interp == 'nearest' else xpy.asarray(frac_first) for p in p_list: Q = xpy.ascontiguousarray(rho_by_p[det][p].T) # (n_time, n_lms), device - if time_interp == 'nearest': - res = Q_inner_product.Q_inner_product_cupy(Q, conjY_d, ifirst_i32, npts) - else: - res = Q_inner_product.Q_inner_product_cubic_cupy(Q, conjY_d, ifirst_i32, frac_d, npts) + res = FL._q_inner_product_gpu(Q, conjY_d, ifirst_i32, frac_d, npts, time_interp) term1 += xpy.conj(b_d[p])[:, None] * res else: for p in p_list: @@ -419,7 +421,8 @@ def _L_of(det): for i in range(npts_ex): Qa[i] = det_rho[..., ifirst[i]:ilast[i]].T else: - Qa = FL._cubic_Q_window_numpy(det_rho.T, ifirst, frac_first, npts) + Qa = FL._q_window_numpy_interp(det_rho.T, ifirst, frac_first, npts, + time_interp) term1 += np.conj(bvec[p])[:, None] * np.einsum('xi,xti->xt', np.conj(Ylms), Qa) term1 = term1.real * inv_dist[:, None] diff --git a/MonteCarloMarginalizeCode/Code/RIFT/likelihood/factored_likelihood_with_rotation.py b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/factored_likelihood_with_rotation.py index 04e9d6f43..981659438 100644 --- a/MonteCarloMarginalizeCode/Code/RIFT/likelihood/factored_likelihood_with_rotation.py +++ b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/factored_likelihood_with_rotation.py @@ -28,11 +28,22 @@ Index conventions in the returned structures -------------------------------------------- An "elementary modulated template" is labelled a = (p, n): - chi_a(t) = exp(i n Omega t) * d^p/dt^p h_lm(t - tau_0). -The physical data-term time series carries a post-phase (derived in the notes): - Q^a_lm(t) = exp(i n Omega t) * < chi_a(.-t) | d > [applied here] -while the cross terms are arrival-time independent: - U^{a,a'} = < chi_a | chi_a' >, V^{a,a'} = < chi_a^* | chi_a' >. + chi_a(u) = exp(i n Omega u) * d^p/du^p h_lm(u - tau_0), +with u the template's INTRINSIC time (its own epoch, ~0), not absolute GPS. Everything the +precompute returns is a plain overlap against that intrinsic-time object: + Q^a_lm(t) = < chi_a(.-t) | d >, + U^{a,a'} = < chi_a | chi_a' >, V^{a,a'} = < chi_a^* | chi_a' >. + +THE ARRIVAL-TIME POST-PHASE IS THE EXTRINSIC LAYER'S JOB, AND IT APPLIES TO BOTH TERMS. +The physical modulation runs on absolute time, exp(i n Omega (t' - tref)); placing the +template at arrival time t splits it as exp(i n Omega u) * exp(i n Omega (t - tref)). So the +coefficient that multiplies chi_a in the model is not C_a but + + C~_a(t) = C_a * exp(i n_a Omega (t - tref)), [rotation_post_phase] + +and the SAME C~ must be used in the data term AND in the model norm. Using C~ in only one of +them evaluates and for two different h, which breaks the Cauchy-Schwarz bound +lnL <= (1/2) by O(n Omega (t-tref)) -- see test_slowrot_cauchy_schwarz.py. Path A (default) uses only p = 0 (amplitude drift; exact 5-harmonic). Path B adds p >= 1. @@ -41,6 +52,8 @@ """ from __future__ import print_function, division +import warnings + import numpy as np # Sidereal angular rate [rad/s] and frequency [Hz] @@ -52,6 +65,45 @@ # test_slowrot_fd_ops.py (which will fail loudly if this is wrong). FT_SIGN = -1.0 +# Half-width of the ANTENNA harmonic set: F_k(t) = sum_{|n|<=2} A_n e^{i n g} is exact +# (the antenna pattern is quadratic in the rotating detector basis vectors). The DELAY +# harmonic set B_n has half-width 1. rotation_coefficients convolves the antenna +# harmonics with the delay-drift harmonics once per derivative order, so the harmonic +# index of the response coefficients C_{(p,ntilde)} widens by exactly one per order -- +# see required_harmonic_width, and test_slowrot_harmonic_width.py, which MEASURES both +# half-widths rather than trusting this comment. +N_ANTENNA_HARMONICS = 2 +N_DELAY_HARMONICS = 1 + + +def required_harmonic_width(p_max): + """Half-width |ntilde|_max actually populated by rotation_coefficients at this p_max. + + C_{(p,ntilde)} = (1/p!) sum_{n+m=ntilde} A_tilde_n [(-D)^{*p}]_m, with |n| <= 2 and + |m| <= 1, so the p-th derivative order reaches |ntilde| <= 2 + p and the full bank + needs |ntilde| <= 2 + p_max. Any C outside the precomputed harmonic set has no + elementary-template band, and BOTH maintained evaluators drop it without complaint + (the NoLoop's Cg/Cg_d return zero for a missing a; the JAX packer in jax_ile.banded + packs only a_list) -- i.e. a narrow harmonic set silently truncates the model. See + issue #142. + """ + return N_ANTENNA_HARMONICS + N_DELAY_HARMONICS * int(p_max) + + +def widen_harmonics_for_p_max(harmonics, p_max): + """Union of a requested harmonic set with the symmetric range required at p_max. + + Returns ``(harmonics_out, widened_Q)``. The requested set is returned UNCHANGED + (same order) when it is already wide enough, so callers that rely on the ordering of + ``meta['a_list']`` are unaffected in the common case. + """ + w = required_harmonic_width(p_max) + required = set(range(-w, w + 1)) + have = set(int(n) for n in harmonics) + if required.issubset(have): + return tuple(harmonics), False + return tuple(sorted(have | required)), True + # --------------------------------------------------------------------------- # Low-level FD primitives (numpy only; operate on a complex spectrum + its fvals). @@ -69,10 +121,66 @@ def evaluate_fvals_from_length(npts, deltaF): def time_derivative_weight(fvals, p): - """(FT_SIGN * 2 pi i f)^p : exact FD weight for the p-th time derivative.""" + """(FT_SIGN * 2 pi i f)^p : FD weight for the p-th time derivative. + + THE NYQUIST BIN IS ZEROED FOR ODD p, AND ONLY FOR ODD p. This packing carries +fNyq at + k=0 but no -fNyq -- the bin holding -f[k] is npts-k, which for k=0 is bin 0 itself -- so + that bin serves both signs, which a weight can only do when it is EVEN in f. For odd p + it is not, and conj(h^(p)) and (conj h)^(p), the same function, then differ there by a + sign. U takes both factors from one template family and cannot see it; + V = pairs the two orders and can. The sidereal modulation is a sub-bin + shift applied as a time-domain phase, so its FFT round trip spreads that one bin across + the band -- being above fMax does not protect it. Zero is the sampled derivative there, + not a compromise: the Nyquist component is (-1)^j, whose odd derivatives vanish at every + sample, and zero is the only value that can serve both signs at once. + + DO NOT extend the zeroing to even p. There the weight is real, there is no ambiguity, + and the derivative IS exactly representable; zeroing it is a regression, not extra + safety. test_slowrot_fd_ops pins both parities -- and pins the VALUE, not just + consistency, because any real value at that bin satisfies consistency. + + slowrot_freqresponse.unpaired_extreme_bin applies the same rule but declines on + `not (any(f<0) and any(f>0))` where this one declines on `not any(f<0)`. They agree on + every axis production builds and DISAGREE on an all-negative one: this zeroes 1 bin, + that zeroes 0. Not interchangeable, and this is the primary site for that fact -- do + not reduce it to a pointer. + + Evidence and measured impact: PRs #117 and #163, and RIFT_roboto_paper + analyses/slowrot_nyquist_bin/NOTE.md + analyses/slowrot_bound_violation/. + + Do NOT reason that this bin sits above fMax and therefore cannot matter -- it does sit + above fMax, and it still mattered, because the modulation round trip does not leave it + there. + + THE SAME RULE APPLIES IN PATH D, and if you are editing this you probably need to edit + that too: slowrot_freqresponse.finite_size_response_weights has the same unpaired-bin + problem and resolves it the same way, via the Hermitian average Re W_p(+fNyq). Neither + module imports the other, so the duplicate is deliberate; see #164. + """ if p == 0: return np.ones_like(fvals, dtype=complex) - return (FT_SIGN * 2.0j * np.pi * fvals) ** p + w = (FT_SIGN * 2.0j * np.pi * fvals) ** p + if p % 2 == 0: + return w + f = np.asarray(fvals) + # NOTE: the ndim test below is redundant -- a 0-d array has size 1, so the size test already + # covers it -- and mutating it alone is an EQUIVALENT MUTANT that no test can kill. Kept for + # readability; recorded here so a future mutation sweep does not chase it as a coverage gap. + # (Deliberately phrased without the literal source text: a naive string-replace mutation + # harness will otherwise rewrite THIS COMMENT instead of the code and report a survivor.) + if f.ndim < 1 or f.size < 2 or not np.any(f < 0): + # Nothing to repair: a one-sided (or degenerate) frequency axis has no unpaired + # Nyquist bin. Leave it rather than eat the top of its band. + return w + fn = np.max(np.abs(f)) + if np.any(f >= fn) and np.any(f <= -fn): + # Both +fn and -fn are present, so the extreme bin IS paired and the weight is well + # defined there. Test UNPAIREDNESS, not magnitude: keying on |f| == max alone would + # blank both ends of a symmetric axis, where nothing is wrong. + return w + w = np.array(w, dtype=complex) + w[np.abs(f) >= fn] = 0. # abs(): the unpaired bin is at -fNyq in fftfreq ordering + return w def apply_time_derivative_array(spectrum, fvals, p): @@ -138,10 +246,15 @@ def _lal_freq_modulate(hf, coef, f_sidereal=F_SIDEREAL, t_ref=0.0): forward-FFT back. Uses the same COMPLEX16 transforms RIFT uses for its overlaps. The reference t_ref is physical, not cosmetic: the true antenna phase is - exp(i n (GMST(t')-RA)) = exp(i n (GMST(t_ev)-RA)) * exp(i n Omega (t'-t_ev)), so the - precompute must carry exactly exp(i n Omega (t' - t_ev)) at absolute data time t', - with the constant GMST(t_ev) piece carried analytically by A_n (slowrot_response). - Hence callers pass t_ref = event_time_geo. + exp(i n (GMST(t')-RA)) = exp(i n (GMST(tref)-RA)) * exp(i n Omega (t'-tref)), with the + constant GMST(tref) piece carried analytically by A_n (slowrot_response). + + ALL CALLERS NOW PASS t_ref = 0.0, i.e. they modulate on the template's own INTRINSIC time + axis (hf.epoch ~ -T_dur, near zero). An earlier revision also modulated the DATA with + t_ref = event_time_geo, to push exp(i n Omega t) off the template and onto the data; that + identity is false for a noise-weighted overlap and is gone. The remaining absolute-time + piece, exp(i n Omega (t_arrival - tref)), is applied once in the extrinsic layer by + rotation_post_phase() -- to BOTH the data term and the model norm. """ import lal if coef == 0: @@ -184,7 +297,7 @@ def PrecomputeLikelihoodTermsWithRotation( harmonics=(-2, -1, 0, 1, 2), p_max=0, f_sidereal=F_SIDEREAL, analyticPSD_Q=False, inv_spec_trunc_Q=False, T_spec=0., verbose=True, quiet=False, internal_fast_precompute=True, - skip_interpolation=False, **hlm_kwargs): + skip_interpolation=False, widen_harmonics=True, **hlm_kwargs): """Slow-rotation analogue of factored_likelihood.PrecomputeLikelihoodTerms. Builds each FD mode once (via factored_likelihood.internal_hlm_generator) and forms the @@ -195,7 +308,26 @@ def PrecomputeLikelihoodTermsWithRotation( crossTermsV_rot[det][(a,a')] : { ((l,m),(l',m')) : } Parameters mirror PrecomputeLikelihoodTerms; rotation-specific: - harmonics : sidereal harmonic indices n to carry (antenna needs |n|<=2). + harmonics : sidereal harmonic indices ntilde to carry. The bank must cover EVERY + index the response coefficients populate, which is NOT just the antenna's + |n| <= 2: rotation_coefficients convolves the antenna harmonics (|n| <= 2) + with the delay-drift harmonics (|m| <= 1) once per derivative order, so the + required half-width is + + required_harmonic_width(p_max) = 2 + p_max + + i.e. |ntilde| <= 2 at p_max=0, <= 3 at p_max=1, <= 4 at p_max=2. The default + (-2..2) is the p_max=0 answer ONLY. A coefficient with no band is dropped + without complaint by both maintained evaluators (the NoLoop's Cg/Cg_d return + zero for a missing a; the JAX packer in jax_ile.banded packs only a_list), so + a too-narrow set yields a quietly truncated model -- consistent, but not the + model that was asked for. See issue #142. + widen_harmonics : if True (default) a too-narrow `harmonics` is widened to the + union with (-(2+p_max) .. 2+p_max) and a RuntimeWarning names the new width; + the extra bands cost |a_list|^2 cross-term overlaps, so the warning is worth + reading. Set False ONLY to build a deliberately truncated bank for band-level + inspection that will never be turned into a likelihood -- the truncation is + then recorded as meta['harmonics_truncated']. p_max : max delay-derivative order (0 = Path A amplitude-only; >=1 = Path B). f_sidereal: sidereal frequency [Hz]. @@ -209,6 +341,29 @@ def PrecomputeLikelihoodTermsWithRotation( environment and is done separately; the FD primitives used here are unit-tested in test_slowrot_fd_ops.py. """ + # --- harmonic-width contract (issue #142) ------------------------------------- + # rotation_coefficients populates |ntilde| <= 2 + p_max; anything outside the bank is + # dropped silently downstream. Widen (or, if the caller opted out, record the fact). + # tuple() FIRST and use only the tuple below: `harmonics` may be any iterable, and a + # generator consumed here and re-iterated later would silently yield an empty a_list. + harmonics_requested = tuple(harmonics) + n_required = required_harmonic_width(p_max) + if widen_harmonics: + harmonics, _widened = widen_harmonics_for_p_max(harmonics_requested, p_max) + harmonics_truncated = False + if _widened: + warnings.warn( + "PrecomputeLikelihoodTermsWithRotation: harmonics=%s cannot carry every " + "response coefficient at p_max=%d (rotation_coefficients populates " + "|ntilde| <= 2 + p_max = %d); widened to %s. Pass a harmonic set at " + "least this wide to silence this, or widen_harmonics=False to accept a " + "truncated model." % (harmonics_requested, p_max, n_required, harmonics), + RuntimeWarning, stacklevel=2) + else: + harmonics = harmonics_requested + harmonics_truncated = not set(range(-n_required, n_required + 1)).issubset( + set(int(n) for n in harmonics)) + # Lazy heavy imports (need the full RIFT stack / lal). import lal from . import factored_likelihood as FL @@ -216,11 +371,10 @@ def PrecomputeLikelihoodTermsWithRotation( assert data_dict.keys() == psd_dict.keys() detectors = list(data_dict.keys()) - t_ev = float(event_time_geo) - # The exp(i n Omega t) modulation for the data term Q is applied to the DATA (shift by - # -n f_sidereal, referenced to t_ev), which is mode-independent: one shift per (det,n), - # and -- since the modulation lives on the fixed absolute data-time axis -- needs NO - # arrival-time-dependent post-phase. U,V use modulated templates (same t_ev reference). + # NOTE: event_time_geo now only sets the retained-window placement (t_shift/N_shift) and + # is recorded in meta. The bank itself is referenced entirely to the template's intrinsic + # epoch; the absolute-time reference enters once, in the extrinsic layer, as the + # rotation_post_phase() applied to BOTH the data term and the model norm. # Reference distance handling identical to the base precompute. P.dist = FL.distMpcRef * 1e6 * lsu.lsu_PC @@ -269,23 +423,25 @@ def PrecomputeLikelihoodTermsWithRotation( N_window = int(2 * t_window / P.deltaT) t = np.arange(N_window) * P.deltaT + float(rho_epoch + N_shift * P.deltaT) - # ---- data-term overlaps Q^a_lm(t) ---- - # exp(i n Omega t) on the template is equivalent to shifting the data spectrum by - # -n f_sidereal (mode-independent). Realize it by modulating the DATA time series - # by exp(-i n Omega (t_abs - t_ev)) (round trip). Because the modulation lives on - # the absolute data-time axis, the resulting overlap is directly - # Q^a_lm(t) = int e^{-i n Omega (t'-t_ev)} [d^p h_lm]^*(t'-t) d(t') dt' - # with NO arrival-time-dependent post-phase. + # ---- data-term overlaps Q^a_lm(t) = ---- + # The MODULATED template goes into the overlap, against the untouched data, so Q and + # the U,V cross terms below are overlaps of the same chi_a and the extrinsic layer's + # post-phase C~_a = C_a exp(i n Omega (t-tref)) makes term1 and term2 consistent. + # + # An earlier revision instead pushed the modulation onto the DATA (shift its spectrum + # by -n f_sidereal) and dropped the post-phase, on the grounds that + # == . That identity holds for the UNWEIGHTED + # overlap and FAILS for the noise-weighted one used here: a frequency shift does not + # commute with the 1/S(f) band weight. Measured, the two routes differ by ~1e-4 of + # at the physical rate -- enough to violate Cauchy-Schwarz, and it is the U,V + # terms (which have no data-side route available) that are then left inconsistent. rholms_rot[det] = {} rholms_intp_rot[det] = {} - data_by_n = {} - for n in set(nn for (_, nn) in a_list): - data_by_n[n] = data if n == 0 else _lal_freq_modulate(data, -n, f_sidereal, t_ev) for a in a_list: p, n = a rho = FL.ComputeModeIPTimeSeries( - hlms_p[p], data_by_n[n], psd, P.fmin, fMax, 1. / 2. / P.deltaT, + chi[a], data, psd, P.fmin, fMax, 1. / 2. / P.deltaT, N_shift, N_window, analyticPSD_Q, inv_spec_trunc_Q, T_spec) rholms_rot[det][a] = rho if not skip_interpolation: @@ -307,9 +463,23 @@ def PrecomputeLikelihoodTermsWithRotation( analyticPSD_Q, inv_spec_trunc_Q, T_spec, prefix="V", verbose=False, same_waveform_Q=False) + # post_phase_required marks the BANK CONVENTION, which changed when the arrival-time + # post-phase moved to the extrinsic layer: Q is now against untouched + # data, and any evaluator MUST apply rotation_post_phase() to both terms. A consumer + # written against the old convention is silently wrong rather than broken, so it is + # recorded here and every evaluator that post-phases REJECTS a bank without it (see + # require_post_phase_bank): FactoredLogLikelihoodWithRotation and + # DiscreteFactoredLogLikelihoodViaArrayVectorNoLoopWithRotation below, and + # jax_ile.banded.build_rotation_data / jax_ile.core._accumulate_unit_banded. meta = dict(harmonics=tuple(harmonics), p_max=p_max, f_sidereal=f_sidereal, a_list=a_list, event_time_geo=float(event_time_geo), - omega_earth=OMEGA_EARTH, modes=list(hlms.keys())) + omega_earth=OMEGA_EARTH, modes=list(hlms.keys()), + post_phase_required=True, + # issue #142: what was asked for, what the coefficients need, and whether + # this bank is a truncated model (only possible via widen_harmonics=False). + harmonics_requested=harmonics_requested, + harmonics_required=n_required, + harmonics_truncated=bool(harmonics_truncated)) return rholms_intp_rot, crossTerms_rot, crossTermsV_rot, rholms_rot, meta @@ -382,6 +552,45 @@ def rotation_coefficients(det, RA, DEC, psi, tref, p_max): return C +def rotation_post_phase(C, omega, delta): + """Arrival-time post-phase on the elementary-template coefficients: C~_a = C_a e^{i n_a omega delta}. + + ``delta`` = (arrival time) - (the tref the coefficients were referenced to), in seconds. + It may be a scalar or an ndarray broadcastable against the entries of ``C``. + + Why this exists: the bank is built from chi_a(u) = e^{i n Omega u} h^{(p)}(u) on the + template's INTRINSIC time u, while the physical response modulation is e^{i n Omega + (t'-tref)} on absolute time. Placing the template at arrival time t gives t' = u + t, so + the modulation factorizes as e^{i n Omega u} * e^{i n Omega (t-tref)}; the second factor + belongs to the coefficient. Apply it to BOTH the data term and the model norm, or + lnL = - (1/2) is evaluated for two different h and can exceed (1/2). + """ + return {a: c * np.exp(1.0j * a[1] * omega * delta) for a, c in C.items()} + + +def require_post_phase_bank(meta, where): + """Refuse a bank that does not declare the post-phase convention (see rotation_post_phase). + + ``meta['post_phase_required']`` marks a bank whose Q is against UNTOUCHED + data, so the evaluator owes the arrival-time post-phase on BOTH the data term and the + model norm. A bank from the previous revision instead pushed the modulation onto the + DATA and carries no such debt: post-phasing it produces finite, silently WRONG lnL rather + than an error, so check the marker rather than assume it. Same guard as + jax_ile.banded.build_rotation_data / jax_ile.core._accumulate_unit_banded. + """ + if not bool(meta.get('post_phase_required', False)): + raise ValueError( + "%s requires meta['post_phase_required'] == True: this evaluator applies the " + "arrival-time post-phase (rotation_post_phase) to both the data term and the " + "model norm, which is only correct for a bank built in that convention. Got " + "meta['post_phase_required']=%r.\n" + "That key is set by PrecomputeLikelihoodTermsWithRotation as of PR #117. A bank " + "from the earlier revision folded the modulation into the data instead and must " + "NOT be evaluated here -- regenerate it with the current " + "PrecomputeLikelihoodTermsWithRotation rather than hand-assembling meta." + % (where, meta.get('post_phase_required'))) + + def FactoredLogLikelihoodWithRotation(extr_params, rholms_intp_rot, crossTerms_rot, crossTermsV_rot, meta, Lmax): """Slow-rotation analogue of factored_likelihood.FactoredLogLikelihood (Path A). @@ -394,6 +603,8 @@ def FactoredLogLikelihoodWithRotation(extr_params, rholms_intp_rot, crossTerms_r Currently implements p_max=0 (amplitude drift only); the delay-derivative (Path B) contraction with B_n is a TODO. """ + require_post_phase_bank(meta, 'FactoredLogLikelihoodWithRotation') + import lal from . import factored_likelihood as FL from .. import lalsimutils as lsu @@ -425,6 +636,11 @@ def FactoredLogLikelihoodWithRotation(extr_params, rholms_intp_rot, crossTerms_r for det in detectors: C = rotation_coefficients(det, RA, DEC, psi, tref, p_max) # {(p,n): C_a} t_det = FL.ComputeArrivalTimeAtDetector(det, RA, DEC, tref) + # Arrival-time post-phase (see rotation_post_phase): delta = t_arrival - tref is just + # the geometric delay here, taken directly rather than as a difference of two ~1e9 s. + delta_arr = float(lal.TimeDelayFromEarthCenter( + FL.lalsim.DetectorPrefixToLALDetector(det).location, RA, DEC, tref)) + C = rotation_post_phase(C, 2.0 * np.pi * meta['f_sidereal'], delta_arr) CT = crossTerms_rot[det] CTV = crossTermsV_rot[det] @@ -503,7 +719,22 @@ def pack_rotation_arrays(meta, rholms_rot, crossTerms_rot, crossTermsV_rot): Returns (lookupNKDict, rho_by_a, U_by_aa, V_by_aa, epochDict), keyed per detector by elementary template a=(p,n) (Path A: a=(0,n); Path B: also p>=1). + + Issue #142: this is the gateway to the NoLoop, whose Cg/Cg_d return zero for a response + coefficient with no band. A bank built with widen_harmonics=False can be missing bands, + so say so HERE -- at the point the bank becomes a likelihood -- rather than let the + evaluator drop them quietly. (The precompute's default widens, so this never fires for + a caller who did not opt out.) """ + if meta.get('harmonics_truncated'): + warnings.warn( + "pack_rotation_arrays: this bank was built with widen_harmonics=False and " + "carries harmonics=%s, which is narrower than the |ntilde| <= 2 + p_max = %s " + "the response coefficients populate at p_max=%s. The NoLoop will evaluate a " + "TRUNCATED model (missing coefficients contribute zero), silently. Rebuild " + "the bank with widen_harmonics=True unless the truncation is deliberate." + % (meta.get('harmonics'), meta.get('harmonics_required'), meta.get('p_max')), + RuntimeWarning, stacklevel=2) a_list = list(meta['a_list']) lookupNKDict = {}; rho_by_a = {}; U_by_aa = {}; V_by_aa = {}; epochDict = {} for det in rholms_rot: @@ -555,6 +786,9 @@ def DiscreteFactoredLogLikelihoodViaArrayVectorNoLoopWithRotation( array_output=True returns lnL_t of shape (npts_ex, npts) (before time marginalization); array_output=False returns the time-marginalized lnL of shape (npts_ex,). """ + require_post_phase_bank( + meta, 'DiscreteFactoredLogLikelihoodViaArrayVectorNoLoopWithRotation') + import lal from . import factored_likelihood as FL on_gpu = not (xpy is np) @@ -594,6 +828,11 @@ def Cg(a): # feed host arrays to cupy.cos and raise -- invisible in a no-cupy sandbox, fatal on a GPU. t_det = float(P_vec.tref - float(t_ref)) + FL.TimeDelayFromEarthCenter( detector_location, RA, DEC, gmst_tref, xpy=np) + # NOTE: this file previously had NO validation, so an unknown time_interp + # silently executed the cubic branch below. Gate it. (An earlier revision of this + # comment also said 'sinc' was rejected on GPU; that stopped being true when + # Q_inner_sinc landed -- all three stencils now have both backends.) + FL.validate_time_interp(time_interp, on_gpu=not (xpy is np)) sample_first = (t_det + float(tvals[0])) / P_vec.deltaT # float(): tvals may be a cupy array on GPU if time_interp == 'nearest': ifirst = (np.round(sample_first) + 0.5).astype(int) @@ -602,25 +841,51 @@ def Cg(a): frac_first = (sample_first - np.floor(sample_first)).astype(np.float64) ilast = ifirst + npts + # ---- arrival-time post-phase (see rotation_post_phase) ---- + # Output sample j of extrinsic sample i is the template placed at arrival time + # t_ref + (samp0_i + j)*deltaT, so delta_ij = (samp0_i + j)*deltaT - off with + # off = tref - t_ref. That SEPARATES, so no (npts_ex, npts) phase array is ever + # materialized: exp(i m omega delta_ij) = pe_m[i] * pt_m[j]. + off = float(P_vec.tref - float(t_ref)) + samp0 = ifirst.astype(np.float64) if time_interp == 'nearest' else sample_first + delta0 = samp0 * P_vec.deltaT - off # (npts_ex,) + jgrid = np.arange(npts) * P_vec.deltaT # (npts,) + omega_sid = 2.0 * np.pi * meta['f_sidereal'] + _ph_cache = {} + + def _ph(m): + """exp(i m omega_sid delta_ij) as rank-1 factors (pe (npts_ex,), pt (npts,)).""" + if m not in _ph_cache: + if m == 0: + _ph_cache[m] = (None, None) # identity; callers skip the multiply + else: + _ph_cache[m] = (xpy.asarray(np.exp(1.0j * m * omega_sid * delta0)), + xpy.asarray(np.exp(1.0j * m * omega_sid * jgrid))) + return _ph_cache[m] + # Device-side arrays for the heavy contraction (identity on CPU; host->device on GPU). Ylms_d = xpy.asarray(Ylms); conjY_d = xpy.conj(Ylms_d) zero_d = xpy.zeros(npts_ex, dtype=complex) C_d = {k: xpy.asarray(v) for k, v in C.items()} Cg_d = lambda a: C_d[a] if a in C_d else zero_d + def _apply_post_phase(a, coef_ex, res): + """conj(C~_a) Q^a = conj(C_a) e^{-i n_a omega delta_ij} Q^a_ij.""" + pe, pt = _ph(-a[1]) + if pe is None: + return coef_ex[:, None] * res + return (coef_ex * pe)[:, None] * (pt[None, :] * res) + term1 = xpy.zeros((npts_ex, npts), dtype=np.complex128) if on_gpu: - # term1 = Re[ sum_a conj(C_a) sum_lm conj(Ylm) Q^a_lm(t) ]: reuse the baseline fused + # term1 = Re[ sum_a conj(C~_a) sum_lm conj(Ylm) Q^a_lm(t) ]: reuse the baseline fused # kernel per elementary template a (A = conj(Ylm)), no (n_ex,npts,n_lms) temporary. ifirst_i32 = xpy.asarray(ifirst).astype(np.int32) frac_d = None if time_interp == 'nearest' else xpy.asarray(frac_first) for a in a_list: Q = xpy.ascontiguousarray(rho_by_a[det][a].T) # (n_time, n_lms), device - if time_interp == 'nearest': - res = Q_inner_product.Q_inner_product_cupy(Q, conjY_d, ifirst_i32, npts) - else: - res = Q_inner_product.Q_inner_product_cubic_cupy(Q, conjY_d, ifirst_i32, frac_d, npts) - term1 += xpy.conj(Cg_d(a))[:, None] * res + res = FL._q_inner_product_gpu(Q, conjY_d, ifirst_i32, frac_d, npts, time_interp) + term1 += _apply_post_phase(a, xpy.conj(Cg_d(a)), res) else: for a in a_list: det_rho = rho_by_a[det][a] @@ -629,23 +894,39 @@ def Cg(a): for i in range(npts_ex): Qa[i] = det_rho[..., ifirst[i]:ilast[i]].T else: - # cubic sub-sample interpolation (calmarg time_interp='cubic'): - # _cubic_Q_window_numpy expects Q_block shape (n_time, n_lm). - Qa = FL._cubic_Q_window_numpy(det_rho.T, ifirst, frac_first, npts) - term1 += np.conj(Cg(a))[:, None] * np.einsum('xi,xti->xt', np.conj(Ylms), Qa) + # sub-sample interpolation; the helpers expect Q_block shape (n_time, n_lm). + Qa = FL._q_window_numpy_interp(det_rho.T, ifirst, frac_first, npts, + time_interp) + term1 += _apply_post_phase(a, np.conj(Cg(a)), + np.einsum('xi,xti->xt', np.conj(Ylms), Qa)) term1 = term1.real * inv_dist[:, None] - term2 = xpy.zeros(npts_ex, dtype=np.complex128) + # term2 also carries the post-phase, and it enters ONLY through m = n_a' - n_a for both + # the U contraction (conj(C~_a) C~_a') and the V one (C~_{(p,-n_a)} C~_a'). So bucket + # the |a_list|^2 einsums -- unchanged in cost -- by m, and pay one rank-1 phase per + # distinct m (4*n_harmonics+1 of them, so 4*(2+p_max)+1 at the default width) + # instead of one per pair. + term2_by_m = {} for a in a_list: aR = (a[0], -a[1]) for ap in a_list: - term2 += xpy.conj(Cg_d(a)) * Cg_d(ap) * xpy.einsum( + val = xpy.conj(Cg_d(a)) * Cg_d(ap) * xpy.einsum( 'xi,xj,ij->x', conjY_d, Ylms_d, xpy.asarray(U_by_aa[det][(a, ap)])) - term2 += Cg_d(aR) * Cg_d(ap) * xpy.einsum( + val = val + Cg_d(aR) * Cg_d(ap) * xpy.einsum( 'xi,xj,ij->x', Ylms_d, Ylms_d, xpy.asarray(V_by_aa[det][(a, ap)])) - term2 = (-0.25 * term2.real) * inv_dist ** 2 + m = ap[1] - a[1] + term2_by_m[m] = term2_by_m[m] + val if m in term2_by_m else val + # Re[] is linear, so accumulate the real part per m and keep the persistent array real. + term2 = xpy.zeros((npts_ex, npts), dtype=np.float64) + for m, val in term2_by_m.items(): + pe, pt = _ph(m) + if pe is None: + term2 += val.real[:, None] + else: + term2 += ((val * pe)[:, None] * pt[None, :]).real + term2 = (-0.25 * term2) * (inv_dist ** 2)[:, None] - lnL_t += term1 + term2[:, None] + lnL_t += term1 + term2 if array_output: return lnL_t diff --git a/MonteCarloMarginalizeCode/Code/RIFT/likelihood/jax_ile/banded.py b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/jax_ile/banded.py index b6f87e5b1..6bcc67d3e 100644 --- a/MonteCarloMarginalizeCode/Code/RIFT/likelihood/jax_ile/banded.py +++ b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/jax_ile/banded.py @@ -14,6 +14,7 @@ is reused verbatim -- only the cheap extrinsic->lnL contraction is JAX. """ +import warnings import numpy as np import jax.numpy as jnp @@ -52,6 +53,41 @@ def build_rotation_data(meta, lookupNKDict, rho_by_a, U_by_aa, V_by_aa, epochDic tref = float(meta["event_time_geo"]) detectors = list(rho_by_a.keys()) + # The bank convention: Q^a = against UNTOUCHED data, so the evaluator + # owes the arrival-time post-phase C~_a = C_a exp(i n_a Omega (t - tref)) on BOTH the + # data term and the model norm (rotation_post_phase). core._accumulate_unit_banded + # implements exactly that convention and nothing else, so refuse a bank that does not + # declare it rather than silently evaluating the wrong likelihood. + if not bool(meta.get("post_phase_required", False)): + raise ValueError( + "build_rotation_data requires meta['post_phase_required'] == True: the JAX " + "rotation evaluator applies the arrival-time post-phase (rotation_post_phase) " + "to both the data term and the model norm, which is only correct for a bank " + "built in that convention. Got meta['post_phase_required']=%r.\n" + "That key is set by PrecomputeLikelihoodTermsWithRotation as of PR #117, which " + "is the REQUIRED PARENT of this code -- if you are seeing this, the tree most " + "likely does not carry #117, in which case its precompute still uses the old " + "convention and the JAX rotation path must not be used on it at all (merge or " + "cherry-pick #117 first). If the tree does carry #117, regenerate the bank " + "with PrecomputeLikelihoodTermsWithRotation rather than hand-assembling meta." + % (meta.get("post_phase_required"),)) + + # The bank WIDTH (issue #142): the response coefficients C_{(p,ntilde)} reach + # |ntilde| <= 2 + p_max, and this packer packs only a_list -- a coefficient with no + # band is dropped and contributes zero, i.e. a truncated model that still evaluates. + # Only a bank built with widen_harmonics=False can be short, so warn rather than raise + # (the caller opted in), but do not let it through in silence. Same guard as + # factored_likelihood_with_rotation.pack_rotation_arrays. + if meta.get("harmonics_truncated"): + warnings.warn( + "build_rotation_data: this bank was built with widen_harmonics=False and " + "carries harmonics=%s, narrower than the |ntilde| <= 2 + p_max = %s the " + "response coefficients populate at p_max=%s. The JAX evaluator packs only " + "a_list, so it will evaluate a TRUNCATED model (missing coefficients " + "contribute zero). Rebuild with widen_harmonics=True unless deliberate." + % (meta.get("harmonics"), meta.get("harmonics_required"), meta.get("p_max")), + RuntimeWarning, stacklevel=2) + # Minimal baseline-shaped packed dict (rholmArray of the FIRST band as a # stand-in) so build_likelihood_data can set up lms/epoch/location/response. a0 = a_list[0] @@ -83,12 +119,21 @@ def build_rotation_data(meta, lookupNKDict, rho_by_a, U_by_aa, V_by_aa, epochDic dd["U_bank"] = jnp.asarray(U) dd["V_bank"] = jnp.asarray(V) + m_values, pp_term1_idx, pp_term2_idx = _rs.post_phase_bucketing(a_list) + data.feature = "rotation" data.band = dict( a_list=a_list, p_max=int(meta["p_max"]), harmonics=tuple(int(h) for h in meta["harmonics"]), refl_idx=np.asarray(_rs.reflection_index(a_list), dtype=np.int64), + # Arrival-time post-phase (see _rs.post_phase_bucketing): omega and the static + # m-bucket maps the accumulator needs to build exp(i m omega (t - tref)). + f_sidereal=float(meta["f_sidereal"]), + post_phase_required=True, + pp_m_values=np.asarray(m_values, dtype=np.int64), + pp_term1_idx=np.asarray(pp_term1_idx, dtype=np.int64), + pp_term2_idx=np.asarray(pp_term2_idx, dtype=np.int64), ) return data diff --git a/MonteCarloMarginalizeCode/Code/RIFT/likelihood/jax_ile/core.py b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/jax_ile/core.py index f5f4a32af..36586c20a 100644 --- a/MonteCarloMarginalizeCode/Code/RIFT/likelihood/jax_ile/core.py +++ b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/jax_ile/core.py @@ -38,7 +38,7 @@ ------------------------------- ``rho_lm^det`` is a discrete timeseries whose sample ``k`` corresponds to GPS time ``epoch_det + k * deltaT``. The window time-bin ``t`` (with -``tvals = linspace(-t_window, +t_window, npts)`` about the fiducial geocenter +``tvals = (arange(npts) - npts//2)*deltaT`` about the fiducial geocenter epoch) maps to the *fractional* sample position pos_det(theta, t) = ( (tref - epoch_det) + tau_det(RA,DEC) + tvals[0] ) / deltaT + t @@ -141,7 +141,12 @@ def build_likelihood_data(packed_per_detector, deltaT, tref, tvals, Fiducial geocenter epoch (used only to fix GMST and the per-detector ``tref - epoch`` offset; time itself is marginalized). tvals : array_like, shape (npts,) - Time-window grid, ``linspace(-t_window, t_window, npts)``. + Time-window grid. Only ``tvals[0]`` and ``len(tvals)`` are consumed -- + evaluation steps by ``deltaT`` and integrates with ``dx=deltaT`` regardless of + the grid's own spacing -- so a grid whose spacing is not ``deltaT`` mislabels + its own samples. The builders default to + ``factored_likelihood.marginalization_time_grid(iwh, deltaT)``, the same + helper ``bin/integrate_likelihood_extrinsic_batchmode`` uses (issue #146). """ gmst = float(lal.GreenwichMeanSiderealTime(tref)) detectors = {} @@ -307,6 +312,13 @@ def _accumulate_unit(data, ra, dec, psi, incl, phiref, interp, Qi = gather(Q[:, k], pos) kappa_det = kappa_det + FY_conj[:, k][:, None] * Qi kappa_unit = kappa_unit + kappa_det + # NOT a gap, and worth saying so because an earlier revision wrongly marked it as one: + # this is the BASELINE (non-banded) accumulator, unreachable for slow rotation, since + # _accumulate_unit delegates to _accumulate_unit_banded whenever data.feature is set. + # Its response coefficient is the static scalar F, evaluated once at tref and carrying + # no sidereal harmonic index, so there is no arrival-time post-phase to apply and + # genuinely does not depend on where in the window the template is placed. The + # slow-rotation model does have that dependence -- see _accumulate_unit_banded. rho_sq_unit = rho_sq_unit + rho_sq_det[:, None] return kappa_unit, rho_sq_unit @@ -355,6 +367,40 @@ def _accumulate_unit_banded(data, ra, dec, psi, incl, phiref, interp, ``term2``). ``aR`` is the V-term reflection (``(p,-n)`` for rotation, the identity for finite-size), supplied as ``data.band['refl_idx']``. + ARRIVAL-TIME POST-PHASE (``feature == "rotation"`` only). + The bank's elementary templates ``chi_a(u) = e^{i n_a Omega u} h^{(p_a)}(u)`` live on + the template's INTRINSIC time ``u``, while the physical response modulation lives on + absolute time. Placing the template at arrival time ``t`` (``t' = u + t``) factorizes + it and leaves a residual factor that belongs to the coefficient, + + C~_a(t) = C_a * exp(i n_a Omega (t - tref)) + + (``factored_likelihood_with_rotation.rotation_post_phase``), which must be applied to + the data term AND the model norm -- using it in only one evaluates ```` and + ```` for different ``h`` and breaks ``lnL <= (1/2)``. It makes ``rho_sq`` + arrival-time DEPENDENT, hence ``(S, npts)`` rather than a broadcast ``(S,)`` scalar. + + No ``(S, npts)`` phase array is materialized per band: with the gather positions + ``pos_ij = p0_i + j`` the offset separates, + + delta_ij = pos_ij * deltaT - (tref - epoch) = delta0_i + jgrid_j, + + so ``exp(i m omega delta_ij) = pe[m, i] * pt[m, j]`` is rank-1, and the phase enters + both terms only through the integer ``m`` (``-n_a`` for the data term, ``n_a' - n_a`` + for BOTH the U and V contractions). One ``(M, S)`` and one ``(M, npts)`` table cover + everything; ``M`` is the number of distinct ``m``, ``4*n_harmonics + 1`` at the default + width whatever ``p_max`` is (several ``p`` share a harmonic once ``p_max >= 1``, so the + ``(a, a')`` pairs genuinely collide in a bucket and the scatter-add accumulates them). + + This mirrors ``DiscreteFactoredLogLikelihoodViaArrayVectorNoLoopWithRotation``, + including its choice of arrival sample: ``interp="nearest"`` phases each output bin at + the sample the gather actually read. The one exception is a position at ``rint(pos) + == -1`` -- one bin off the FRONT of the rholm buffer -- where ``_gather_nearest``'s + ``trunc(. + 0.5)`` index rounds to sample 0; see the note at the ``samp0`` assignment. + + ``freqresponse`` (Path D) has NO post-phase -- its basis is not a sidereal modulation + -- and keeps the arrival-time-independent ``rho_sq``. + ``phase_marginalization`` is not supported for banded features. """ if phase_marginalization: @@ -376,6 +422,27 @@ def _accumulate_unit_banded(data, ra, dec, psi, incl, phiref, interp, t_offsets = jnp.arange(npts, dtype=jnp.float64) refl_idx = data.band["refl_idx"] # (A,) int, static + # Arrival-time post-phase: rotation only (see the docstring). Honour the bank + # convention flag rather than assuming it, so a future change fails loudly. + band = data.band + post_phase = (data.feature == "rotation") + if post_phase: + if not bool(band.get("post_phase_required", False)): + raise ValueError( + "rotation likelihood data does not declare post_phase_required; this " + "evaluator applies the arrival-time post-phase (rotation_post_phase) to " + "both the data term and the model norm and is only correct for a bank " + "built in that convention. meta['post_phase_required'] is set by " + "PrecomputeLikelihoodTermsWithRotation as of PR #117 -- if this tree does " + "not have #117, it does not have the corrected precompute either and the " + "JAX rotation path MUST NOT be used on it. Otherwise rebuild the bank " + "with banded.build_rotation_data.") + omega_sid = 2.0 * np.pi * float(band["f_sidereal"]) + pp_m = jnp.asarray(np.asarray(band["pp_m_values"], dtype=np.float64)) # (M,) + pp_t1 = np.asarray(band["pp_term1_idx"], dtype=np.int64) # (A,) static + pp_t2 = jnp.asarray(np.asarray(band["pp_term2_idx"], dtype=np.int64)) # (A,A) + M = int(pp_m.shape[0]) + kappa_unit = jnp.zeros((S, npts), dtype=jnp.complex128) rho_sq_unit = jnp.zeros((S, npts), dtype=jnp.float64) @@ -399,26 +466,66 @@ def _accumulate_unit_banded(data, ra, dec, psi, incl, phiref, interp, p0 = (t_det + data.tval0) * inv_deltaT pos = p0[:, None] + t_offsets[None, :] # (S, npts) - # --- term1: sum_a conj(C_a) * ( sum_lm conj(Y_lm) Q^a_lm(t) ) --- + if post_phase: + # delta_ij = (arrival time of output bin j for sample i) - tref, in seconds. + # ``pos`` is in samples from the rholm epoch, so delta = pos*deltaT - off with + # off = tref - epoch. It must be the arrival the GATHER actually uses, or the + # data term and the model norm drift apart again: for interp="nearest" that is + # the rounded position, for the interpolating stencils the continuous one. + # + # ``jnp.rint(p0) + j == jnp.rint(p0 + j)`` exactly (j is an integer and the sum + # is well inside float64's exact-integer range), so this IS the gathered + # position, and it stays separable in (i, j). _gather_nearest's index is + # ``trunc(rint(pos) + 0.5)``, which equals rint(pos) for every non-negative + # position; the one place the two differ is rint(pos) == -1, where that + # truncation reads sample 0 for a position one bin off the FRONT of the buffer. + # That is a pre-existing quirk of the gather (the numpy NoLoop, which slices + # ``ifirst:ilast``, is no better there) and not something the post-phase can or + # should paper over; every position the gather treats as in-bounds and + # non-negative is phased at exactly the sample it read. + off = float(data.tref_minus_epoch(det)) + samp0 = jnp.rint(p0) if interp == "nearest" else p0 + delta0 = samp0 * data.deltaT - off # (S,) + jgrid = t_offsets * data.deltaT # (npts,) + pe = jnp.exp(1j * omega_sid * pp_m[:, None] * delta0[None, :]) # (M, S) + pt = jnp.exp(1j * omega_sid * pp_m[:, None] * jgrid[None, :]) # (M, npts) + + # --- term1: sum_a conj(C~_a) * ( sum_lm conj(Y_lm) Q^a_lm(t) ) --- + # conj(C~_a) = conj(C_a) exp(-i n_a omega delta), i.e. the m = -n_a bucket. kappa_det = jnp.zeros((S, npts), dtype=jnp.complex128) for a in range(A): inner_a = jnp.zeros((S, npts), dtype=jnp.complex128) Qa = Q_bank[a] # (npts_full, K) for k in range(K): inner_a = inner_a + conjY[:, k][:, None] * gather(Qa[:, k], pos) - kappa_det = kappa_det + jnp.conj(C[a])[:, None] * inner_a + if post_phase: + i1 = int(pp_t1[a]) + kappa_det = kappa_det + ((jnp.conj(C[a]) * pe[i1])[:, None] + * (pt[i1][None, :] * inner_a)) + else: + kappa_det = kappa_det + jnp.conj(C[a])[:, None] * inner_a kappa_unit = kappa_unit + kappa_det - # --- term2: 0.5 Re[ sum_{a,a'} conj(C_a)C_a' YbarUY + C_aR C_a' YVY ] --- + # --- term2: 0.5 Re[ sum_{a,a'} conj(C~_a)C~_a' YbarUY + C~_aR C~_a' YVY ] --- # YUY[a,a'] = einsum(conjY, Y, U_bank[a,a']); YVY[a,a'] = einsum(Y, Y, V) YUY = jnp.einsum("si,sj,abij->abs", conjY, Y, U_bank) # (A,A,S) YVY = jnp.einsum("si,sj,abij->abs", Y, Y, V_bank) # (A,A,S) - # conj(C_a) C_a' and C_aR C_a' contracted over (a,a') + # conj(C_a) C_a' and C_aR C_a' contracted over (a,a') -- the post-phase is + # applied below, since it depends only on m = n_a' - n_a for both contractions. CC_U = jnp.einsum("as,bs->abs", jnp.conj(C), C) # (A,A,S) CC_V = jnp.einsum("as,bs->abs", C_refl, C) # (A,A,S) - term2_c = jnp.sum(CC_U * YUY + CC_V * YVY, axis=(0, 1)) # (S,) complex - rho_sq_det = 0.5 * term2_c.real # (S,) - rho_sq_unit = rho_sq_unit + rho_sq_det[:, None] + pair = CC_U * YUY + CC_V * YVY # (A,A,S) complex + if post_phase: + # BOTH contractions carry exp(i (n_a' - n_a) omega delta), so bucket the pairs + # by m and pay one rank-1 phase per distinct m (M of them) instead of A^2. + val_m = jnp.zeros((M, S), dtype=jnp.complex128).at[pp_t2].add(pair) + # rho_sq becomes arrival-time dependent: (S, npts), not a broadcast scalar. + rho_sq_det = 0.5 * jnp.einsum("ms,mt->st", val_m * pe, pt).real + else: + term2_c = jnp.sum(pair, axis=(0, 1)) # (S,) complex + rho_sq_det = 0.5 * term2_c.real # (S,) + rho_sq_unit = rho_sq_unit + (rho_sq_det if post_phase + else rho_sq_det[:, None]) return kappa_unit, rho_sq_unit diff --git a/MonteCarloMarginalizeCode/Code/RIFT/likelihood/jax_ile/response_slowrot.py b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/jax_ile/response_slowrot.py index bd926603f..31b984bb6 100644 --- a/MonteCarloMarginalizeCode/Code/RIFT/likelihood/jax_ile/response_slowrot.py +++ b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/jax_ile/response_slowrot.py @@ -18,6 +18,22 @@ The detector-fixed inputs (``response`` tensor, ``location`` vector) are host constants supplied by the caller (from ``lalsimulation.DetectorPrefixToLALDetector``); only ``DEC, psi, RA`` are JAX (differentiable) leaves and ``gmst_tref`` a host float. + +THE ARRIVAL-TIME POST-PHASE IS NOT IN ``C_a`` -- IT CANNOT BE. +``rotation_coefficients_dict`` / ``rotation_coefficients_packed`` return the BARE +``C_a``, matching ``rotation_coefficients_vector``. The bank's elementary templates +live on the template's INTRINSIC time ``u`` while the physical modulation lives on +absolute time, so placing the template at arrival time ``t`` leaves + + C~_a(t) = C_a * exp(i n_a Omega (t - tref)) [rotation_post_phase] + +which the evaluator MUST apply to the data term AND the model norm (dropping it from +one of them evaluates ```` and ```` for different ``h`` and breaks +``lnL <= (1/2)``; see ``test_slowrot_cauchy_schwarz.py``). It is arrival-time +dependent, so it does not fit in an ``(A, S)`` coefficient array; the helpers +:func:`harmonic_indices` and :func:`post_phase_bucketing` below give the evaluator the +static index bookkeeping it needs to apply it as a rank-1 (per-sample x per-time-bin) +phase, bucketed by ``m``. ``core._accumulate_unit_banded`` is that evaluator. """ import math @@ -200,3 +216,48 @@ def rotation_coefficients_packed(response, location, RA, DEC, psi, gmst_tref, cdict = rotation_coefficients_dict(response, location, RA, DEC, psi, gmst_tref, p_max) return pack_coefficients(cdict, a_list, S) + + +# --------------------------------------------------------------------------- +# Arrival-time post-phase bookkeeping (see the module docstring and +# factored_likelihood_with_rotation.rotation_post_phase). +# --------------------------------------------------------------------------- +def harmonic_indices(a_list): + """Sidereal harmonic ``n_a`` of each elementary template ``a = (p, n)``. + + Returns an ``(A,)`` int numpy array (static; used to index the post-phase table). + """ + return np.asarray([int(n) for (_p, n) in a_list], dtype=np.int64) + + +def post_phase_bucketing(a_list): + """Static index bookkeeping for the arrival-time post-phase. + + The post-phase enters the two likelihood terms only through an integer harmonic + multiplier ``m``, so a single table of ``exp(i m omega delta)`` serves both: + + * data term: ``conj(C~_a) Q^a`` carries ``m = -n_a`` (one per band a) + * model norm: ``conj(C~_a) C~_a'`` and ``C~_{(p,-n_a)} C~_a'`` BOTH carry + ``m = n_a' - n_a`` (one per pair) + + (The V contraction reflects the first index, ``(p, n_a) -> (p, -n_a)``, so its phase + is ``exp(i(-n_a) omega delta) exp(i n_a' omega delta)`` -- the same ``m``. This is + why ``factored_likelihood_with_rotation``'s NoLoop can bucket U and V together.) + + Returns + ------- + m_values : (M,) int numpy array + The distinct ``m`` actually needed, ascending. + term1_idx : (A,) int numpy array + ``m_values[term1_idx[a]] == -n_a``. + term2_idx : (A, A) int numpy array + ``m_values[term2_idx[a, ap]] == n_ap - n_a``. + """ + n_of_a = harmonic_indices(a_list) + t1 = -n_of_a # (A,) + t2 = n_of_a[None, :] - n_of_a[:, None] # (A, A): [a, ap] = n_ap - n_a + m_values = np.unique(np.concatenate([t1.ravel(), t2.ravel()])) + pos = {int(m): i for i, m in enumerate(m_values)} + term1_idx = np.asarray([pos[int(m)] for m in t1], dtype=np.int64) + term2_idx = np.asarray([[pos[int(m)] for m in row] for row in t2], dtype=np.int64) + return m_values.astype(np.int64), term1_idx, term2_idx diff --git a/MonteCarloMarginalizeCode/Code/RIFT/likelihood/jax_ile/wrapper.py b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/jax_ile/wrapper.py index ee51cb4d0..bb3281fc8 100644 --- a/MonteCarloMarginalizeCode/Code/RIFT/likelihood/jax_ile/wrapper.py +++ b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/jax_ile/wrapper.py @@ -53,7 +53,13 @@ def build_rotation_data_from_precompute(P, data_dict, psd_dict, fiducial_epoch, ``t_window`` is the rholm-buffer half width for the rotation precompute (it builds its own buffer, unlike the baseline two-window driver); ``tvals`` is - the marginalization grid (defaults to ``linspace(-iwh, iwh, 2*iwh/deltaT)``). + the marginalization grid, defaulting to + ``factored_likelihood.marginalization_time_grid(iwh, deltaT)`` -- spacing + exactly ``deltaT``, and the SAME grid batchmode builds (issue #146). + + ``harmonics`` defaults to the ``p_max=0`` width; at ``p_max>=1`` the precompute + widens it to ``2 + p_max`` (issue #142) and warns, because the JAX packer would + otherwise drop the response coefficients that have no band. """ import RIFT.likelihood.factored_likelihood_with_rotation as flwr from .banded import build_rotation_data @@ -68,14 +74,15 @@ def build_rotation_data_from_precompute(P, data_dict, psd_dict, fiducial_epoch, deltaT = float(P.deltaT) if tvals is None: - # tvals spaced EXACTLY by deltaT (arange, not linspace) so the grid matches - # the pos<->sample mapping and Simpson weights the likelihood assumes; the - # maintained NoLoop path uses this same arange(-Nw,Nw)*deltaT convention. - # (A linspace grid is spaced deltaT*npts/(npts-1) and shifts the time - # reference by a fraction of a sample -> a sky bias that only shows up at - # high SNR, where cubic interpolation resolves the razor-sharp peak.) - Nw = int(integration_window_half / deltaT) - tvals = np.arange(-Nw, Nw) * deltaT + # THE one window-grid constructor, shared with + # bin/integrate_likelihood_extrinsic_batchmode (issue #146). Spacing is + # exactly deltaT, matching the pos<->sample mapping and Simpson weights + # the likelihood assumes; a linspace(-iwh,iwh,npts) grid is spaced + # 2*iwh/(npts-1) instead, which shifts the time reference by a fraction + # of a sample -> a sky bias that only shows up at high SNR, where cubic + # interpolation resolves the razor-sharp peak. + tvals = factored_likelihood.marginalization_time_grid( + integration_window_half, deltaT, xpy=np) data = build_rotation_data(meta, lk, rbn, ubn, vbn, ep, deltaT, tvals) extras = dict(meta=meta, rho_by_a=rbn, U_by_aa=ubn, V_by_aa=vbn, epochDict=ep, lookupNKDict=lk) @@ -117,14 +124,15 @@ def _L_of(det): deltaT = float(P.deltaT) if tvals is None: - # tvals spaced EXACTLY by deltaT (arange, not linspace) so the grid matches - # the pos<->sample mapping and Simpson weights the likelihood assumes; the - # maintained NoLoop path uses this same arange(-Nw,Nw)*deltaT convention. - # (A linspace grid is spaced deltaT*npts/(npts-1) and shifts the time - # reference by a fraction of a sample -> a sky bias that only shows up at - # high SNR, where cubic interpolation resolves the razor-sharp peak.) - Nw = int(integration_window_half / deltaT) - tvals = np.arange(-Nw, Nw) * deltaT + # THE one window-grid constructor, shared with + # bin/integrate_likelihood_extrinsic_batchmode (issue #146). Spacing is + # exactly deltaT, matching the pos<->sample mapping and Simpson weights + # the likelihood assumes; a linspace(-iwh,iwh,npts) grid is spaced + # 2*iwh/(npts-1) instead, which shifts the time reference by a fraction + # of a sample -> a sky bias that only shows up at high SNR, where cubic + # interpolation resolves the razor-sharp peak. + tvals = factored_likelihood.marginalization_time_grid( + integration_window_half, deltaT, xpy=np) data = build_freqresponse_data(meta, lk, rbp, ubp, vbp, ep, deltaT, tvals, det_geom) extras = dict(meta=meta, rho_by_p=rbp, U_by_pp=ubp, V_by_pp=vbp, @@ -151,8 +159,16 @@ def build_data_from_precompute(P, data_dict, psd_dict, fiducial_epoch, location roams, or the analysis window slides off the buffer. * ``integration_window_half`` (``--data-integration-window-half``, default 0.075 s) -- the half-width of the time-*marginalization* window; the - ``tvals`` grid is ``linspace(-iwh, iwh, int(2*iwh/deltaT))``, exactly as - the driver constructs it. + ``tvals`` grid comes from + ``factored_likelihood.marginalization_time_grid(iwh, deltaT)``, i.e. + ``(arange(npts) - npts//2)*deltaT`` with ``npts = int(2*iwh/deltaT)`` -- + spacing exactly ``deltaT`` (see the ``if tvals is None`` branch below). + ``bin/integrate_likelihood_extrinsic_batchmode`` calls the same helper at + all ten of its window-grid sites, so the two drivers agree by value + (issue #146; it formerly built ``linspace(-iwh, iwh, int(2*iwh/deltaT))``, + spaced ``2*iwh/(npts-1)``). Anything that compares this data object + against the numpy reference should still pass ``data.tvals`` to the + reference rather than rebuild a grid. Returns ------- @@ -179,10 +195,14 @@ def build_data_from_precompute(P, data_dict, psd_dict, fiducial_epoch, deltaT = float(P.deltaT) if tvals is None: - # arange(-Nw,Nw)*deltaT: spacing exactly deltaT (see the freqresponse - # builder) -- matches the maintained NoLoop tvals convention. - Nw = int(integration_window_half / deltaT) - tvals = np.arange(-Nw, Nw) * deltaT + # THE one window-grid constructor, shared with + # bin/integrate_likelihood_extrinsic_batchmode (issue #146): spacing + # exactly deltaT, which is the convention both likelihoods EVALUATE in + # (each steps by deltaT from tvals[0] and integrates with dx=deltaT). + # All ten of batchmode's window-grid sites now call this same helper, so + # the two drivers build identical grids at every sample rate. + tvals = factored_likelihood.marginalization_time_grid( + integration_window_half, deltaT, xpy=np) data = build_likelihood_data(packed, deltaT, float(fiducial_epoch), tvals) extras = dict(rholms=rholms, cross_terms=cross_terms, diff --git a/MonteCarloMarginalizeCode/Code/RIFT/likelihood/slowrot_freqresponse.py b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/slowrot_freqresponse.py index a9950d275..cd0fb567d 100644 --- a/MonteCarloMarginalizeCode/Code/RIFT/likelihood/slowrot_freqresponse.py +++ b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/slowrot_freqresponse.py @@ -420,6 +420,35 @@ def F_fd_expanded(det, ra, dec, psi, f, Qmax, gmst=0.0, L_arm=None): return Fp, Fc +def unpaired_extreme_bin(fvals): + """Mask of the extreme-|f| bin when it has NO partner at the opposite sign. + + RIFT's two-sided packing (f[k] = deltaF*(npts/2 - k)) carries +fNyq at k=0 but no + -fNyq, because the bin holding -f[k] is npts-k, which for k=0 is bin 0 itself. That + bin has to serve both signs, so a weight which is not even in f has no consistent + value there. + + Tests UNPAIREDNESS, not magnitude: a one-sided axis has no such bin (its top is just + the top of a band), and neither does a symmetric axis carrying both +/-fmax. Returns + an all-False mask in those cases. + + factored_likelihood_with_rotation.time_derivative_weight applies the same rule with a + slightly different guard; the two are not interchangeable. + """ + f = np.asarray(fvals) + if f.ndim < 1 or f.size < 2: + return np.zeros(np.shape(f), dtype=bool) + if not (np.any(f < 0) and np.any(f > 0)): + # One-sided (or all-zero) axis: the top of an analysis band is NOT an unpaired + # Nyquist bin, and must not be touched. + return np.zeros(f.shape, dtype=bool) + fn = np.max(np.abs(f)) + if np.any(f >= fn) and np.any(f <= -fn): + # Symmetric axis: the extreme bin has a partner, so it is well defined. + return np.zeros(f.shape, dtype=bool) + return np.abs(f) >= fn + + def finite_size_response_weights(fvals, geom, Qmax): """Per-basis frequency weights W_p(f) folded into the FD modes for the likelihood. @@ -427,10 +456,27 @@ def finite_size_response_weights(fvals, geom, Qmax): p=0 ("baseline") : W_0(f) = 1 b_0 = F0 (exact lal) p=1+q : W_{1+q}(f) = e^{-i2pi f T} c_q(f) - [q==0] b_{1+q} = beta_q (arm) - Each W_p is Hermitian (W_p(-f)=conj(W_p(f))) so the V cross term needs NO - harmonic reflection. The common delay e^{-i2 pi f T} (= a T=L/c arrival-time - shift of the finite-size correction relative to the LWL baseline) is carried - inside the correction weights. Returns (weights (Npbasis, Nf) complex, coeff-builder). + The common delay e^{-i2 pi f T} (= a T=L/c arrival-time shift of the finite-size + correction relative to the LWL baseline) is carried inside the correction weights. + Returns the weights, (Npbasis, Nf) complex. + + Each W_p is Hermitian, W_p(-f) = conj(W_p(f)), which is what lets the V cross term + skip a harmonic reflection. On a two-sided grid the unpaired extreme bin has to stand + for both signs, so Hermiticity there means real; it is projected onto its real part, + the Hermitian average. Without that, crossTermsV_fr = is not the + term it claims to be. + + This is a GRID object, not a pointwise map f -> W(f): the value at the extreme bin + depends on the axis, so build the weights on the same axis the overlap will use. + + DO NOT REMOVE THE PROJECTION ON THE GROUNDS THAT IT CHANGES NOTHING. It is a no-op in + RIFT overlaps today only because ComplexIP gives the extreme bin zero weight. Any later + step that mixes frequencies -- a modulation, a resampling, a windowed round trip -- or + any consumer that indexes W directly instead of going through ComplexIP, makes it live. + The Path-B twin is this same bin, made live by exactly such a step. + + Evidence and measured impact: issues #164 / #165, and (once merged) + RIFT_roboto_paper analyses/slowrot_nyquist_bin/NOTE.md. """ fvals = np.asarray(fvals, dtype=float) c = finite_size_c_coeffs(fvals, geom['L'], Qmax) @@ -439,4 +485,7 @@ def finite_size_response_weights(fvals, geom, Qmax): W[0] = 1.0 for q in range(Qmax + 1): W[1 + q] = phase * c[q] - (1.0 if q == 0 else 0.0) + nyq = unpaired_extreme_bin(fvals) + if np.any(nyq): + W[:, nyq] = W[:, nyq].real return W diff --git a/MonteCarloMarginalizeCode/Code/RIFT/likelihood/study_stencil_lnL_sensitivity.py b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/study_stencil_lnL_sensitivity.py new file mode 100644 index 000000000..0a6376f0d --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/study_stencil_lnL_sensitivity.py @@ -0,0 +1,1248 @@ +#!/usr/bin/env python +"""study_stencil_lnL_sensitivity.py + +DOES THE Q_lm SUB-SAMPLE TIME-INTERPOLATION STENCIL MOVE lnL AND lnZ? + +Measurement, using the real RIFT likelihood machinery (no toy signals): + + * Build a ChooseWaveformParams signal, a zero-noise data_dict over H1/L1/V1, an analytic + aLIGO ZDHP PSD, and run fl.PrecomputeLikelihoodTerms + PackLikelihoodDataStructuresAsArrays + exactly as test_slowrot_noloop.py / test_slowrot_gpu.py do. + * Draw a FIXED set of K extrinsic points from a FIXED seed. Every stencil sees the SAME + points, so this is a paired comparison and the stencil is the only thing that varies. + * Evaluate fl.DiscreteFactoredLogLikelihoodViaArrayVectorNoLoop with return_lnLt=True for + time_interp in {'nearest','cubic','sinc'} on a common coarse time grid. + * REFERENCE ("infinite sinc"): Q_lm(t) as produced by ComputeModeIPTimeSeries is the inverse + FFT of a spectrum that is identically zero outside [fmin,fMax], so it is band-limited. + Zero-padding its FFT by an integer factor M and inverse-transforming is therefore an + essentially exact interpolation onto an M-times finer time grid. We then evaluate the + likelihood by NEAREST lookup on that fine grid, which is what the reference is. + + WHERE THE REFERENCE IS NOT EXACT (stated up front, and measured below): + (a) residual quantization: nearest lookup on the fine grid still has up to 1/(2M) of a + COARSE sample of timing error. Checked by re-running the reference at 2M and + demanding the reference move by much less than the smallest stencil-vs-reference + difference. + (b) periodic wrap: PrecomputeLikelihoodTerms stores a CUT of the full-length rho(t) + series, and zero-pad-FFT interpolation of a cut treats the cut as periodic. The + resulting Gibbs ringing is an error in the reference itself, which (a) cannot see + because both M and 2M share it. Checked independently by rebuilding the reference + from a Q window HALF as long (edges twice as close, wrap artifact ~2x larger) and + comparing; the evaluation window is kept far from the stored-window edges. + * Reduce each lnL_t(K,npts) to one lnL per extrinsic point by Simpson time integration with + IDENTICAL weights for all four methods (this is what the production code does internally + with dx=deltaT; doing it here keeps the quadrature out of the comparison). + * Evidence: lnZ = log(mean(exp(lnL - max))) + max over the fixed point set; repeated over + several seeds so the seed-to-seed SPREAD of lnZ - lnZ_ref is reported alongside the mean. + +Run (CPU only, off the session host): + OMP_NUM_THREADS=1 PYTHONPATH=/home/richard.oshaughnessy/rift_wt_sinc/MonteCarloMarginalizeCode/Code \ + /home/richard.oshaughnessy/RIFT_develUWM/bin/python \ + RIFT/likelihood/study_stencil_lnL_sensitivity.py 2>/dev/null +""" +from __future__ import print_function, division + +import sys +import time +import argparse + +import numpy as np +import lal +import lalsimulation as lalsim + +import RIFT.lalsimutils as lsu +import RIFT.likelihood.factored_likelihood as fl + +# Same environment workaround the existing slowrot tests use: when numba's @vectorize +# decoration fails at import (RIFT_LOWLATENCY set in this venv), factored_likelihood falls +# back to a scalar lalylm that cannot take array arguments. Rebind it locally, for this +# process only. Does not touch factored_likelihood.py on disk. +if not getattr(fl, "numba_on", True): + fl.lalylm = np.vectorize(lal.SpinWeightedSphericalHarmonic, otypes=[complex]) + +EVENT_TIME = 1e9 +LMAX = 2 +REF_STENCIL = 'cubic' # lookup used on the FFT-upsampled fine grid; see eval_reference +DELTA_F = 1. / 4. + + +# --------------------------------------------------------------------------- +# configuration / precompute +# --------------------------------------------------------------------------- +# The model behind the shipped guidance (RIFT/likelihood/DESIGN_q_window_stencil.md). Named once +# so the banner, the skip message, the argparse help and Setup cannot disagree -- they did. +DEFAULT_APPROX = 'SEOBNRv4' + + +class ApproximantUnavailable(Exception): + """The requested (model, srate, mass) combination cannot be GENERATED. + + Recoverable: the caller may legitimately skip this configuration and continue. A bad + approximant NAME is deliberately NOT this exception -- see UnknownApproximant.""" + + +class UnknownApproximant(Exception): + """The approximant name does not exist. A user typo, not a configuration limitation. + + Raised rather than skipped, and never caught by the per-configuration handlers: skipping it + made every configuration 'skip' and the process exit 0 with nothing measured, so a batch + wrapper checking $? saw success on an empty run.""" + + +def validate_approximant(approx): + """Raise UnknownApproximant if the name does not exist. Call ONCE, before dispatch. + + Doing it per-configuration was the bug: run_mass_ladder's blanket `except Exception` turned + the getattr AttributeError into a skipped mass, so a typo skipped every mass and exited 0. + Validating up front means no mode's handler can swallow it, present or future.""" + name = approx or DEFAULT_APPROX + if not hasattr(lalsim, name): + raise UnknownApproximant( + "unknown approximant %r -- not an attribute of lalsimulation. Check the spelling; " + "this is not a srate/mass problem." % (name,)) + return name + + +def build_setup_or_skip(label, approx, *args, **kwargs): + """Construct a Setup, or explain and skip. ONE implementation, called by every mode. + + An earlier revision put this recovery in run_config only; run_snr_ladder kept a bare + Setup(...) and still aborted the whole invocation with a raw lal domain error. Two copies of + a recovery path is one copy too many. + + A BAD MODEL NAME IS NOT A GENERABILITY FAILURE and must not be reported as one -- it raises + before any waveform is attempted, and no amount of raising srate will help. + """ + name = validate_approximant(approx) + try: + return Setup(label, *args, approx=approx, **kwargs) + except Exception as exc: + raise ApproximantUnavailable( + "%s could not be generated at srate %g (%s). Its ringdown must fit under Nyquist, " + "which fails for low total mass at low srate. Use a configuration whose srate is " + "high enough -- '--only B-light' is the 16384 Hz configuration in this script -- or " + "pass --approx TaylorT4 and accept that inspiral-only results named the WRONG " + "stencil at M = 9, 10 and 20. See RIFT/likelihood/DESIGN_q_window_stencil.md." + % (name, kwargs.get('fSample', args[0] if args else float('nan')), str(exc)[:120])) + + +class Setup(object): + """Everything the likelihood needs for one (sample rate, fmax, source) combination.""" + + def __init__(self, label, fSample, fmax, m1, m2, fmin, t_window, dist_mpc=200., + deltaF=DELTA_F, approx=None, quiet=True): + self.label = label + self.fSample = float(fSample) + self.fmax = float(fmax) + self.deltaT = 1. / self.fSample + self.fmin = float(fmin) + self.t_window = float(t_window) + self.oversampling = (self.fSample / 2.) / self.fmax + self.dist_mpc = float(dist_mpc) + self.deltaF = float(deltaF) + + self.Psig = lsu.ChooseWaveformParams( + fmin=self.fmin, radec=True, incl=0.3, phiref=0.0, theta=0.2, phi=1.0, psi=0.4, + m1=m1 * lal.MSUN_SI, m2=m2 * lal.MSUN_SI, + detector='H1', dist=self.dist_mpc * 1e6 * lal.PC_SI, deltaT=self.deltaT, + tref=EVENT_TIME, deltaF=self.deltaF) + # Approximant. Default (None) resolves to DEFAULT_APPROX (SEOBNRv4), the IMR model + # behind the shipped guidance -- NOT ChooseWaveformParams' own TaylorT4 default. SEOBNRv4 is a TD IMR model and is + # the reason this is an argument: TaylorT4 terminates at ISCO and has NO merger or + # ringdown, so every feature above f_ISCO in a TaylorT4 Q spectrum is termination + # ringing from the approximant rather than physics. + # DEFAULT IS THE IMR MODEL, deliberately. This script produced the guidance in + # RIFT/likelihood/DESIGN_q_window_stencil.md, and that guidance rests on SEOBNRv4. + # Defaulting to TaylorT4 meant an ordinary reproduction run regenerated inspiral-only + # numbers -- which are not merely less precise: they NAMED THE WRONG STENCIL at M = 9, + # 10 and 20, because TaylorT4 terminates at ISCO and carries no merger-ringdown. A + # script whose default output contradicts the recommendation it supports is a trap. + self.approx_name = approx or DEFAULT_APPROX + self.Psig.approx = getattr(lalsim, self.approx_name) + + if 'Taylor' in self.approx_name: + print(" ** WARNING: %s is INSPIRAL-ONLY (terminates at ISCO, no merger-ringdown).\n" + " It understates the Q bandwidth by 2-3.7x and named the WRONG stencil at\n" + " M = 9, 10 and 20. Do not use these numbers to support stencil guidance;\n" + " see RIFT/likelihood/DESIGN_q_window_stencil.md." % self.approx_name) + self.data_dict = {} + for det in ("H1", "L1", "V1"): + P = self.Psig.manual_copy() + P.detector = det + self.data_dict[det] = lsu.non_herm_hoff(P) + self.psd_dict = {det: lalsim.SimNoisePSDaLIGOZeroDetHighPower for det in self.data_dict} + # SEOBNRv4's TD path will not truncate: if the signal does not fit the segment it + # WRAPS, silently and catastrophically. Check the actual strain the likelihood will + # see -- inverse-transform the non_herm_hoff series (packed [-fNyq .. fNyq-df], hence + # the ifftshift) and test whether it is still live at the segment edges, which is + # exactly what wrapping produces. A signal that fits is tapered to ~0 at both ends. + self.seg_duration = 1.0 / self.deltaF + _ht = np.fft.ifft(np.fft.ifftshift(self.data_dict['H1'].data.data)) + _a = np.abs(_ht) + _peak = float(np.max(_a)) + assert _peak > 0 and np.all(np.isfinite(_a)), \ + "%s at M=%.4g produced empty or non-finite strain" % (self.approx_name, m1 + m2) + _n_edge = max(16, int(0.001 * len(_a))) + _edge = max(float(np.max(_a[:_n_edge])), float(np.max(_a[-_n_edge:]))) / _peak + _live = np.nonzero(_a > 1e-4 * _peak)[0] + self.wf_duration = float(len(_live)) / self.fSample + self.edge_fraction = _edge + assert _edge < 1e-2, ( + "%s at M=%.4g, srate %g, segment %.4g s: strain is still at %.2e of peak at the " + "segment edge -- the waveform does not fit and has WRAPPED" + % (self.approx_name, m1 + m2, self.fSample, self.seg_duration, _edge)) + + self.packs = self._precompute(self.t_window, quiet) + + def _precompute(self, t_window, quiet=True): + # NOTE: PrecomputeLikelihoodTerms RESETS P.dist to the fiducial reference distance + # in place, so hand it a copy. + Ptmpl = self.Psig.manual_copy() + out = fl.PrecomputeLikelihoodTerms( + EVENT_TIME, t_window, Ptmpl, self.data_dict, self.psd_dict, LMAX, self.fmax, + analyticPSD_Q=True, verbose=False, quiet=quiet, ignore_threshold=None, + skip_interpolation=True) + rholms_intp, crossTerms, crossTermsV, rholms, guess_snr, _rest = out + packs = dict(lookupNK={}, rho={}, ctU={}, ctV={}, epoch={}, snr=guess_snr) + for det in self.data_dict: + pairKeys = list(rholms[det].keys()) + (lookupNK, _keys2n, _conj, ctU, ctV, rholmArray, _intp, epoch) = \ + fl.PackLikelihoodDataStructuresAsArrays( + pairKeys, None, rholms[det], crossTerms[det], crossTermsV[det]) + packs['lookupNK'][det] = lookupNK + packs['rho'][det] = rholmArray # (n_lms, n_time) + packs['ctU'][det] = ctU + packs['ctV'][det] = ctV + packs['epoch'][det] = epoch + return packs + + def alternate_window_packs(self, t_window): # noqa: D401 + """Second precompute with a different stored-Q window (reference wrap-artifact test).""" + return self._precompute(t_window) + + +# --------------------------------------------------------------------------- +# extrinsic points +# --------------------------------------------------------------------------- +def draw_points(K, seed, dist_mpc): + """Isotropic sky/orientation, distance uniform over [0.5, 4] x the injected distance -- + the same shape as test_slowrot_gpu._P_vec (100-800 Mpc about a 200 Mpc injection), scaled + so that every configuration is probed over the same range of lnL.""" + rng = np.random.RandomState(seed) + return dict( + phi=rng.uniform(0, 2 * np.pi, K), # RA + theta=np.arcsin(rng.uniform(-1, 1, K)), # DEC + psi=rng.uniform(0, np.pi, K), + incl=np.arccos(rng.uniform(-1, 1, K)), + phiref=rng.uniform(0, 2 * np.pi, K), + dist=rng.uniform(0.5 * dist_mpc, 4.0 * dist_mpc, K) * 1e6 * lsu.lsu_PC, + ) + + + +RELEVANT_BAND = 30.0 # nats below the peak; points fainter than this carry exp(-30) of the + # posterior weight and cannot move any inference + + +def draw_points_near_truth(K, seed, setup, rho, rho0=100.0, base=0.05, s_max=0.1): + """Cloud AROUND the injection, with every offset scaled as 1/SNR. + + Why this set exists. The isotropic set above is drawn over the whole sky with distance + down to 0.5 x the injected distance, so it contains points whose lnL is enormous and + NEGATIVE (rho_sq ~ 1/d^2 with a mismatched sky). Those points have |kappa| large, hence + |d lnL| large, but weight exp(lnL - lnL_max) ~ 0: a max| | over the isotropic set is + therefore dominated by samples that cannot influence any inference. Here the offsets + scale as 1/rho, which is how the posterior width scales, so the cloud spans a comparable + band of lnL at EVERY rung and the error statistics over it are directly comparable across + the SNR ladder. + + Two guards, both necessary and both learned the hard way: + * the distance offset is LOGNORMAL (d -> d exp(s z)), not d(1 + s z). The linear form + drives d towards zero for s of order 1, and rho_sq ~ 1/d^2 then produces lnL of order + -1e10, which swamps every statistic computed over the cloud. + * s is capped at s_max. 1/rho scaling keeps the lnL span of the cloud constant, but only + while the quadratic expansion of lnL about the peak holds; the cap keeps the low-SNR + rungs inside it. Below the cap the cloud is simply TIGHTER than scale-invariant, which + is harmless. The realised lnL span is printed for every rung -- check it. + """ + rng = np.random.RandomState(seed + 777) + s = min(float(s_max), base * rho0 / float(rho)) + P = setup.Psig + eps = 1e-6 + return dict( + phi=float(P.phi) + s * rng.randn(K), + theta=np.clip(float(P.theta) + s * rng.randn(K), -np.pi / 2 + eps, np.pi / 2 - eps), + psi=float(P.psi) + s * rng.randn(K), + incl=np.clip(float(P.incl) + s * rng.randn(K), eps, np.pi - eps), + phiref=float(P.phiref) + s * rng.randn(K), + dist=setup.dist_mpc * np.exp(s * rng.randn(K)) * 1e6 * lsu.lsu_PC, + ) + + +def err_stats(lnL, lnL_ref): + """Paired error statistics, reported BOTH over all points and over the inference-relevant + band lnL_ref > max(lnL_ref) - RELEVANT_BAND.""" + assert_finite('lnL', lnL) + assert_finite('lnL_ref', lnL_ref) + d = lnL - lnL_ref + band = lnL_ref > (np.max(lnL_ref) - RELEVANT_BAND) + out = dict(maxabs=float(np.max(np.abs(d))), rms=float(np.sqrt(np.mean(d ** 2))), + mean=float(np.mean(d)), lnL_max=float(np.max(lnL)), lnL_min=float(np.min(lnL)), + n_band=int(np.sum(band))) + if out['n_band'] > 0: + db = d[band] + out.update(maxabs_band=float(np.max(np.abs(db))), + rms_band=float(np.sqrt(np.mean(db ** 2))), + mean_band=float(np.mean(db))) + else: + out.update(maxabs_band=np.nan, rms_band=np.nan, mean_band=np.nan) + return out + + +def make_Pvec(setup, pts, sl, deltaT): + Pv = setup.Psig.manual_copy() + for key in ('phi', 'theta', 'psi', 'incl', 'phiref', 'dist'): + setattr(Pv, key, np.asarray(pts[key][sl])) + Pv.tref = float(EVENT_TIME) + Pv.deltaT = float(deltaT) + return Pv + + +# --------------------------------------------------------------------------- +# band-limited (zero-pad FFT) upsampling +# --------------------------------------------------------------------------- +def bandlimited_upsample(x, M): + """Interpolate complex x (..., N) onto an M-times finer grid by FFT zero padding. + + Exact for a periodic band-limited signal; y[..., ::M] reproduces x identically. + The Nyquist bin (N even) is split symmetrically between +fNyq and -fNyq, which is the + choice that preserves y[..., ::M] == x. For a genuinely band-limited Q that bin is + numerically zero anyway; the returned nyq_frac lets the caller check that. + """ + x = np.asarray(x) + N = x.shape[-1] + X = np.fft.fft(x, axis=-1) + Nf = N * M + Y = np.zeros(x.shape[:-1] + (Nf,), dtype=np.complex128) + h = N // 2 + Y[..., :h] = X[..., :h] + Y[..., Nf - (N - h):] = X[..., h:] + if N % 2 == 0: + v = Y[..., Nf - h].copy() + Y[..., Nf - h] = 0.5 * v + Y[..., h] = 0.5 * v + y = np.fft.ifft(Y, axis=-1) * M + nyq_frac = float(np.max(np.abs(X[..., h])) / np.max(np.abs(X))) + return y, nyq_frac + + +# --------------------------------------------------------------------------- +# lnL evaluation +# --------------------------------------------------------------------------- +def eval_lnL_t(setup, packs, pts, tvals, deltaT, time_interp, rho_arrays, chunk): + """lnL_t of shape (K, len(tvals)), evaluated in chunks over extrinsic points.""" + K = len(pts['phi']) + out = np.empty((K, len(tvals)), dtype=np.float64) + for lo in range(0, K, chunk): + sl = slice(lo, min(lo + chunk, K)) + Pv = make_Pvec(setup, pts, sl, deltaT) + out[sl] = fl.DiscreteFactoredLogLikelihoodViaArrayVectorNoLoop( + tvals, Pv, packs['lookupNK'], rho_arrays, packs['ctU'], packs['ctV'], + packs['epoch'], Lmax=LMAX, xpy=np, return_lnLt=True, time_interp=time_interp) + return out + + +def eval_reference(setup, packs, pts, tvals, M, chunk, rho_fine=None, stencil='cubic'): + """Reference lnL_t on the coarse tvals grid, from an Mx finer (FFT zero-padded) Q grid. + + ``stencil`` is the lookup used ON THE FINE GRID. 'nearest' is the literal prescription + (no interpolating stencil at all), but its residual error is only O(1/M) -- at M=32 that + is still ~1/32 of the coarse 'nearest' error, which is NOT small compared to what we are + trying to resolve. 'cubic' on the fine grid is O((1/M)^4) ~ 1e-6 of the coarse cubic + error at M=32, i.e. six orders of magnitude below the differences being measured, so it + is the default; the two are shown to agree by ref_convergence_ladder() below, which walks + 'nearest' up in M until it lands on the 'cubic' reference. + """ + deltaT_f = setup.deltaT / M + npts = len(tvals) + npts_f = (npts - 1) * M + 1 + tvals_f = tvals[0] + np.arange(npts_f) * deltaT_f + if rho_fine is None: + rho_fine, _ = build_fine_rho(packs, M) + lnL_t_f = eval_lnL_t(setup, packs, pts, tvals_f, deltaT_f, stencil, rho_fine, + max(1, chunk // 4)) + return lnL_t_f[:, ::M] + + +def build_fine_rho(packs, M): + rho_fine = {} + worst_roundtrip = 0.0 + worst_nyq = 0.0 + for det, arr in packs['rho'].items(): + y, nyq = bandlimited_upsample(arr, M) + worst_roundtrip = max(worst_roundtrip, + float(np.max(np.abs(y[..., ::M] - arr)) / np.max(np.abs(arr)))) + worst_nyq = max(worst_nyq, nyq) + rho_fine[det] = y + return rho_fine, (worst_roundtrip, worst_nyq) + + +def time_marginalize(lnL_t, deltaT): + """One lnL per extrinsic point: log int dt exp(lnL_t), Simpson weights, dx=deltaT. + + Uses fl.my_simps (the same quadrature the production reduction uses) applied here so + every method gets bit-identical weights and the quadrature drops out of the comparison. + """ + m = np.max(lnL_t, axis=-1, keepdims=True) + return m[:, 0] + np.log(fl.my_simps(np.exp(lnL_t - m), dx=deltaT, axis=-1)) + + +def ln_evidence(lnL): + m = np.max(lnL) + return m + np.log(np.mean(np.exp(lnL - m))) + + +# --------------------------------------------------------------------------- +# Q spectrum diagnostic +# --------------------------------------------------------------------------- +def q_spectrum_report(setup, packs): + """How much of Q_lm's power actually lives near Nyquist? + + fNyq/fmax is only a proxy for the stencil's difficulty: Q(t) = is band-limited + by BOTH fMax and the template's own high-frequency cutoff, whichever is lower, and its + power is further shaped by |h|^2/S. A Tukey-windowed FFT of the stored Q window (windowed + to suppress the leakage from the cut) gives the honest picture. + """ + det = 'H1' + arr = packs['rho'][det] + N = arr.shape[1] + w = lal.CreateTukeyREAL8Window(N, 0.2).data.data + X = np.fft.fft(arr * w[None, :], axis=-1) + f = np.fft.fftfreq(N, d=setup.deltaT) + p = np.sum(np.abs(X) ** 2, axis=0) + order = np.argsort(np.abs(f)) + fa = np.abs(f)[order] + cum = np.cumsum(p[order]) / np.sum(p) + out = {} + for q in (0.99, 0.999, 0.9999): + out['f%g' % q] = float(fa[np.searchsorted(cum, q)]) + # fraction of power above 1/2 and 3/4 of the *stencil-relevant* Nyquist + fNyq = setup.fSample / 2. + for frac in (0.25, 0.5, 0.75): + thr = frac * fNyq + out['pow>%.2ffNyq' % frac] = float(np.sum(p[np.abs(f) > thr]) / np.sum(p)) + return out + + + +# --------------------------------------------------------------------------- +# achieved network SNR +# --------------------------------------------------------------------------- +def true_point_lnL_t(setup, packs, tvals, chunk, rho_fine=None, M=32): + """lnL(t) at the TRUE extrinsic parameters (true sky/orientation/distance). + + The data are noiseless and the template is the injection, so max_t lnL_t = rho_net^2/2 + exactly. Measuring the SNR this way uses the very machinery under test, so the SNR that + labels each rung is the one that actually sets the lnL scale (not a nominal number). + """ + P = setup.Psig + pts = dict(phi=np.array([float(P.phi)]), theta=np.array([float(P.theta)]), + psi=np.array([float(P.psi)]), incl=np.array([float(P.incl)]), + phiref=np.array([float(P.phiref)]), + dist=np.array([setup.dist_mpc * 1e6 * lsu.lsu_PC])) + return eval_reference(setup, packs, pts, tvals, M, chunk, rho_fine=rho_fine, + stencil=REF_STENCIL) + + +def network_snr(setup, packs, tvals, chunk, rho_fine=None, n_phiref=32, n_psi=8): + """SNR_lik = sqrt(2 max_t max_{phiref,psi} lnL) at the true sky, inclination and distance. + + MAXIMISED over the phase/polarization pair rather than evaluated at the nominal injected + values, and that is not a nicety. RIFT's SEOBNR mode decomposition (hlmoft -> + SimIMRSpinAlignedEOBModes) carries a phase convention that differs from the one + non_herm_hoff uses to build the injection, so at the NOMINAL true point SEOBNRv4 scores + lnL = -43 while the same data have an optimal SNR of 172. Maximising over the two + degenerate angles recovers SNR_lik/SNR_direct = 0.99 for SEOBNRv4 and 0.96 for TaylorT4: + the offset is purely a convention, the template is not corrupted, and nothing about the + PAIRED stencil comparison depends on it (same Q, same points, only the stencil varies). + Without this the SEOBNRv4 distance normalisation is nonsense (sqrt of a negative number). + """ + P = setup.Psig + ph = np.repeat(np.linspace(0, 2 * np.pi, n_phiref, endpoint=False), n_psi) + ps = np.tile(np.linspace(0, np.pi, n_psi, endpoint=False), n_phiref) + n = n_phiref * n_psi + pts = dict(phi=np.full(n, float(P.phi)), theta=np.full(n, float(P.theta)), + psi=ps, incl=np.full(n, float(P.incl)), phiref=ph, + dist=np.full(n, setup.dist_mpc * 1e6 * lsu.lsu_PC)) + lnL_t = eval_reference(setup, packs, pts, tvals, 32, chunk, rho_fine=rho_fine, + stencil=REF_STENCIL) + peak = float(np.max(lnL_t)) + if not np.isfinite(peak) or peak <= 0: + raise RuntimeError("peak lnL over the (phiref,psi) grid is %r -- cannot define an SNR" + % peak) + return float(np.sqrt(2.0 * peak)) + + +def network_snr_direct(setup): + """Independent cross-check of the network SNR: sqrt(sum_det ) from lsu.ComplexIP + on the same (noiseless) data and analytic PSD, with no likelihood machinery involved.""" + tot = 0.0 + for det, d in setup.data_dict.items(): + IP = lsu.ComplexIP(setup.fmin, setup.fmax, 1. / 2. / setup.deltaT, d.deltaF, + setup.psd_dict[det], True, False, 0.) + tot += float(np.abs(IP.ip(d, d))) + return float(np.sqrt(tot)) + + +def assert_finite(name, x): + bad = int(np.sum(~np.isfinite(x))) + if bad: + raise RuntimeError("%s: %d non-finite lnL values -- refusing to report a max| | over " + "them" % (name, bad)) + return bad + + +def ess_fraction(lnL): + """Effective sample fraction of the lnZ estimator, so the reader can see when lnZ is + dominated by a single point (which it always is at very high SNR).""" + w = np.exp(lnL - np.max(lnL)) + return float(np.sum(w) ** 2 / np.sum(w ** 2) / len(w)) + + +# --------------------------------------------------------------------------- +# SNR ladder (near-Nyquist configuration A) +# --------------------------------------------------------------------------- +def run_snr_ladder(label, fSample, fmax, m1, m2, fmin, dist0, snr_targets, K, seeds, + t_half, M_ref, M_check, t_window, chunk, approx=None): + """Configuration A across an SNR ladder. + + A stencil makes a fixed RELATIVE error in Q(t). lnL ~ SNR^2, so the ABSOLUTE lnL error + is predicted to grow as SNR^2 -- a difference that is invisible at demo SNRs need not be + invisible at 3G SNRs. SNR is varied by the injected distance only (same waveform, same + stencil geometry); the extrinsic draw is dist = x_i * d_inj with x_i FIXED across rungs, + so a clean SNR^2 scaling is what the null hypothesis predicts. + """ + t0 = time.time() + print("=" * 100) + print("SNR LADDER %s : approximant=%s fSample=%g fmax=%g fNyq/fmax=%.3g m1=%g m2=%g fmin=%g" + % (label, approx or DEFAULT_APPROX, fSample, fmax, (fSample / 2.) / fmax, m1, m2, fmin)) + sys.stdout.flush() + + try: + probe = build_setup_or_skip(label, approx, fSample, fmax, m1, m2, fmin, t_window, + dist_mpc=dist0) + except ApproximantUnavailable as exc: + print("\n SNR LADDER %s SKIPPED -- %s" % (label, exc)) + return None + npts_half = int(round(t_half * fSample)) + npts = 2 * npts_half + 1 + tvals = (np.arange(npts) - npts_half) * probe.deltaT + rho_probe = network_snr(probe, probe.packs, tvals, chunk) + print(" SNR CONVENTION: rungs are labelled by SNR_lik = sqrt(2 x peak lnL at the true") + print(" extrinsic point), i.e. the SNR the LIKELIHOOD actually attains -- that is the") + print(" quantity that sets the lnL scale, so it is what translates these nats to a real") + print(" event. The optimal network SNR of the same noiseless data is also shown;") + print(" it is larger, because the Lmax=2 template the likelihood uses does not recover") + print(" 100%% of the injected strain (a pre-existing property of this test setup, not of") + print(" the stencils, and it cancels in the paired stencil comparison).") + print(" probe: d=%g Mpc -> SNR_lik %.4g (optimal SNR: %.4g)" + % (dist0, rho_probe, network_snr_direct(probe))) + del probe + + rows = [] + for target in snr_targets: + d_inj = dist0 * rho_probe / float(target) + setup = Setup(label, fSample, fmax, m1, m2, fmin, t_window, dist_mpc=d_inj, approx=approx) + packs = setup.packs + rho_fine, _ = build_fine_rho(packs, M_ref) + rho = network_snr(setup, packs, tvals, chunk, rho_fine=rho_fine) + rho_dir = network_snr_direct(setup) + lnL_peak_true = 0.5 * rho ** 2 + + acc = dict((st, []) for st in ('nearest', 'cubic', 'sinc')) + accN = dict((st, []) for st in ('nearest', 'cubic', 'sinc')) + lnZ = dict(ref=[], nearest=[], cubic=[], sinc=[]) + ess = [] + cloud_span = [] + for seed in seeds: + for tag, pts, store in (('iso', draw_points(K, seed, d_inj), acc), + ('near', draw_points_near_truth(K, seed, setup, rho), + accN)): + lnL_t_ref = eval_reference(setup, packs, pts, tvals, M_ref, chunk, + rho_fine=rho_fine, stencil=REF_STENCIL) + lnL_ref = time_marginalize(lnL_t_ref, setup.deltaT) + assert_finite('reference', lnL_ref) + if tag == 'iso': + lnZ['ref'].append(ln_evidence(lnL_ref)) + ess.append(ess_fraction(lnL_ref)) + else: + cloud_span.append(float(np.max(lnL_ref) - np.min(lnL_ref))) + for stencil in ('nearest', 'cubic', 'sinc'): + lnL = time_marginalize( + eval_lnL_t(setup, packs, pts, tvals, setup.deltaT, stencil, + packs['rho'], chunk), setup.deltaT) + store[stencil].append(err_stats(lnL, lnL_ref)) + if tag == 'iso': + lnZ[stencil].append(ln_evidence(lnL)) + if target == snr_targets[0]: + lnL_t_r2 = eval_reference(setup, packs, pts, tvals, M_check, chunk, + stencil=REF_STENCIL) + print(" reference check at this rung: moves %.3g nats going M=%d->%d" + % (float(np.max(np.abs(time_marginalize(lnL_t_r2, setup.deltaT) - lnL_ref))), + M_ref, M_check)) + rows.append(dict(target=target, d_inj=d_inj, rho=rho, acc=acc, accN=accN, lnZ=lnZ, + ess=float(np.mean(ess)), cloud_span=float(np.mean(cloud_span)))) + print(" rung target SNR %5g -> d=%.4g Mpc, achieved SNR_lik %.5g " + "(peak lnL at truth %.6g; optimal SNR %.5g) (%.0fs)" + % (target, d_inj, rho, lnL_peak_true, rho_dir, time.time() - t0)) + sys.stdout.flush() + del rho_fine, packs, setup + + # ---- tables ---- + print("") + for tag, key, blurb in ( + ('ISOTROPIC', 'acc', + 'whole sky, dist in [0.5,4]x d_inj -- includes huge-negative-lnL samples'), + ('NEAR-TRUTH', 'accN', + 'cloud about the injection with all offsets scaled as 1/SNR')): + print("") + print(" SNR LADDER, %s point set (%s)" % (tag, blurb)) + print(" %d points x %d seeds per rung, paired across stencils" % (K, len(seeds))) + print(" %-8s %8s %11s %11s %11s %11s %12s %12s" % + ("stencil", "SNR_lik", "max|dlnL|", "RMS dlnL", "max/SNR^2", "RMS/SNR^2", + "max(lnL)", "min(lnL)")) + for r in rows: + for stencil in ('nearest', 'cubic', 'sinc'): + A = r[key][stencil] + mx = max(x['maxabs'] for x in A) + rms = float(np.mean([x['rms'] for x in A])) + print(" %-8s %8.4g %11.4g %11.4g %11.4g %11.4g %12.6g %12.6g" % + (stencil, r['rho'], mx, rms, mx / r['rho'] ** 2, rms / r['rho'] ** 2, + max(x['lnL_max'] for x in A), min(x['lnL_min'] for x in A))) + print(" (lnZ ESS fraction %.3g ; near-truth cloud lnL span %.4g nats)" + % (r['ess'], r['cloud_span'])) + print("") + + print(" EVIDENCE across the ladder: mean and seed-spread of lnZ - lnZ_ref (nats)") + print(" %-8s %8s %14s %14s" % ("stencil", "SNR", "mean dlnZ", "spread")) + for r in rows: + for stencil in ('nearest', 'cubic', 'sinc'): + d = np.array(r['lnZ'][stencil]) - np.array(r['lnZ']['ref']) + print(" %-8s %8.4g %14.5g %14.4g" % + (stencil, r['rho'], float(np.mean(d)), float(np.max(d) - np.min(d)))) + print("") + + # ---- power-law fit and the threshold SNRs ---- + print(" SCALING AND THRESHOLDS (power-law fit err = C * SNR_lik^p over the ladder;") + print(" a threshold below the lowest rung is an EXTRAPOLATION under the fitted law)") + rho_arr = np.array([r['rho'] for r in rows]) + + def _fit(y, name): + p_fit, logC = np.polyfit(np.log(rho_arr), np.log(y), 1) + C = np.exp(logC) + print(" %-42s : p = %.3f -> 0.1 nat at SNR %.4g, 1 nat at SNR %.4g" + % (name, p_fit, (0.1 / C) ** (1. / p_fit), (1.0 / C) ** (1. / p_fit))) + + for stencil in ('nearest', 'cubic', 'sinc'): + for key, lab in (('acc', 'isotropic'), ('accN', 'near-truth')): + _fit(np.array([max(x['maxabs'] for x in r[key][stencil]) for r in rows]), + "%s max|dlnL| (%s)" % (stencil, lab)) + _fit(np.array([float(np.mean([x['rms'] for x in r[key][stencil]])) + for r in rows]), "%s RMS dlnL (%s)" % (stencil, lab)) + _fit(np.array([max(1e-300, abs(float(np.mean(np.array(r['lnZ'][stencil]) - + np.array(r['lnZ']['ref']))))) + for r in rows]), "%s |mean d lnZ| (isotropic)" % stencil) + print(" total %.0f s" % (time.time() - t0)) + sys.stdout.flush() + return rows + + + +# --------------------------------------------------------------------------- +# mass ladder: does the f_ISCO bandwidth rule pick the right stencil? +# --------------------------------------------------------------------------- +# GW frequency at ISCO for total mass M (solar masses). Kept here rather than imported: +# time_interp_choice used to export it, then stopped, and this script must not break when the +# module under study is edited. +F_ISCO_1MSUN_HZ = 4397.0 + + +def chirp_time_s(m1_msun, m2_msun, f_low): + """Leading-order (0PN) inspiral duration from f_low to coalescence, seconds.""" + m1 = m1_msun * lal.MTSUN_SI + m2 = m2_msun * lal.MTSUN_SI + mc = (m1 * m2) ** 0.6 / (m1 + m2) ** 0.2 + return (5. / 256.) * mc ** (-5. / 3.) * (np.pi * f_low) ** (-8. / 3.) + + +def segment_deltaF(m1, m2, fmin, base_T=4.0): + """Segment length (as a deltaF) long enough to hold the whole signal from fmin. + + fmin is held FIXED across the mass ladder -- every mass is analysed in the same + [fmin, fmax] band, so the only thing varying is the source. That forces the segment to + grow at low mass (a 2.6 Msun binary sweeps for ~90 s from 30 Hz), which is why deltaF is + a per-mass quantity here and a constant everywhere else in this file. + """ + need = 2.0 * chirp_time_s(m1, m2, fmin) + 4.0 + T = base_T + while T < need: + T *= 2.0 + return 1.0 / T, T + + +def run_mass_ladder(fSample, fmax, fmin, masses, target_snr, K, seeds, t_half, M_ref, M_check, + t_window, t_window_short, chunk, on_gpu_variants=(False, True), + approx=None): + """Sweep total mass at FIXED srate/fmax and ask, per mass, which stencil actually wins and + whether time_interp_choice predicts it. + + Every mass is normalised to the same SNR_lik (via the injected distance) so the nats are + comparable down the ladder; the SNR^2 scaling needed to do that was measured, not assumed. + """ + import RIFT.likelihood.time_interp_choice as tic + t0 = time.time() + print("=" * 110) + print("MASS LADDER : approximant=%s fSample=%g fmax=%g fmin=%g " + "(fNyq/fmax = %.3g for every mass)" + % (approx or DEFAULT_APPROX, fSample, fmax, fmin, (fSample / 2.) / fmax)) + print(" every mass normalised to SNR_lik = %g so the nats are comparable down the ladder" + % target_snr) + print(" selector under test: %s" % tic.__file__) + sys.stdout.flush() + + rows = [] + for m_total in masses: + if abs(m_total - 2.6) < 1e-9: + m1, m2 = 1.3, 1.3 + else: + m1 = m2 = m_total / 2.0 + dF, T_seg = segment_deltaF(m1, m2, fmin) + tau = chirp_time_s(m1, m2, fmin) + + try: + probe = build_setup_or_skip('probe', approx, fSample, fmax, m1, m2, fmin, t_window, + dist_mpc=200., deltaF=dF) + except ApproximantUnavailable as exc: + # NOTE the narrow except: UnknownApproximant deliberately propagates. A blanket + # `except Exception` here turned a typo into a skipped mass, so every mass skipped + # and the run exited 0 having measured nothing. + print(" M=%6.1f : SKIPPED -- %s" % (m_total, exc)) + sys.stdout.flush() + continue + npts_half = int(round(t_half * fSample)) + npts = 2 * npts_half + 1 + tvals = (np.arange(npts) - npts_half) * probe.deltaT + rho_probe = network_snr(probe, probe.packs, tvals, chunk) + del probe + d_inj = 200. * rho_probe / float(target_snr) + + setup = Setup('M%g' % m_total, fSample, fmax, m1, m2, fmin, t_window, + dist_mpc=d_inj, deltaF=dF, approx=approx) + packs = setup.packs + spec = q_spectrum_report(setup, packs) + rho_fine, (rt, nyq) = build_fine_rho(packs, M_ref) + rho = network_snr(setup, packs, tvals, chunk, rho_fine=rho_fine) + rho_dir = network_snr_direct(setup) + check_bounds(setup, packs, seeds[:1], K, tvals, npts, M_check, d_inj) + + acc = dict((st, []) for st in ('nearest', 'cubic', 'sinc')) + floor = dict((st, np.nan) for st in ('nearest', 'cubic', 'sinc')) + lnZ = dict(ref=[], nearest=[], cubic=[], sinc=[]) + for seed in seeds: + pts = draw_points(K, seed, d_inj) + lnL_ref = time_marginalize( + eval_reference(setup, packs, pts, tvals, M_ref, chunk, rho_fine=rho_fine, + stencil=REF_STENCIL), setup.deltaT) + lnZ['ref'].append(ln_evidence(lnL_ref)) + lnL_by_stencil = {} + for stencil in ('nearest', 'cubic', 'sinc'): + lnL = time_marginalize( + eval_lnL_t(setup, packs, pts, tvals, setup.deltaT, stencil, packs['rho'], + chunk), setup.deltaT) + lnL_by_stencil[stencil] = lnL + acc[stencil].append(err_stats(lnL, lnL_ref)) + lnZ[stencil].append(ln_evidence(lnL)) + if seed == seeds[0]: + ref2 = time_marginalize( + eval_reference(setup, packs, pts, tvals, M_check, chunk, + stencil=REF_STENCIL), setup.deltaT) + ref_move = float(np.max(np.abs(ref2 - lnL_ref))) + packs_s = setup.alternate_window_packs(t_window_short) + ref_s = time_marginalize( + eval_reference(setup, packs_s, pts, tvals, M_ref, chunk, + stencil=REF_STENCIL), setup.deltaT) + wrap_move = float(np.max(np.abs(ref_s - lnL_ref))) + del packs_s + # PER-STENCIL REFERENCE FLOOR. The reference is built by zero-pad-FFT + # interpolating a CUT of rho(t), which treats the cut as periodic; the + # resulting wrap (Gibbs) error is a property of the REFERENCE and cannot be + # seen by the M -> 2M check, which shares it. Re-scoring the SAME stencil + # lnL values against a reference built from a shorter stored window changes + # only that artifact, so the shift is a direct per-stencil error floor. This + # costs nothing: the stencil lnL values are already in hand. Any entry in + # column B at or below its floor is an UPPER BOUND, not a measurement. + for stencil in ('nearest', 'cubic', 'sinc'): + d_long = lnL_by_stencil[stencil] - lnL_ref + d_short = lnL_by_stencil[stencil] - ref_s + floor[stencil] = abs(float(np.max(np.abs(d_short))) + - float(np.max(np.abs(d_long)))) + + # The shipped selector API is in flux (automatic selection was removed after the + # TaylorT4 ladder). Query it if it is still there; otherwise report no prediction + # rather than inventing one. + preds = {} + for on_gpu in on_gpu_variants: + chooser = getattr(tic, 'choose_time_interp_stencil', None) + if chooser is None: + preds[on_gpu] = (None, None, None) + else: + preds[on_gpu] = chooser(fSample, fmax, on_gpu=on_gpu, m_total_msun=m_total) + # PSD-based bandwidth estimator (RIFT.misc.psd_bandwidth), evaluated on the SAME + # analytic ZDHP PSD and the same [fmin, fmax] this measurement uses, at each of the + # quantiles its calibration table quotes. This is the estimator that is meant to + # replace f_ISCO, and its calibration currently rests on TaylorT4 bandwidths. + psd_est = {} + try: + import RIFT.misc.psd_bandwidth as pbw + _f = np.arange(1, int(fSample / 2)) * 1.0 + _p = np.array([lalsim.SimNoisePSDaLIGOZeroDetHighPower(x) for x in _f]) + for q in (0.95, 0.99, 0.9999): + psd_est[q] = pbw.bandwidth_from_psd(_f, _p, fmin, fmax, + m_total_msun=m_total, quantile=q) + except Exception as exc: + psd_est = {'error': str(exc)[:80]} + rows.append(dict(M=m_total, m1=m1, m2=m2, T_seg=T_seg, tau=tau, d_inj=d_inj, rho=rho, + spec=spec, acc=acc, floor=floor, lnZ=lnZ, preds=preds, + f_isco=F_ISCO_1MSUN_HZ / m_total, + f_q_rule=(tic.q_bandwidth_hz(fmax, m_total) + if hasattr(tic, 'q_bandwidth_hz') + else min(fmax, F_ISCO_1MSUN_HZ / m_total)), + psd_est=psd_est, + ref_move=ref_move, wrap_move=wrap_move, upsample_rt=rt)) + print(" M=%6.1f (%g+%g) T_seg=%gs tau=%.3gs wf=%.3gs edge=%.1e d=%.4g Mpc SNR_lik=%.4g (direct %.4g, ratio %.3f) " + "f_Q(99.99%%)=%.1f Hz f_RD~%.0f Hz (%.0fs)" + % (m_total, m1, m2, T_seg, tau, setup.wf_duration, setup.edge_fraction, + d_inj, rho, rho_dir, rho / rho_dir, + spec['f0.9999'], 16000. / m_total, time.time() - t0)) + sys.stdout.flush() + del rho_fine, packs, setup + + # ---------------- report ---------------- + print("") + print(" A. MEASURED Q BANDWIDTH vs THE f_ISCO BOUND USED BY THE RULE") + print(" %6s %10s %10s %10s %12s %12s %11s %11s" % + ("M/Msun", "f 99%", "f 99.9%", "f 99.99%", "f_ISCO=4397/M", "f_Q(rule)", + "meas/f_ISCO", "fNyq/f_Q")) + for r in rows: + print(" %6.1f %10.1f %10.1f %10.1f %12.1f %12.1f %11.3g %11.4g" % + (r['M'], r['spec']['f0.99'], r['spec']['f0.999'], r['spec']['f0.9999'], + r['f_isco'], r['f_q_rule'], r['spec']['f0.9999'] / r['f_isco'], + (fSample / 2.) / r['f_q_rule'])) + + print("") + print(" B. PAIRED STENCIL ERROR vs THE EXACT REFERENCE (nats; %d points x %d seeds; " + "all masses at SNR_lik=%g)" % (K, len(seeds), target_snr)) + print(" %6s | %19s | %19s | %19s | %9s" % + ("M/Msun", "nearest max / RMS", "cubic max / RMS", "sinc max / RMS", + "cubic/sinc")) + for r in rows: + cells = [] + mx = {} + rms = {} + for st in ('nearest', 'cubic', 'sinc'): + mx[st] = max(x['maxabs'] for x in r['acc'][st]) + rms[st] = float(np.mean([x['rms'] for x in r['acc'][st]])) + flag = '<' if mx[st] <= 3.0 * r['floor'][st] else ' ' + cells.append("%s%8.4g /%9.4g" % (flag, mx[st], rms[st])) + print(" %6.1f | %s | %s | %s | %9.4g" % + (r['M'], cells[0], cells[1], cells[2], mx['cubic'] / mx['sinc'])) + print(" '<' marks an entry within 3x of its own reference floor (column E): an UPPER " + "BOUND, not a measurement.") + + print("") + print(" C. WHO WINS, WHAT THE RULE PREDICTS, AND WHETHER IT AGREES") + print(" %6s %8s %10s %12s %10s %10s %8s | %10s %8s" % + ("M/Msun", "fNyq/f_Q", "winner", "margin(max)", "margin(RMS)", "rule CPU", + "agree", "rule GPU", "agree")) + disagreements = [] + for r in rows: + mx = dict((st, max(x['maxabs'] for x in r['acc'][st])) + for st in ('nearest', 'cubic', 'sinc')) + rms = dict((st, float(np.mean([x['rms'] for x in r['acc'][st]]))) + for st in ('nearest', 'cubic', 'sinc')) + winner = 'cubic' if mx['cubic'] < mx['sinc'] else 'sinc' + loser = 'sinc' if winner == 'cubic' else 'cubic' + margin_mx = mx[loser] / mx[winner] + margin_rms = rms[loser] / rms[winner] + line = [] + for on_gpu in on_gpu_variants: + pred, ov, thr = r['preds'][on_gpu] + if pred is None: + line.append(('n/a', True)) + continue + ok = (pred == winner) + line.append((pred, ok)) + if not ok: + disagreements.append((r['M'], 'GPU' if on_gpu else 'CPU', pred, winner, + margin_mx, margin_rms)) + print(" %6.1f %8.4g %10s %12.4g %10.4g %10s %8s | %10s %8s" % + (r['M'], (fSample / 2.) / r['f_q_rule'], winner, margin_mx, margin_rms, + line[0][0], "yes" if line[0][1] else "NO", line[1][0], + "yes" if line[1][1] else "NO")) + + print("") + print(" C2. PSD-BASED BANDWIDTH ESTIMATOR (RIFT.misc.psd_bandwidth) vs THIS MEASUREMENT") + print(" estimate/measured at each quantile, and fNyq/measured with the winner") + print(" %6s %10s | %9s %9s %9s | %9s %9s %9s | %10s %8s" % + ("M/Msun", "meas f_Q", "est q.95", "est q.99", "est q1e-4", + "rat .95", "rat .99", "rat 1e-4", "fNyq/meas", "winner")) + for r in rows: + meas = r['spec']['f0.9999'] + e = r.get('psd_est', {}) + vals = [e.get(q) for q in (0.95, 0.99, 0.9999)] + mx = dict((st, max(x['maxabs'] for x in r['acc'][st])) for st in ('cubic', 'sinc')) + win = 'cubic' if mx['cubic'] < mx['sinc'] else 'sinc' + def _f(v): + return ("%9.1f" % v) if isinstance(v, float) else " n/a" + def _r(v): + return ("%9.3g" % (v / meas)) if isinstance(v, float) else " n/a" + print(" %6.1f %10.1f | %s %s %s | %s %s %s | %10.4g %8s" % + (r['M'], meas, _f(vals[0]), _f(vals[1]), _f(vals[2]), + _r(vals[0]), _r(vals[1]), _r(vals[2]), (fSample / 2.) / meas, win)) + + print("") + print(" D. EVIDENCE: mean +- seed-spread of lnZ - lnZ_ref (nats)") + print(" %6s %22s %22s %22s" % ("M/Msun", "nearest", "cubic", "sinc")) + for r in rows: + cells = [] + for st in ('nearest', 'cubic', 'sinc'): + d = np.array(r['lnZ'][st]) - np.array(r['lnZ']['ref']) + cells.append("%11.4g +-%9.3g" % (float(np.mean(d)), + float(np.max(d) - np.min(d)))) + print(" %6.1f %22s %22s %22s" % (r['M'], cells[0], cells[1], cells[2])) + + print("") + print(" E. REFERENCE VALIDITY PER MASS (must stay far below column B)") + print(" %6s %14s %12s | %11s %11s %11s" % + ("M/Msun", "M32->64", "wrap(ref)", "floor:nearest", "floor:cubic", "floor:sinc")) + for r in rows: + print(" %6.1f %14.4g %12.4g | %11.4g %11.4g %11.4g" % + (r['M'], r['ref_move'], r['wrap_move'], r['floor']['nearest'], + r['floor']['cubic'], r['floor']['sinc'])) + + print("") + sinc_ov = [(fSample / 2.) / r['spec']['f0.9999'] for r in rows + if max(x['maxabs'] for x in r['acc']['sinc']) + < max(x['maxabs'] for x in r['acc']['cubic'])] + cub_ov = [(fSample / 2.) / r['spec']['f0.9999'] for r in rows + if max(x['maxabs'] for x in r['acc']['sinc']) + >= max(x['maxabs'] for x in r['acc']['cubic'])] + print(" THRESHOLD BRACKET on fNyq / measured-99.99%%-bandwidth, from THIS ladder:") + print(" sinc wins up to %s" % ("%.3f" % max(sinc_ov) if sinc_ov else "(no sinc wins)")) + print(" cubic wins from %s" % ("%.3f" % min(cub_ov) if cub_ov else "(no cubic wins)")) + print("") + if disagreements: + print(" ** RULE MIS-SELECTS at %d (mass, backend) points:" % len(disagreements)) + for (M, be, pred, win, mmx, mrms) in disagreements: + print(" M=%g Msun %s: rule says %s, measurement says %s " + "(penalty %.3gx on max, %.3gx on RMS)" % (M, be, pred, win, mmx, mrms)) + else: + print(" ** RULE AGREES WITH THE MEASUREMENT AT EVERY MASS AND BOTH BACKENDS.") + print(" total %.0f s" % (time.time() - t0)) + sys.stdout.flush() + return rows + + +# --------------------------------------------------------------------------- +# driver +# --------------------------------------------------------------------------- +def ref_convergence_ladder(setup, packs, pts, tvals, lnL_ref, Ms, chunk, K_sub): + """Walk the LITERAL prescription ('nearest' on an Mx fine grid) up in M and show it + converging onto the primary reference. Done on a subset of points to keep it cheap.""" + sub = {k: v[:K_sub] for k, v in pts.items()} + out = [] + for M in Ms: + lnL_t = eval_reference(setup, packs, sub, tvals, M, chunk, stencil='nearest') + d = time_marginalize(lnL_t, setup.deltaT) - lnL_ref[:K_sub] + out.append((M, float(np.max(np.abs(d))), float(np.sqrt(np.mean(d ** 2))))) + return out + + +def run_config(label, fSample, fmax, m1, m2, fmin, dist_mpc, K, seeds, t_half, M_ref, + M_check, t_window, t_window_short, chunk, ladder_Ms=(32, 64, 128, 256), + approx=None): + t0 = time.time() + print("=" * 100) + print("CONFIG %s : approximant=%s fSample=%g fmax=%g fNyq/fmax=%.3g source m1=%g m2=%g fmin=%g " + "dist=%g Mpc" % (label, approx or DEFAULT_APPROX, fSample, fmax, + (fSample / 2.) / fmax, m1, m2, fmin, dist_mpc)) + sys.stdout.flush() + + try: + setup = build_setup_or_skip(label, approx, fSample, fmax, m1, m2, fmin, t_window, + dist_mpc=dist_mpc) + except ApproximantUnavailable as exc: + # Almost always: an IMR model asked for below the mass where its ringdown fits under + # Nyquist. Say what to do instead of emitting a raw lal domain error, and skip this + # configuration rather than aborting the remaining ones. + print("\n CONFIG %s SKIPPED -- %s" % (label, exc)) + return None + packs = setup.packs + n_time = packs['rho']['H1'].shape[1] + print(" precompute: %.1fs n_time(stored Q window)=%d (=%.4g s) SNR guess=%.4g" + % (time.time() - t0, n_time, n_time * setup.deltaT, packs['snr'])) + + spec = q_spectrum_report(setup, packs) + print(" Q(t) spectrum (Tukey-windowed, H1, all modes): f(99%%)=%.1f Hz f(99.9%%)=%.1f Hz " + " f(99.99%%)=%.1f Hz frac power >0.25fNyq=%.2e >0.5fNyq=%.2e >0.75fNyq=%.2e" + % (spec['f0.99'], spec['f0.999'], spec['f0.9999'], + spec['pow>0.25fNyq'], spec['pow>0.50fNyq'], spec['pow>0.75fNyq'])) + + npts_half = int(round(t_half * setup.fSample)) + npts = 2 * npts_half + 1 + tvals = (np.arange(npts) - npts_half) * setup.deltaT + print(" eval time grid: npts=%d, +-%.4g s about tref" % (npts, npts_half * setup.deltaT)) + + # ---- window bounds: make sure no stencil (or the fine reference) ever runs off the + # stored Q window, which would silently zero-fill. + check_bounds(setup, packs, seeds, K, tvals, npts, M_check, dist_mpc) + + results = {} + lnZ = {} + rho_fine, (rt, nyq) = build_fine_rho(packs, M_ref) + print(" upsample check (M=%d): max|y[::M]-x|/max|x| = %.2e ; |X[Nyq]|/max|X| = %.2e" + % (M_ref, rt, nyq)) + for seed in seeds: + pts = draw_points(K, seed, dist_mpc) + + lnL_t_ref = eval_reference(setup, packs, pts, tvals, M_ref, chunk, + rho_fine=rho_fine, stencil=REF_STENCIL) + lnL_ref = time_marginalize(lnL_t_ref, setup.deltaT) + lnZ.setdefault('ref', []).append(ln_evidence(lnL_ref)) + + for stencil in ('nearest', 'cubic', 'sinc'): + lnL_t = eval_lnL_t(setup, packs, pts, tvals, setup.deltaT, stencil, + packs['rho'], chunk) + lnL = time_marginalize(lnL_t, setup.deltaT) + st = err_stats(lnL, lnL_ref) + st['maxabs_lnLt'] = float(np.max(np.abs(lnL_t - lnL_t_ref))) + results.setdefault(stencil, []).append(st) + lnZ.setdefault(stencil, []).append(ln_evidence(lnL)) + print(" seed %d done (%.0fs elapsed)" % (seed, time.time() - t0)) + sys.stdout.flush() + + if seed == seeds[0]: + # ---- reference validity (a): does the reference move when M -> M_check? + lnL_t_ref2 = eval_reference(setup, packs, pts, tvals, M_check, chunk, + stencil=REF_STENCIL) + lnL_ref2 = time_marginalize(lnL_t_ref2, setup.deltaT) + ref_move = float(np.max(np.abs(lnL_ref2 - lnL_ref))) + ref_move_lnZ = abs(ln_evidence(lnL_ref2) - ln_evidence(lnL_ref)) + # ---- reference validity (b): wrap artifact, from a HALF-length stored Q window + packs_short = setup.alternate_window_packs(t_window_short) + lnL_t_ref_s = eval_reference(setup, packs_short, pts, tvals, M_ref, chunk, + stencil=REF_STENCIL) + lnL_ref_s = time_marginalize(lnL_t_ref_s, setup.deltaT) + wrap_move = float(np.max(np.abs(lnL_ref_s - lnL_ref))) + del packs_short + ladder = ref_convergence_ladder(setup, packs, pts, tvals, lnL_ref, + ladder_Ms, chunk, min(K, 200)) + + print("") + print(" RESULTS (differences in nats; lnL is the time-marginalized log likelihood)") + print(" ALL %d points per seed. NOTE max(lnL) vs min(lnL): the isotropic draw contains " + "points with" % K) + print(" huge NEGATIVE lnL (small distance, mismatched sky); they carry no posterior " + "weight but do") + print(" carry a large |kappa|, so the all-points max| | is a pessimistic bound, not an " + "inference-relevant one.") + print(" %-8s %12s %12s %12s %12s %13s %13s" % + ("stencil", "max|dlnL|", "RMS dlnL", "mean dlnL", "max|dlnL_t|", "max(lnL)", + "min(lnL)")) + for stencil in ('nearest', 'cubic', 'sinc'): + r = results[stencil] + print(" %-8s %12.4g %12.4g %12.4g %12.4g %13.6g %13.6g" % + (stencil, max(x['maxabs'] for x in r), + float(np.mean([x['rms'] for x in r])), + float(np.mean([x['mean'] for x in r])), + max(x['maxabs_lnLt'] for x in r), + max(x['lnL_max'] for x in r), min(x['lnL_min'] for x in r))) + print("") + print(" RESTRICTED to the inference-relevant band lnL_ref > max(lnL_ref) - %g " + "(%s points/seed)" % (RELEVANT_BAND, + "/".join(str(x['n_band']) for x in results['cubic']))) + print(" %-8s %12s %12s %12s" % ("stencil", "max|dlnL|", "RMS dlnL", "mean dlnL")) + for stencil in ('nearest', 'cubic', 'sinc'): + r = results[stencil] + print(" %-8s %12.4g %12.4g %12.4g" % + (stencil, max(x['maxabs_band'] for x in r), + float(np.mean([x['rms_band'] for x in r])), + float(np.mean([x['mean_band'] for x in r])))) + + print("") + print(" REFERENCE VALIDITY (primary reference = '%s' lookup on an M=%dx FFT-upsampled Q)" + % (REF_STENCIL, M_ref)) + smallest = min(max(x['maxabs'] for x in results[s]) for s in ('nearest', 'cubic', 'sinc')) + print(" reference moves by max %.4g nats going M=%d -> M=%d " + "(smallest stencil-vs-reference max|dlnL| = %.4g -> ratio %.3g)" + % (ref_move, M_ref, M_check, smallest, ref_move / smallest if smallest else np.nan)) + print(" reference lnZ moves by %.4g nats going M=%d -> M=%d" % (ref_move_lnZ, M_ref, M_check)) + print(" reference moves by max %.4g nats when the stored Q window is halved " + "(%.4g s -> %.4g s): this bounds the periodic-wrap (Gibbs) artifact" + % (wrap_move, 2 * t_window, 2 * t_window_short)) + print(" literal prescription ('nearest' on the fine grid) vs this reference, on %d points:" + % min(K, 200)) + for (M, mx, rms) in ladder: + print(" M=%4d : max|dlnL| = %10.4g RMS = %10.4g" % (M, mx, rms)) + + print("") + print(" EVIDENCE lnZ = log(mean(exp(lnL-max)))+max over the SAME %d fixed points, " + "%d seeds" % (K, len(seeds))) + print(" reference lnZ per seed: %s" % np.array2string(np.array(lnZ['ref']), precision=6)) + print(" %-8s %14s %14s %14s" % ("stencil", "mean lnZ", "mean d lnZ", "spread(d lnZ)")) + for stencil in ('nearest', 'cubic', 'sinc'): + d = np.array(lnZ[stencil]) - np.array(lnZ['ref']) + print(" %-8s %14.6f %14.4g %14.4g" % + (stencil, float(np.mean(lnZ[stencil])), float(np.mean(d)), + float(np.max(d) - np.min(d)))) + print(" total %.0f s" % (time.time() - t0)) + sys.stdout.flush() + return results, lnZ + + +def check_bounds(setup, packs, seeds, K, tvals, npts, M_check, dist_mpc): + """Assert every stencil window (incl. sinc's 8 taps/side and the finest reference grid) + lies strictly inside the stored Q series -- otherwise the builders zero-fill silently.""" + a = fl.SINC_HALFWIDTH_DEFAULT + gmst = float(lal.GreenwichMeanSiderealTime(EVENT_TIME)) + worst_lo, worst_hi = np.inf, np.inf + for seed in seeds: + pts = draw_points(K, seed, dist_mpc) + for det in packs['rho']: + loc = np.asarray(lalsim.DetectorPrefixToLALDetector(det).location) + dt = fl.TimeDelayFromEarthCenter(loc, pts['phi'], pts['theta'], gmst, xpy=np) + t_det = float(EVENT_TIME - float(packs['epoch'][det])) + dt + n_time = packs['rho'][det].shape[1] + for M in (1, M_check): + s0 = (t_det + tvals[0]) / (setup.deltaT / M) + i0 = np.floor(s0) + worst_lo = min(worst_lo, float(np.min(i0)) - a + 1) + worst_hi = min(worst_hi, n_time * M - float(np.max(i0)) - (npts - 1) * M - a) + print(" window bounds: min margin below start = %.0f samples, above end = %.0f samples " + "(both must be > 0)" % (worst_lo, worst_hi)) + assert worst_lo > 0 and worst_hi > 0, "evaluation window runs off the stored Q series" + + +SKIPPED_CONFIGS = [] + + +def _exit_if_nothing_measured(results, what): + """Exit non-zero when every configuration skipped. + + An empty run and a completed run must not share an exit status; a reproduction wrapper + checking $? cannot tell them apart otherwise.""" + if not any(r is not None for r in results): + print("\n*** NOTHING WAS MEASURED: every %s was skipped. Exiting non-zero so this is " + "not mistaken for a completed run. ***" % what) + sys.exit(2) + return results + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--K", type=int, default=2000) + ap.add_argument("--seeds", type=int, nargs='+', default=[101, 202, 303]) + ap.add_argument("--t-half", type=float, default=0.01, + help="half width of the lnL(t) evaluation window, seconds") + ap.add_argument("--M-ref", type=int, default=32) + ap.add_argument("--M-check", type=int, default=64) + ap.add_argument("--chunk", type=int, default=64) + ap.add_argument("--ladder-Ms", type=int, nargs='+', default=[32, 64, 128, 256]) + ap.add_argument("--dist-scale", type=float, default=1.0, + help="multiply every injected distance by this (lnL and dlnL both scale " + "as SNR^2, so this is the knob that rescales the whole table)") + ap.add_argument("--only", type=str, default=None, help="run only this config label") + ap.add_argument("--mode", choices=('grid', 'snr-ladder', 'mass-ladder'), default='grid') + ap.add_argument("--masses", type=float, nargs='+', + default=[2.6, 5., 10., 20., 35., 55., 80., 120.]) + ap.add_argument("--mass-ladder-snr", type=float, default=100.) + ap.add_argument("--mass-ladder-fmin", type=float, default=30.) + ap.add_argument("--mass-ladder-srate", type=float, default=4096.) + ap.add_argument("--approx", type=str, default=None, + help="lalsimulation approximant name. DEFAULT %s -- the IMR model behind the " + "guidance in RIFT/likelihood/DESIGN_q_window_stencil.md. Inspiral-only " + "models (TaylorT4) terminate at ISCO, understate the Q bandwidth by " + "2-3.7x and NAMED THE WRONG STENCIL at M = 9, 10 and 20; passing one " + "prints a warning. Note %s cannot be generated below M ~ 8 at srate " + "4096 -- use srate 16384 there, or accept the inspiral-only caveat." + % (DEFAULT_APPROX, DEFAULT_APPROX)) + ap.add_argument("--t-window", type=float, default=0.4, + help="half width of the STORED Q window (the reference is built from it)") + ap.add_argument("--t-window-short", type=float, default=0.2, + help="shorter stored Q window used to bound the periodic-wrap artifact") + ap.add_argument("--snr-targets", type=float, nargs='+', + default=[10., 30., 100., 300., 1000.]) + args = ap.parse_args() + + # (label, fSample, fmax, m1, m2, fmin, t_window, t_window_short) + # + # Two SOURCES are run through each sample-rate/fmax configuration on purpose. fNyq/fmax + # is the number the stencil chooser uses, but the quantity that actually sets the stencil's + # difficulty is the bandwidth of Q(t) = , which is limited by the TEMPLATE as + # well as by fMax. The 30+25 Msun system used by the existing slowrot tests has its ISCO + # near 80 Hz, so at fmax=1700 its Q is nowhere near Nyquist no matter what fNyq/fmax says. + # The 1.3+1.3 Msun system has ISCO near 1690 Hz, so it genuinely fills the band. Both are + # reported; neither is chosen after seeing the answer. + configs = [ + ("A-heavy", 4096., 1700., 30., 25., 30., 200., 0.4, 0.2), + ("B-heavy", 16384., 512., 30., 25., 30., 200., 0.4, 0.2), + ("A-light", 4096., 1700., 1.3, 1.3, 150., 12., 0.4, 0.2), + ("B-light", 16384., 512., 1.3, 1.3, 150., 12., 0.4, 0.2), + ] + if args.mode == 'mass-ladder': + _ladder_rows = run_mass_ladder( + args.mass_ladder_srate, 1700., args.mass_ladder_fmin, args.masses, + args.mass_ladder_snr, args.K, args.seeds, args.t_half, args.M_ref, + args.M_check, args.t_window, args.t_window_short, args.chunk, + approx=args.approx) + _exit_if_nothing_measured(_ladder_rows or [], "mass in the ladder") + return + + # Validate the approximant NAME once, before any mode runs. Per-configuration handlers + # must never be given the chance to swallow a typo. + validate_approximant(args.approx) + + _results = [] + + if args.mode == 'snr-ladder': + # Near-Nyquist configuration A only (fNyq/fmax = 1.2), both sources. + for (label, fS, fmax, m1, m2, fmin, dmpc, tw, tws) in configs: + if not label.startswith('A'): + continue + if args.only and args.only not in label: + continue + _results.append( + run_snr_ladder(label, fS, fmax, m1, m2, fmin, dmpc, args.snr_targets, args.K, + args.seeds, args.t_half, args.M_ref, args.M_check, tw, args.chunk, + approx=args.approx)) + _exit_if_nothing_measured(_results, "SNR-ladder configuration") + return + + for (label, fS, fmax, m1, m2, fmin, dmpc, tw, tws) in configs: + if args.only and args.only not in label: + continue + _results.append( + run_config(label, fS, fmax, m1, m2, fmin, dmpc * args.dist_scale, args.K, args.seeds, + args.t_half, args.M_ref, args.M_check, tw, tws, args.chunk, + ladder_Ms=args.ladder_Ms, approx=args.approx)) + _exit_if_nothing_measured(_results, "grid configuration") + + +if __name__ == "__main__": + try: + main() + except UnknownApproximant as exc: + # A user typo, reported cleanly and fatally. Never skipped: skipping it made every + # configuration "skip" and the process exit 0 having measured nothing. + print("\n*** %s ***" % exc) + sys.exit(2) diff --git a/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_calmarg_stencil_gating.py b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_calmarg_stencil_gating.py new file mode 100644 index 000000000..ca2ff3e49 --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_calmarg_stencil_gating.py @@ -0,0 +1,279 @@ +""" +test_calmarg_stencil_gating : the fused calibration-marginalization kernel is implemented +ONLY for time_interp='nearest', and everything else must fall back to the 'loop' path. + +Three things are checked. + +(a) LIBRARY-LEVEL REFUSAL (executed). + factored_likelihood.DiscreteFactoredLogLikelihoodViaArrayVectorNoLoop(..., + cal_method='fused', time_interp='cubic'|'sinc') must raise NotImplementedError, and + cal_method='fused' with time_interp='nearest' must NOT raise -- and must actually run + the fused reduction to a finite lnL, not merely survive the guard. ('sinc' is the new + stencil; the guard predates it, so the point of the test is that the guard is written + against 'nearest' rather than against a hard-coded list of the stencils that existed + when it was written.) + +(b) DRIVER-LEVEL GATING (executed, on the driver's own source expressions). + bin/integrate_likelihood_extrinsic_batchmode chooses, at three NoLoop call sites, + cal_method = ('fused' if and opts._noloop_time_interp == 'nearest' else 'loop') + and, at two of them, + cal_distmarg = (cal_distmarg_dict if opts._noloop_time_interp == 'nearest' else None). + Rather than restate those expressions (which would test nothing), this test PARSES the + driver with `ast`, extracts the actual keyword-argument expressions from the actual + call sites, and evaluates them over the full truth table of + (gate condition) x (stencil in TIME_INTERP_CHOICES). So the assertion is against the + code as written, and a later edit that drops the `== 'nearest'` clause fails here. + The driver is not importable as a module (it is a script that parses argv and builds a + full ILE state), so its surrounding control flow is NOT executed -- only the gating + expressions themselves are. + +(c) THE STENCIL REALLY TAKES EFFECT THROUGH THE CALMARG PATH (executed). + n_cal>1 with time_interp='sinc' must give finite lnL, must NOT be bit-identical to the + 'cubic' calmarg result, and the two interpolating stencils must agree with each other + far better than either agrees with 'nearest' -- which is what their error orders + predict (nearest is O(h) in the sub-sample offset; cubic is O(h^4) and sinc is + window-limited, so both sit close to the exact band-limited value and therefore close + to each other). A conservative factor of 5 is required; the observed factor is ~40. + +Runs on CPU (numpy), so it needs no GPU; the GPU legs of (c) are added when cupy is +available. The heavy precompute is shared with test_noloop_gpu_stencils. + + OMP_NUM_THREADS=1 PYTHONPATH=/MonteCarloMarginalizeCode/Code \ + ~/RIFT_develUWM/bin/python RIFT/likelihood/test_calmarg_stencil_gating.py +""" +from __future__ import print_function, division + +import ast +import os + +import numpy as np + +import RIFT.likelihood.factored_likelihood as fl +from RIFT.likelihood.test_noloop_gpu_stencils import ( + HAVE_GPU, N_CAL, Lmax, T_HALFWIDTH, deltaT, data_dict, + _setup, _P_vec, _P_vec_to_gpu, _banks_to_gpu, +) + +if HAVE_GPU: + import cupy + + +def _tvals(): + return np.arange(int(2 * T_HALFWIDTH / deltaT)) * deltaT - T_HALFWIDTH + + +# --------------------------------------------------------------------------- +# (a) library-level refusal / acceptance +# --------------------------------------------------------------------------- +def test_a_fused_is_nearest_only(): + banks = _setup()['cal'] + lookupNKDict, rholmArrayDict, ctUArrayDict, ctVArrayDict, epochDict = banks + Pv = _P_vec() + tvals = _tvals() + + def _call(interp): + return fl.DiscreteFactoredLogLikelihoodViaArrayVectorNoLoop( + tvals, Pv, lookupNKDict, rholmArrayDict, ctUArrayDict, ctVArrayDict, epochDict, + Lmax=Lmax, xpy=np, n_cal=N_CAL, cal_method='fused', time_interp=interp) + + for interp in ('cubic', 'sinc'): + raised = None + try: + _call(interp) + except NotImplementedError as e: + raised = e + except Exception as e: # noqa: BLE001 - want the type + raise AssertionError( + "cal_method='fused', time_interp=%r raised %s (%s), expected NotImplementedError" + % (interp, type(e).__name__, e)) + assert raised is not None, \ + "cal_method='fused', time_interp=%r did NOT raise NotImplementedError" % interp + print("(a) fused + %-7s -> NotImplementedError: %s" % (interp, raised)) + + lnL_fused = np.asarray(_call('nearest')) + assert np.all(np.isfinite(lnL_fused)), \ + "cal_method='fused', time_interp='nearest' produced non-finite lnL" + print("(a) fused + nearest -> ran, lnL finite, max|lnL| = %.6g" % np.max(np.abs(lnL_fused))) + + # The fused kernel and the loop reduction compute the same quantity; they must agree. + lnL_loop = np.asarray(fl.DiscreteFactoredLogLikelihoodViaArrayVectorNoLoop( + tvals, Pv, lookupNKDict, rholmArrayDict, ctUArrayDict, ctVArrayDict, epochDict, + Lmax=Lmax, xpy=np, n_cal=N_CAL, cal_method='loop', time_interp='nearest')) + d = float(np.max(np.abs(lnL_fused - lnL_loop))) + tol = 1e-8 + 1e-11 * float(np.max(np.abs(lnL_loop))) + print("(a) fused vs loop, nearest, n_cal=%d : max|diff| = %.3e (tol %.3e)" % (N_CAL, d, tol)) + assert d < tol, "fused and loop calmarg disagree at nearest: %g >= %g" % (d, tol) + + +# --------------------------------------------------------------------------- +# (b) driver gating expressions, extracted and evaluated +# --------------------------------------------------------------------------- +_DRIVER = os.path.join( + os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), + 'bin', 'integrate_likelihood_extrinsic_batchmode') + +_NOLOOP_NAME = 'DiscreteFactoredLogLikelihoodViaArrayVectorNoLoop' + + +def _default_cal_method(): + """The library's own default for cal_method, read off the signature (py2/py3 safe).""" + try: + import inspect + return inspect.signature( + fl.DiscreteFactoredLogLikelihoodViaArrayVectorNoLoop).parameters['cal_method'].default + except (ImportError, AttributeError): # pragma: no cover - py2 fallback + import inspect + spec = inspect.getargspec(fl.DiscreteFactoredLogLikelihoodViaArrayVectorNoLoop) + return spec.defaults[spec.args.index('cal_method') - (len(spec.args) - len(spec.defaults))] + + +_DEFAULT_CAL_METHOD = _default_cal_method() + + +class _Opts(object): + def __init__(self, interp): + self._noloop_time_interp = interp + + +def _noloop_call_sites(): + """(lineno, {kw: ast expression}) for every NoLoop call in the driver.""" + with open(_DRIVER, 'r') as f: + tree = ast.parse(f.read(), filename=_DRIVER) + sites = [] + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + fn = node.func + name = fn.attr if isinstance(fn, ast.Attribute) else getattr(fn, 'id', None) + if name != _NOLOOP_NAME: + continue + kws = dict((kw.arg, kw.value) for kw in node.keywords if kw.arg) + sites.append((node.lineno, kws)) + return sorted(sites) + + +def _eval_expr(expr, ns): + mod = ast.Expression(body=expr) + ast.fix_missing_locations(mod) + return eval(compile(mod, _DRIVER, 'eval'), dict(ns)) + + +def test_b_driver_gating_expressions(): + sites = _noloop_call_sites() + assert sites, "found no %s call sites in %s" % (_NOLOOP_NAME, _DRIVER) + print("(b) driver: %s call sites at lines %s" + % (_NOLOOP_NAME, [ln for ln, _ in sites])) + + sentinel = {'table': 'CAL_DISTMARG_TABLE'} + conditional = [] + for lineno, kws in sites: + cm = kws.get('cal_method') + if cm is None: + # Site relies on the library default; that default must be the safe one. + assert _DEFAULT_CAL_METHOD == 'loop', \ + "line %d omits cal_method and the library default is %r, not 'loop'" \ + % (lineno, _DEFAULT_CAL_METHOD) + print("(b) line %-5d cal_method omitted -> library default %r -- always safe" + % (lineno, _DEFAULT_CAL_METHOD)) + continue + if isinstance(cm, ast.Str) or (isinstance(cm, ast.Constant) and isinstance(cm.value, str)): + lit = cm.s if isinstance(cm, ast.Str) else cm.value + assert lit == 'loop', \ + "line %d passes a LITERAL cal_method=%r; only 'loop' may be hard-wired, " \ + "'fused' must be gated on the stencil" % (lineno, lit) + print("(b) line %-5d cal_method literal %r -- always safe" % (lineno, lit)) + continue + conditional.append((lineno, kws, cm)) + + assert conditional, \ + "no conditional cal_method expression found -- the fused/loop gate has disappeared" + print("(b) conditional cal_method gates at lines %s" % [ln for ln, _, _ in conditional]) + + for lineno, kws, cm_expr in conditional: + cd_expr = kws.get('cal_distmarg') + for gate in (True, False): + for interp in fl.TIME_INTERP_CHOICES: + ns = {'use_fused_calmarg': gate, + 'cal_distmarg_dict': (sentinel if gate else None), + 'opts': _Opts(interp)} + got = _eval_expr(cm_expr, ns) + want = 'fused' if (gate and interp == 'nearest') else 'loop' + assert got == want, \ + "driver line %d: cal_method evaluated to %r for gate=%s, stencil=%r; " \ + "expected %r" % (lineno, got, gate, interp, want) + if cd_expr is not None: + got_cd = _eval_expr(cd_expr, ns) + want_cd = (sentinel if gate else None) if interp == 'nearest' else None + assert got_cd == want_cd, \ + "driver line %d: cal_distmarg evaluated to %r for gate=%s, " \ + "stencil=%r; expected %r" % (lineno, got_cd, gate, interp, want_cd) + print("(b) line %-5d cal_method -> fused iff (gate and nearest); " + "cal_distmarg %s" + % (lineno, "gated on nearest" if cd_expr is not None else "(not passed)")) + + # There must be no route by which a non-nearest stencil reaches the fused kernel. + for lineno, kws, cm_expr in conditional: + for interp in ('cubic', 'sinc'): + for gate in (True, False): + ns = {'use_fused_calmarg': gate, + 'cal_distmarg_dict': (sentinel if gate else None), + 'opts': _Opts(interp)} + assert _eval_expr(cm_expr, ns) == 'loop', \ + "driver line %d routes stencil %r to the fused kernel" % (lineno, interp) + print("(b) no driver call site routes 'cubic' or 'sinc' to cal_method='fused'") + + +# --------------------------------------------------------------------------- +# (c) the stencil takes effect through the calmarg loop path +# --------------------------------------------------------------------------- +def _calmarg_lnL(banks, interp, xpy, Pv, tvals): + lookupNKDict, rholmArrayDict, ctUArrayDict, ctVArrayDict, epochDict = banks + if xpy is np: + out = fl.DiscreteFactoredLogLikelihoodViaArrayVectorNoLoop( + tvals, Pv, lookupNKDict, rholmArrayDict, ctUArrayDict, ctVArrayDict, epochDict, + Lmax=Lmax, xpy=np, n_cal=N_CAL, cal_method='loop', time_interp=interp) + return np.asarray(out) + rG, uG, vG = _banks_to_gpu(rholmArrayDict, ctUArrayDict, ctVArrayDict) + out = fl.DiscreteFactoredLogLikelihoodViaArrayVectorNoLoop( + cupy.asarray(tvals), _P_vec_to_gpu(Pv), lookupNKDict, rG, uG, vG, epochDict, + Lmax=Lmax, xpy=cupy, n_cal=N_CAL, cal_method='loop', time_interp=interp) + return cupy.asnumpy(out) + + +def test_c_sinc_takes_effect_through_calmarg(): + banks = _setup()['cal'] + Pv = _P_vec() + tvals = _tvals() + + backends = [('CPU', np)] + if HAVE_GPU: + backends.append(('GPU', cupy)) + + for tag, xpy in backends: + lnL = dict((i, _calmarg_lnL(banks, i, xpy, Pv, tvals)) for i in fl.TIME_INTERP_CHOICES) + for i in fl.TIME_INTERP_CHOICES: + assert np.all(np.isfinite(lnL[i])), \ + "(c) %s n_cal=%d loop, %s: non-finite lnL" % (tag, N_CAL, i) + sep_sc = float(np.max(np.abs(lnL['sinc'] - lnL['cubic']))) + sep_nc = float(np.max(np.abs(lnL['nearest'] - lnL['cubic']))) + sep_ns = float(np.max(np.abs(lnL['nearest'] - lnL['sinc']))) + print("(c) %s n_cal=%d loop: max|lnL| = %.6g ; " + "max|sinc-cubic| = %.3e ; max|nearest-cubic| = %.3e ; max|nearest-sinc| = %.3e" + % (tag, N_CAL, np.max(np.abs(lnL['sinc'])), sep_sc, sep_nc, sep_ns)) + assert sep_sc > 0.0, \ + "(c) %s: sinc and cubic are bit-identical through the calmarg path -- the " \ + "stencil is not taking effect" % tag + assert sep_nc > 0.0 and sep_ns > 0.0, \ + "(c) %s: nearest is bit-identical to an interpolating stencil" % tag + assert sep_sc < sep_nc / 5.0, \ + "(c) %s: sinc-vs-cubic (%.3e) is not much smaller than nearest-vs-cubic (%.3e); " \ + "the two sub-sample stencils should bracket the exact value far more tightly " \ + "than nearest does" % (tag, sep_sc, sep_nc) + + +if __name__ == "__main__": + test_a_fused_is_nearest_only() + test_b_driver_gating_expressions() + test_c_sinc_takes_effect_through_calmarg() + print("CALMARG STENCIL GATING CHECK DONE (GPU legs %s)" + % ("included" if HAVE_GPU else "skipped: no GPU")) diff --git a/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_interpolate_time_cli.py b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_interpolate_time_cli.py new file mode 100644 index 000000000..aa55a0535 --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_interpolate_time_cli.py @@ -0,0 +1,219 @@ +#!/usr/bin/env python3 +"""test_interpolate_time_cli -- the stencil flag AT THE COMMAND LINE, in real subprocesses. + +WHY SUBPROCESSES AND NOT UNIT CALLS. test_time_interp_choice exercises +resolve_interpolate_time_request directly, which proves the resolver is right but proves NOTHING +about how the two pipeline scripts are wired to it. Reverting either parser to `const=None`, or +deleting either script's call to the resolver, leaves every one of those unit tests green while +restoring the original defect: a bare `--internal-ile-interpolate-time` that silently does +nothing. The wiring is the thing that broke, so the wiring is what has to be tested. + +Same argument for the driver's honoured-path gate: the predicate can be correct in isolation and +still be unreachable, or reachable and mis-wired. + +These run the real scripts with the real interpreter. Each invocation costs a few seconds of +lal/numba import, which is why the case list is kept to the ones that DISTINGUISH behaviours +rather than every combination. No data files are needed -- all three scripts reach the relevant +validation before touching frames or PSDs. + + python3 test_interpolate_time_cli.py # or: pytest test_interpolate_time_cli.py +""" +from __future__ import print_function + +import os +import re +import subprocess +import sys + +from RIFT.likelihood.time_interp_choice import CROSSOVER_GUIDANCE + +_HERE = os.path.dirname(os.path.abspath(__file__)) +CODE_ROOT = os.path.normpath(os.path.join(_HERE, '..', '..')) +BIN = os.path.join(CODE_ROOT, 'bin') + +HELPER = os.path.join(BIN, 'helper_LDG_Events.py') +PSEUDO = os.path.join(BIN, 'util_RIFT_pseudo_pipe.py') +DRIVER = os.path.join(BIN, 'integrate_likelihood_extrinsic_batchmode') + +PIPELINE_ENTRY_POINTS = [('helper_LDG_Events.py', HELPER), + ('util_RIFT_pseudo_pipe.py', PSEUDO)] + +# EVERY surface that hands a user a stencil recommendation. The driver's --help was omitted from +# the original guidance test even though it interpolates the same constant, so that third copy +# could drift silently -- which is the exact failure this whole test file exists to prevent. +ADVICE_SURFACES = PIPELINE_ENTRY_POINTS + [ + ('integrate_likelihood_extrinsic_batchmode', DRIVER)] + +# Values the guidance constant has previously held and that must never reappear in user-facing +# text. A positive assertion ("the current constant is present") cannot catch a SUPERSEDED claim +# left standing beside it -- that is how a rendered error message came to read "the crossover is +# between 20 and 35 the crossover rises with fmin ...", splicing the retracted rule onto its +# replacement, while every test passed. Add each retired value here when the constant changes. +RETIRED_GUIDANCE_FRAGMENTS = ( + # NB: no trailing ' Msun' -- the splice that actually shipped read "...between 20 and 35 " + # immediately followed by the NEW constant, so a fragment ending in 'Msun' could not match it. + # Keep retired fragments as short as is still unambiguous. + 'the crossover is between 20 and 35', + 'unless the total mass is below', + 'prefer sinc at any mass', + # v3 of the constant, retired by the #109 review commit. Fragment chosen to be absent from + # the current value: 'measured over 9-55 Msun only' was v3's scope clause and v4 words it + # differently. COPY RETIRED TEXT FROM THE DIFF, never retype it -- fragment [0] was + # originally written with a trailing ' Msun' the real splice did not have, and could not fire. + '(measured over 9-55 Msun only)', +) + + +def _run(script, args, timeout=300): + """Run a script and return its combined output. Never raises on non-zero exit.""" + env = dict(os.environ) + env['PYTHONPATH'] = CODE_ROOT + os.pathsep + env.get('PYTHONPATH', '') + env['OMP_NUM_THREADS'] = '1' + env.setdefault('CUDA_VISIBLE_DEVICES', '') # keep these CPU-only and deterministic + proc = subprocess.Popen([sys.executable, script] + args, env=env, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + out, _ = proc.communicate() + if not isinstance(out, str): + out = out.decode('utf-8', 'replace') + return out + + +def _squash(text): + """Collapse whitespace, so a match is not defeated by argparse's line wrapping. + + argparse rewraps help text to the terminal width, so a phrase that is present can still fail a + naive line-oriented grep. This bit us while writing the test. + """ + return re.sub(r'\s+', ' ', text) + + +def test_bare_flag_is_rejected_by_both_entry_points(): + """The defect this exists for: `const=None` makes a bare flag == an absent flag. + + A unit test on the resolver cannot see this -- the bug lives in the parser declaration. + """ + for name, script in PIPELINE_ENTRY_POINTS: + out = _squash(_run(script, ['--internal-ile-interpolate-time'])) + assert 'given with no value' in out, ( + "%s accepted a BARE --internal-ile-interpolate-time. If the parser has gone back to " + "const=None, a bare flag is indistinguishable from omitting it and the feature is " + "silently disabled. Output was: %s" % (name, out[-400:])) + print("%-26s bare flag rejected: OK" % name) + + +def test_typo_and_retired_auto_are_rejected_by_both_entry_points(): + for name, script in PIPELINE_ENTRY_POINTS: + out = _squash(_run(script, ['--internal-ile-interpolate-time', 'sinK'])) + assert 'unrecognised Q_lm time-interpolation stencil' in out, \ + "%s accepted a typo'd stencil: %s" % (name, out[-400:]) + out = _squash(_run(script, ['--internal-ile-interpolate-time', 'True'])) + assert 'REMOVED' in out, \ + "%s did not reject the retired 'True' spelling: %s" % (name, out[-400:]) + print("%-26s typo and retired 'True' rejected: OK" % name) + + +def test_valid_and_off_spellings_pass_the_resolver_in_both_entry_points(): + """These must NOT trip the stencil validation. They will fail later for unrelated reasons + (no event, no data) -- what matters is that the failure is not ours.""" + ours = re.compile(r'unrecognised Q_lm|given with no value|REMOVED') + for name, script in PIPELINE_ENTRY_POINTS: + for value in ('sinc', 'cubic', 'nearest', 'False'): + out = _squash(_run(script, ['--internal-ile-interpolate-time', value])) + assert not ours.search(out), \ + "%s wrongly rejected --internal-ile-interpolate-time %s: %s" % ( + name, value, out[-400:]) + print("%-26s valid stencils and 'False' accepted: OK" % name) + + +def test_help_text_carries_the_same_crossover_guidance_in_both_entry_points(): + """Pin the DUPLICATED guidance, which has already drifted once. + + util_RIFT_pseudo_pipe.py was left recommending the pre-IMR "cubic unless below ~4 Msun" -- the + measurably WORSE stencil across roughly 4-20 Msun -- while the other copies had been updated. + Both helps must carry the canonical phrase from time_interp_choice, and neither may carry the + old recommendation. + """ + for name, script in ADVICE_SURFACES: + out = _squash(_run(script, ['--help'])) + assert CROSSOVER_GUIDANCE in out, ( + "%s --help does not contain the canonical crossover guidance %r. If the measurement " + "changed, update CROSSOVER_GUIDANCE in time_interp_choice and every help string " + "together -- that is what this test is for." % (name, CROSSOVER_GUIDANCE)) + for retired in RETIRED_GUIDANCE_FRAGMENTS: + assert retired not in out, ( + "%s --help still carries retired guidance %r. A superseded recommendation left " + "standing beside the current one reads as authoritative." % (name, retired)) + print("%-40s help carries canonical guidance, no retired text: OK" % name) + + +def test_error_messages_carry_the_canonical_guidance_too(): + """The error paths advise users as much as --help does, and were never checked. + + A bare flag and a retired 'True' both print guidance. One of them shipped rendering the + RETIRED constant spliced onto the current one -- ungrammatical, and advising the superseded + rule -- while every test passed, because nothing asserted on those strings at all. + """ + from RIFT.likelihood.time_interp_choice import ( + BARE_FLAG_SENTINEL, resolve_interpolate_time_request) + for value in (BARE_FLAG_SENTINEL, 'True'): + try: + resolve_interpolate_time_request(value) + except ValueError as e: + msg = _squash(str(e)) + else: + raise AssertionError("%r must raise" % value) + assert CROSSOVER_GUIDANCE in msg, ( + "the error for %r does not carry the canonical guidance: %r" % (value, msg)) + for retired in RETIRED_GUIDANCE_FRAGMENTS: + assert retired not in msg, ( + "the error for %r still carries retired guidance %r: %r" % (value, retired, msg)) + print("error path for %-12s carries canonical guidance, no retired text: OK" % repr(value)) + + +def test_driver_refuses_configurations_that_cannot_honour_the_stencil(): + """The conjunctive gate, exercised through the real CLI. + + Each case names a prerequisite that, if missing, means the likelihood actually executed takes + no sub-sample stencil -- so accepting the flag would run a different likelihood than the one + the user asked for, silently. + """ + cases = [ + (['--interpolate-time', 'sinc', '--gpu', '--force-xpy', '--time-marginalization'], + '--vectorized', + "GPU without --vectorized reaches DiscreteFactoredLogLikelihoodViaArrayVector"), + (['--interpolate-time', 'sinc', '--vectorized', '--gpu', '--force-xpy'], + '--time-marginalization', + "no time marginalization reaches FactoredLogLikelihood, which has no stencil argument"), + (['--interpolate-time', 'sinc', '--vectorized', '--force-xpy', '--time-marginalization'], + 'one of --gpu', + "plain --vectorized reaches the array-vector likelihood, which has no stencil argument"), + ] + for args, expect_missing, why in cases: + out = _squash(_run(DRIVER, args)) + assert 'cannot honour it' in out and expect_missing in out, ( + "driver accepted a configuration that cannot honour the stencil (%s); expected it to " + "report missing %r. Output: %s" % (why, expect_missing, out[-500:])) + print("driver rejects, missing %-22s : OK" % expect_missing) + + +def test_driver_does_not_gate_the_default_stencil(): + """'nearest' is the historical behaviour and must never be refused. + + Without this, the gate could be tightened into breaking every run that does not ask for + interpolation at all -- a far worse regression than the one it prevents. + """ + out = _squash(_run(DRIVER, ['--interpolate-time', 'nearest', '--vectorized'])) + assert 'cannot honour it' not in out, \ + "the gate must not fire for the default 'nearest' stencil: %s" % out[-400:] + print("driver does not gate 'nearest': OK") + + +if __name__ == "__main__": + test_bare_flag_is_rejected_by_both_entry_points() + test_typo_and_retired_auto_are_rejected_by_both_entry_points() + test_valid_and_off_spellings_pass_the_resolver_in_both_entry_points() + test_help_text_carries_the_same_crossover_guidance_in_both_entry_points() + test_error_messages_carry_the_canonical_guidance_too() + test_driver_refuses_configurations_that_cannot_honour_the_stencil() + test_driver_does_not_gate_the_default_stencil() + print("\nPASS") diff --git a/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_noloop_gpu_stencils.py b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_noloop_gpu_stencils.py new file mode 100644 index 000000000..2ac9f2687 --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_noloop_gpu_stencils.py @@ -0,0 +1,330 @@ +""" +test_noloop_gpu_stencils : GPU-vs-CPU parity for the BASELINE NoLoop likelihood, over all +three Q_lm sub-sample time stencils and BOTH of its GPU dispatch sites. + +factored_likelihood.DiscreteFactoredLogLikelihoodViaArrayVectorNoLoop is the baseline +consumer of the Q-window machinery, and it reaches the device through +factored_likelihood._q_inner_product_gpu at two structurally different places: + + (1) the n_cal == 1 path (no calibration marginalization), which calls the kernel once + per detector on the full Q buffer; + (2) the n_cal > 1 calibration-marginalization 'loop' path, which caches + (Q, FY_conj, ifirst, N_window_block, frac_first) per detector in `cal_cache` and + then calls the kernel once per (realization, detector) on a *block slice* + Q_det[c*N_window_block:(c+1)*N_window_block] with the within-block offset + `ifirst_within`. + +Site (2) is not covered by the kernel-level test (test_q_window_interp_gpu.py) nor by the +rotation/freqresponse tests, and it is the site where a stencil can be wired into the +plain path and forgotten in the calibration path: the block slicing changes the buffer +length seen by the kernel, so the zero-extension guard is exercised differently. + +Both sites are run here with xpy=numpy and with xpy=cupy on the SAME packed data (the +Q banks, U/V cross terms and the extrinsic parameter vector are moved to device exactly +as bin/integrate_likelihood_extrinsic_batchmode does under --gpu), for every stencil in +factored_likelihood.TIME_INTERP_CHOICES, and asserted to agree. + +TOLERANCE (chosen a priori, not fitted to the observed numbers): the two backends +evaluate the same real sum in a different order -- the CPU builds the +(n_extrinsic, npts, n_lm) Q window and contracts it with einsum, the device kernel fuses +the lm contraction -- so only floating-point reassociation should separate them. For a +reduction of this length that is ~sqrt(N)*eps*|lnL| ~ 1e-14*|lnL|. We require + max|lnL_gpu - lnL_cpu| < 1e-8 + 1e-11 * max|lnL_cpu| +i.e. ~1000x the reassociation floor, which is still many orders of magnitude tighter +than any genuine stencil/dispatch error (a wrong or missing stencil moves lnL by O(1) +nats or more, as the 'sinc' vs 'cubic' separation printed by +test_calmarg_stencil_gating demonstrates). + +SKIPPED (not failed) if cupy / a GPU is unavailable, following test_slowrot_gpu.py. + +Run on a GPU node (an sm_75 card -- the installed cupy 10.6/CUDA 11.2 cannot compile +for sm_120): + CUDA_VISIBLE_DEVICES=3 OMP_NUM_THREADS=1 \ + PYTHONPATH=/MonteCarloMarginalizeCode/Code \ + ~/RIFT_develUWM/bin/python RIFT/likelihood/test_noloop_gpu_stencils.py +""" +from __future__ import print_function, division + +import numpy as np +import lal +import lalsimulation as lalsim + +import RIFT.lalsimutils as lsu +import RIFT.likelihood.factored_likelihood as fl + +# Same pre-existing environment workaround used by test_slowrot_noloop.py / +# test_slowrot_gpu.py: when numba's @vectorize decoration is unavailable at import time, +# factored_likelihood falls back to a SCALAR lalylm which its own array call sites +# (ComputeYlmsArrayVector) cannot use. Rebinding it here affects only this process. +if not getattr(fl, "numba_on", True): + fl.lalylm = np.vectorize(lal.SpinWeightedSphericalHarmonic, otypes=[complex]) + +from RIFT.likelihood._gpu_test_support import skip_without_gpu + +try: + import cupy + _ = cupy.array(1.0) + 1.0 # force a real device op + HAVE_GPU = True + _WHY = None +except Exception as e: # pragma: no cover - env dependent + HAVE_GPU = False + _WHY = str(e) + + +fSample = 4096.0 +fmin = 30.0 +fmax = 1700.0 +event_time = 1e9 +t_window = 0.1 +Lmax = 2 +deltaT = 1. / fSample +deltaF = 1. / 4. + +N_CAL = 4 # calibration realizations for the 'loop' path +N_EXTRINSIC = 64 +T_HALFWIDTH = 0.03 # lnL(t) window half width + +# Injected distance 2 Gpc (SNR ~ 12), NOT the 200 Mpc used by the rotation tests. Those +# tests compare lnL(t) arrays; this one compares the TIME-INTEGRATED lnL, whose reduction +# is lnL = lnLmax + log simps(exp(lnL_t - lnLmax)) with lnLmax the GLOBAL max over all +# extrinsic samples. At SNR ~ 120 the spread of lnL across random sky positions is +# ~1e4 nats, so exp() underflows to 0 for the poorly-placed samples and the CPU result is +# -inf for them (a real property of the reduction, reproduced on both backends -- not a +# bug in the stencils, but it makes a difference comparison vacuous). A realistic SNR +# keeps the whole extrinsic vector in range. +Psig = lsu.ChooseWaveformParams( + fmin=fmin, radec=True, incl=0.3, phiref=0.0, theta=0.2, phi=1.0, psi=0.4, + m1=30 * lal.MSUN_SI, m2=25 * lal.MSUN_SI, detector='H1', + dist=2000e6 * lal.PC_SI, deltaT=deltaT, tref=event_time, deltaF=deltaF) + +data_dict = {} +for _det in ("H1", "L1", "V1"): + _P = Psig.manual_copy() + _P.detector = _det + data_dict[_det] = lsu.non_herm_hoff(_P) +psd_dict = {det: lalsim.SimNoisePSDaLIGOZeroDetHighPower for det in data_dict} + + +def _P_vec(K=N_EXTRINSIC, seed=1234): + """Vector of extrinsic samples, exactly the shape the ILE hands the NoLoop path.""" + rng = np.random.RandomState(seed) + Pv = Psig.manual_copy() + Pv.phi = rng.uniform(0, 2 * np.pi, K) + Pv.theta = np.arcsin(rng.uniform(-1, 1, K)) + Pv.psi = rng.uniform(0, np.pi, K) + Pv.incl = np.arccos(rng.uniform(-1, 1, K)) + Pv.phiref = rng.uniform(0, 2 * np.pi, K) + Pv.dist = rng.uniform(1500, 4000, K) * 1e6 * lsu.lsu_PC + Pv.tref = float(event_time) + Pv.deltaT = deltaT + return Pv + + +def _P_vec_to_gpu(Pv): + """Cast the sampled extrinsic arrays to device arrays, as the driver does + (integrate_likelihood_extrinsic_batchmode: ``P.phi = xpy_default.asarray(...)``).""" + Pg = Pv.manual_copy() + for attr in ("phi", "theta", "psi", "incl", "phiref", "dist"): + Pg.__dict__[attr] = cupy.asarray(np.asarray(getattr(Pv, attr), dtype=np.float64)) + Pg.tref = float(Pv.tref) + Pg.deltaT = float(Pv.deltaT) + return Pg + + +def _pack(rholms, crossTerms, crossTermsV): + """Array-pack the precompute output for the NoLoop path (one entry per detector). + + NOTE: pass None for the interpolant dict -- PackLikelihoodDataStructuresAsArrays has a + pre-existing py2-ism (`rholm_intpArray = range(nKeys)`) that raises TypeError whenever + that argument is truthy. The NoLoop array path does not use the interpolants. + """ + lookupNKDict, rholmArrayDict, ctUArrayDict, ctVArrayDict, epochDict = {}, {}, {}, {}, {} + for det in rholms: + pairKeys = list(rholms[det].keys()) + lookupNK, _lkn, _conj, ctU, ctV, rholmArray, _intp, epoch = \ + fl.PackLikelihoodDataStructuresAsArrays( + pairKeys, None, rholms[det], crossTerms[det], crossTermsV[det]) + lookupNKDict[det] = lookupNK + rholmArrayDict[det] = rholmArray + ctUArrayDict[det] = ctU + ctVArrayDict[det] = ctV + epochDict[det] = epoch + return lookupNKDict, rholmArrayDict, ctUArrayDict, ctVArrayDict, epochDict + + +def _banks_to_gpu(rholmArrayDict, ctUArrayDict, ctVArrayDict): + return ( + {d: cupy.asarray(rholmArrayDict[d]) for d in rholmArrayDict}, + {d: cupy.asarray(ctUArrayDict[d]) for d in ctUArrayDict}, + {d: cupy.asarray(ctVArrayDict[d]) for d in ctVArrayDict}, + ) + + +def _calibration_realizations(data, n_cal, seed=7): + """Smooth, physically-shaped complex calibration draws C_c(f), shape (n_freq, n_cal). + + ComputeModeIPTimeSeries iterates ``calibration_realizations.T``, applies each draw to + the DATA, and concatenates the resulting per-realization rho_lm(t) blocks -- which is + exactly the n_cal-contiguous-block layout the NoLoop 'loop' path assumes. A few + percent in amplitude and a few tens of mrad in phase is the realistic O4 scale; the + point here is only that the blocks genuinely DIFFER, so the per-realization kernel + calls cannot be accidentally satisfied by a single block. + """ + n = data.data.length + f = float(data.f0) + np.arange(n) * float(data.deltaF) + rng = np.random.RandomState(seed) + out = np.empty((n, n_cal), dtype=np.complex128) + for c in range(n_cal): + a0, a1, p0, p1 = rng.uniform(-1, 1, 4) + dA = 0.03 * (a0 * np.sin(2 * np.pi * f / 512.) + a1 * np.cos(2 * np.pi * f / 1024.)) + dphi = 0.03 * (p0 * np.cos(2 * np.pi * f / 700.) + p1 * np.sin(2 * np.pi * f / 300.)) + out[:, c] = (1.0 + dA) * np.exp(1j * dphi) + return out + + +_CACHE = {} + + +def _setup(): + """Precompute + pack, once: the plain (n_cal=1) banks and the n_cal=N_CAL banks.""" + if _CACHE: + return _CACHE + _, ct, ctV, rho, _snr, _rest = fl.PrecomputeLikelihoodTerms( + event_time, t_window, Psig, data_dict, psd_dict, Lmax, fmax, + analyticPSD_Q=True, verbose=False, quiet=True, ignore_threshold=None, + skip_interpolation=True) + _CACHE['plain'] = _pack(rho, ct, ctV) + + cal = {det: _calibration_realizations(data_dict[det], N_CAL, seed=11 + i) + for i, det in enumerate(sorted(data_dict))} + _, ct_c, ctV_c, rho_c, _snr_c, _rest_c = fl.PrecomputeLikelihoodTerms( + event_time, t_window, Psig, data_dict, psd_dict, Lmax, fmax, + analyticPSD_Q=True, verbose=False, quiet=True, ignore_threshold=None, + skip_interpolation=True, calibration_realizations=cal) + _CACHE['cal'] = _pack(rho_c, ct_c, ctV_c) + return _CACHE + + +def _tolerance(lnL_cpu): + return 1e-8 + 1e-11 * float(np.max(np.abs(np.asarray(lnL_cpu)))) + + +def _run_pair(banks, n_cal, interp, Pv, tvals): + """Return (lnL_cpu, lnL_gpu) for one (bank, n_cal, stencil) combination.""" + lookupNKDict, rholmArrayDict, ctUArrayDict, ctVArrayDict, epochDict = banks + lnL_cpu = fl.DiscreteFactoredLogLikelihoodViaArrayVectorNoLoop( + tvals, Pv, lookupNKDict, rholmArrayDict, ctUArrayDict, ctVArrayDict, epochDict, + Lmax=Lmax, xpy=np, n_cal=n_cal, cal_method='loop', time_interp=interp) + + rG, uG, vG = _banks_to_gpu(rholmArrayDict, ctUArrayDict, ctVArrayDict) + lnL_gpu = fl.DiscreteFactoredLogLikelihoodViaArrayVectorNoLoop( + cupy.asarray(tvals), _P_vec_to_gpu(Pv), lookupNKDict, rG, uG, vG, epochDict, + Lmax=Lmax, xpy=cupy, n_cal=n_cal, cal_method='loop', time_interp=interp) + return np.asarray(lnL_cpu), cupy.asnumpy(lnL_gpu) + + +def test_noloop_gpu_matches_cpu_all_stencils(): + """Both GPU dispatch sites of the baseline NoLoop, all three stencils.""" + if not HAVE_GPU: + if skip_without_gpu(HAVE_GPU, _WHY): return + cache = _setup() + Pv = _P_vec() + tvals = np.arange(int(2 * T_HALFWIDTH / deltaT)) * deltaT - T_HALFWIDTH + + results = {} + failures = [] + for label, key, n_cal in (("n_cal=1 ", 'plain', 1), + ("n_cal=%d loop" % N_CAL, 'cal', N_CAL)): + for interp in fl.TIME_INTERP_CHOICES: + lnL_cpu, lnL_gpu = _run_pair(cache[key], n_cal, interp, Pv, tvals) + assert lnL_cpu.shape == lnL_gpu.shape, \ + "shape mismatch %s %s: %s vs %s" % (label, interp, lnL_cpu.shape, lnL_gpu.shape) + assert np.all(np.isfinite(lnL_cpu)), "non-finite CPU lnL (%s, %s)" % (label, interp) + assert np.all(np.isfinite(lnL_gpu)), "non-finite GPU lnL (%s, %s)" % (label, interp) + d = float(np.max(np.abs(lnL_cpu - lnL_gpu))) + tol = _tolerance(lnL_cpu) + results[(label, interp)] = (d, tol, float(np.max(np.abs(lnL_cpu)))) + print("(GPU) NoLoop %s interp=%-7s : max|GPU-CPU| = %.3e (tol %.3e, " + "max|lnL| = %.4g)" % (label, interp, d, tol, np.max(np.abs(lnL_cpu)))) + if not (d < tol): + failures.append("%s / %s: max|GPU-CPU| = %.6e >= tol %.6e" % (label, interp, d, tol)) + assert not failures, "GPU disagrees with CPU:\n " + "\n ".join(failures) + return results + + +def test_stencils_are_distinguishable_on_gpu(): + """Guard against a silent dispatch collapse. + + The parity test above would still pass if _q_inner_product_gpu quietly returned the + 'nearest' result for every stencil (both backends would just be wrong together -- + except they would not, since the CPU dispatch is separate; but a shared upstream + collapse, e.g. frac_first left as None, would). So also require that the three + stencils give DIFFERENT GPU lnL, at both dispatch sites. + """ + if not HAVE_GPU: + if skip_without_gpu(HAVE_GPU, _WHY): return + cache = _setup() + Pv = _P_vec() + tvals = np.arange(int(2 * T_HALFWIDTH / deltaT)) * deltaT - T_HALFWIDTH + for label, key, n_cal in (("n_cal=1 ", 'plain', 1), + ("n_cal=%d loop" % N_CAL, 'cal', N_CAL)): + lnL = {} + for interp in fl.TIME_INTERP_CHOICES: + _, lnL[interp] = _run_pair(cache[key], n_cal, interp, Pv, tvals) + for a, b in (('nearest', 'cubic'), ('nearest', 'sinc'), ('cubic', 'sinc')): + sep = float(np.max(np.abs(lnL[a] - lnL[b]))) + print("(GPU) NoLoop %s stencil separation %-7s vs %-7s : max|diff| = %.3e" + % (label, a, b, sep)) + assert sep > 0.0, \ + "GPU stencils %s and %s are bit-identical (%s) -- dispatch collapsed" % (a, b, label) + + +def test_both_gpu_dispatch_sites_are_reached(): + """Structural proof that the parity test above really covered BOTH device call sites. + + Counting the calls into factored_likelihood._q_inner_product_gpu distinguishes them + unambiguously: the n_cal==1 path calls it once per detector, the calibration 'loop' + path once per (realization, detector). Without this, a refactor that routed the loop + path back through the CPU builder would leave the parity numbers above looking fine + while silently testing nothing on the device. + """ + if not HAVE_GPU: + if skip_without_gpu(HAVE_GPU, _WHY): return + cache = _setup() + Pv = _P_vec() + tvals = np.arange(int(2 * T_HALFWIDTH / deltaT)) * deltaT - T_HALFWIDTH + n_det = len(data_dict) + orig = fl._q_inner_product_gpu + for label, key, n_cal, expect in (("n_cal=1", 'plain', 1, n_det), + ("n_cal=%d loop" % N_CAL, 'cal', N_CAL, n_det * N_CAL)): + for interp in fl.TIME_INTERP_CHOICES: + counter = {'n': 0, 'lens': set()} + + def _counting(Q, A, si, fo, npts, ti, _o=orig, _c=counter): + _c['n'] += 1 + _c['lens'].add(int(Q.shape[0])) + return _o(Q, A, si, fo, npts, ti) + + fl._q_inner_product_gpu = _counting + try: + lookupNKDict, rholmArrayDict, ctUArrayDict, ctVArrayDict, epochDict = cache[key] + rG, uG, vG = _banks_to_gpu(rholmArrayDict, ctUArrayDict, ctVArrayDict) + fl.DiscreteFactoredLogLikelihoodViaArrayVectorNoLoop( + cupy.asarray(tvals), _P_vec_to_gpu(Pv), lookupNKDict, rG, uG, vG, + epochDict, Lmax=Lmax, xpy=cupy, n_cal=n_cal, cal_method='loop', + time_interp=interp) + finally: + fl._q_inner_product_gpu = orig + print("(GPU) NoLoop %-13s interp=%-7s : _q_inner_product_gpu calls = %d " + "(expected %d), device Q buffer lengths = %s" + % (label, interp, counter['n'], expect, sorted(counter['lens']))) + assert counter['n'] == expect, \ + "%s / %s reached the GPU dispatch %d times, expected %d" \ + % (label, interp, counter['n'], expect) + + +if __name__ == "__main__": + test_noloop_gpu_matches_cpu_all_stencils() + test_stencils_are_distinguishable_on_gpu() + test_both_gpu_dispatch_sites_are_reached() + print("NOLOOP GPU STENCIL CHECK DONE" if HAVE_GPU else "NOLOOP GPU STENCIL CHECK SKIPPED (no GPU)") diff --git a/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_q_window_interp.py b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_q_window_interp.py new file mode 100644 index 000000000..626e950d4 --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_q_window_interp.py @@ -0,0 +1,151 @@ +#!/usr/bin/env python3 +"""test_q_window_interp.py -- accuracy of the Q(t) sub-sample interpolation stencils. + +Q^a_lm(t) is the inverse transform of something supported on [fmin, fmax], so it is BAND-LIMITED, +and it is sampled at 1/deltaT -- usually far above the Nyquist rate its own band requires. This +test builds a signal with exactly that property, samples it, asks each stencil for values at +random sub-sample offsets, and compares against the exact band-limited signal. + +What this pins down is the CROSSOVER, because there isn't a uniformly better stencil: + + * 'nearest' is a rounding, not an interpolation: O(1) error everywhere. + * 'cubic' (4-point Lagrange) has O(h^4) error, so it improves FAST with oversampling and is + poor near Nyquist. + * 'sinc' (Lanczos, 2a taps) has window-limited error that is independent of oversampling, so + it PLATEAUS -- much better than cubic near Nyquist, worse than cubic once heavily + oversampled. + +Asserted: sinc beats cubic by >10x at fNyq/fmax <= 2 (the production regime -- srate 4096 with +fmax ~1700 is ~1.2), cubic beats sinc by the top of the range, and both beat nearest throughout. +A regression that "improved" sinc into winning everywhere would mean the window had been widened +until it was no longer a local stencil, so the crossover is asserted in BOTH directions. + +Self-contained: numpy only, no LAL, no data. Runs in about a second. + + python3 test_q_window_interp.py # or: pytest test_q_window_interp.py + +NOTE the assertions live in test_-prefixed functions, not in main(). They used to live only in +main(), which meant `pytest` collected ZERO tests from this file and reported success -- the +crossover gate silently did not run. Keep any new assertion in a test_ function. +""" +from __future__ import print_function + +import numpy as np + +from RIFT.likelihood.factored_likelihood import ( + _cubic_Q_window_numpy, + _nearest_Q_window_numpy, + _sinc_Q_window_numpy, +) + + +def band_limited_signal(n_time, n_lm, oversample, seed=1234): + """A complex signal whose spectrum is zero above n_time/(2*oversample) bins. + + Returned as (samples, evaluate) where evaluate(t) gives the exact continuum value at + arbitrary real sample coordinate t, by direct evaluation of the Fourier sum -- so the + comparison is against truth, not against another interpolant. + """ + rng = np.random.RandomState(seed) + kmax = int(n_time // (2 * oversample)) + ks = np.arange(-kmax, kmax + 1) + amps = (rng.randn(len(ks), n_lm) + 1j * rng.randn(len(ks), n_lm)) / np.sqrt(len(ks)) + + def evaluate(t): + t = np.atleast_1d(np.asarray(t, dtype=float)) + phase = np.exp(2j * np.pi * np.outer(t, ks) / float(n_time)) + return phase.dot(amps) + + return evaluate(np.arange(n_time)), evaluate + + +def max_rel_error(kind, samples, evaluate, starts, fracs, npts, n_time): + if kind == "nearest": + got = _nearest_Q_window_numpy(samples, (np.round(starts + fracs)).astype(int), npts) + elif kind == "cubic": + got = _cubic_Q_window_numpy(samples, starts, fracs, npts) + elif kind == "sinc": + got = _sinc_Q_window_numpy(samples, starts, fracs, npts) + else: + raise ValueError(kind) + err = 0.0 + scale = np.max(np.abs(samples)) + for i in range(len(starts)): + t = starts[i] + fracs[i] + np.arange(npts) + # stay clear of the ends, where every stencil zero-extends + keep = (t > 32) & (t < n_time - 32) + if not np.any(keep): + continue + err = max(err, np.max(np.abs(got[i][keep] - evaluate(t[keep]))) / scale) + return err + + +N_TIME, N_LM, NPTS = 4096, 2, 24 + + +def _fixed_targets(): + rng = np.random.RandomState(7) + return rng.randint(200, N_TIME - 300, size=6), rng.rand(6) + + +def test_stencil_accuracy_and_crossover(): + """The accuracy table, and the crossover asserted in BOTH directions.""" + starts, fracs = _fixed_targets() + + print("%-12s %14s %14s %14s" % ("fNyq/fmax", "nearest", "cubic", "sinc(a=8)")) + err = {} + for oversample in (1.5, 2, 4, 8, 16): + samples, evaluate = band_limited_signal(N_TIME, N_LM, oversample) + e = {k: max_rel_error(k, samples, evaluate, starts, fracs, NPTS, N_TIME) + for k in ("nearest", "cubic", "sinc")} + err[oversample] = e + print("%-12s %14.3e %14.3e %14.3e" + % (oversample, e["nearest"], e["cubic"], e["sinc"])) + assert e["cubic"] < e["nearest"], "cubic must beat nearest at fNyq/fmax=%s" % oversample + assert e["sinc"] < e["nearest"], "sinc must beat nearest at fNyq/fmax=%s" % oversample + + # Near Nyquist -- the production regime -- sinc must win, and by a lot. + for oversample in (1.5, 2): + gain = err[oversample]["cubic"] / err[oversample]["sinc"] + print(" fNyq/fmax=%s: sinc is %.0fx better than cubic" % (oversample, gain)) + assert gain > 10, "sinc must beat cubic by >10x at fNyq/fmax=%s (got %.1fx)" % ( + oversample, gain) + + # Heavily oversampled, cubic's h^4 wins: assert that too, so nobody "fixes" sinc into + # winning everywhere by quietly widening the window past a local stencil. + assert err[16]["cubic"] < err[16]["sinc"], ( + "cubic should win at fNyq/fmax=16 (%g vs %g) -- if this fails the stencil is no longer " + "local" % (err[16]["cubic"], err[16]["sinc"])) + print(" fNyq/fmax=16: cubic is %.0fx better than sinc, as expected" + % (err[16]["sinc"] / err[16]["cubic"])) + + +def test_zero_offset_identity(): + """At integer offsets every stencil must reproduce the samples exactly.""" + starts, _ = _fixed_targets() + samples, _ = band_limited_signal(N_TIME, N_LM, 8) + exact = _sinc_Q_window_numpy(samples, starts, np.zeros(len(starts)), NPTS) + for i, s0 in enumerate(starts): + assert np.allclose(exact[i], samples[s0:s0 + NPTS], atol=1e-12), \ + "sinc must be the identity at zero fractional offset" + print("zero-offset identity: OK") + + +def test_partition_of_unity(): + """Weights must sum to one for any offset, so a constant is interpolated exactly.""" + from RIFT.likelihood.factored_likelihood import _sinc_lanczos_weights + for u in (0.0, 0.1, 0.5, 0.9, 0.999): + _, w = _sinc_lanczos_weights(u) + assert abs(w.sum() - 1.0) < 1e-12, "weights must sum to 1 at u=%g" % u + print("partition of unity: OK") + + +def main(): + test_stencil_accuracy_and_crossover() + test_zero_offset_identity() + test_partition_of_unity() + print("\nPASS") + + +if __name__ == "__main__": + main() diff --git a/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_q_window_interp_gpu.py b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_q_window_interp_gpu.py new file mode 100644 index 000000000..4a1e156f8 --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_q_window_interp_gpu.py @@ -0,0 +1,141 @@ +#!/usr/bin/env python3 +"""test_q_window_interp_gpu -- GPU/CPU parity for the Q(t) sub-sample stencils. + +test_q_window_interp.py pins down how ACCURATE each stencil is against a known band-limited +signal. This file pins down something different and equally necessary: that the CUDA kernels +compute the SAME stencil the numpy reference does. Accuracy evidence gathered on the CPU only +transfers to production -- which runs --gpu -- if the two agree. + +Three levels, cheapest first, so a failure localises itself: + + 1. weights. _sinc_lanczos_weight_matrix is evaluated with the numpy and the cupy backend. + Both are the same source expression, so this only measures the difference between the two + sin() implementations. If THIS is the thing that is large, nothing downstream is a kernel + bug. + 2. kernel. Q_inner_product_{,cubic_,sinc_}cupy against the numpy window builder contracted + with the same A, on random data -- including windows deliberately placed so the stencil + hangs off both ends of the Q buffer, which is the one place the per-tap zero-extension + guard can differ between the two implementations. + 3. likelihood. Covered by test_slowrot_gpu.py / test_slowrot_freqresponse_gpu.py, which loop + over all three stencils. + +SKIPPED if cupy / a GPU is unavailable. Run on a GPU node: + python RIFT/likelihood/test_q_window_interp_gpu.py + +MEASURED (2026-08): identical to the last digit on an RTX 2080 Ti (sm_75) and on an RTX PRO 4000 +Blackwell (sm_120), so the kernel is not architecture-sensitive. + +If cupy raises "nvrtc: error: invalid value for --gpu-architecture (-arch)" you are on a card +newer than your cupy knows. cupy 10.6 computes min(arch, nvrtc_max_cc) on STRINGS, so +min("120","86") == "120" and it hands nvrtc an sm_120 it cannot target. BOTH of these are needed +to work around it (either alone still fails) -- pin the PTX target and let the driver JIT forward: + + export CUPY_COMPILE_WITH_PTX=1 + # plus a sitecustomize.py early on PYTHONPATH: + # import cupy.cuda.compiler as _c; _c._get_arch = lambda: "86" + +That is a test-time workaround for an old cupy, NOT something to carry into production; the real +fix is a container whose CUDA can target the card directly. +""" +from __future__ import print_function, division + +import numpy as np + +import RIFT.likelihood.factored_likelihood as FL + +from RIFT.likelihood._gpu_test_support import skip_without_gpu + +try: + import cupy + _ = cupy.array(1.0) + 1.0 # force a real device op + from RIFT.likelihood import Q_inner_product as QIP + HAVE_GPU = True +except Exception as e: # pragma: no cover + HAVE_GPU = False + _WHY = str(e) + +# Agreement demanded of the kernels. The CPU builder sums taps then contracts over lm; the +# kernels fuse the two, so the summation order differs and bitwise equality is not available. +# What IS available is agreement at the level double-precision reassociation allows. +TOL_REL = 1e-13 + + +def _cpu_reference(Q, A, starts, fracs, npts, time_interp): + """(n_ex, npts) product, built the CPU way: window first, then contract over lm.""" + Qlms = FL._q_window_numpy_interp(Q, starts, fracs, npts, time_interp) + return np.einsum("ej,etj->et", A, Qlms) + + +def _gpu(Q, A, starts, fracs, npts, time_interp): + return cupy.asnumpy(FL._q_inner_product_gpu( + cupy.asarray(Q), cupy.asarray(A), cupy.asarray(starts.astype(np.int32)), + cupy.asarray(fracs), npts, time_interp)) + + +def test_weight_backends_agree(): + """Level 1: the shared weight formula, numpy backend vs cupy backend.""" + if not HAVE_GPU: + if skip_without_gpu(HAVE_GPU, _WHY): return + u = np.concatenate([np.linspace(0.0, 1.0, 257), [0.0, 0.5, 1.0 - 1e-12]]) + _, w_np = FL._sinc_lanczos_weight_matrix(u) + _, w_cp = FL._sinc_lanczos_weight_matrix(cupy.asarray(u), xpy=cupy) + d = float(np.max(np.abs(w_np - cupy.asnumpy(w_cp)))) + print("(GPU) sinc weights, numpy vs cupy backend : max|diff| = %.3e" % d) + assert d < 1e-14, "the two backends' sinc() disagree by more than round-off: %g" % d + # Partition of unity must survive on the device too, or a constant is not reproduced. + s = float(np.max(np.abs(cupy.asnumpy(w_cp).sum(axis=1) - 1.0))) + print("(GPU) sinc weights, device partition of unity : max|sum-1| = %.3e" % s) + assert s < 1e-12, "device weights do not sum to one: %g" % s + + +def _kernel_case(label, n_time, npts, n_lm, starts, seed=3): + rng = np.random.RandomState(seed) + Q = (rng.randn(n_time, n_lm) + 1j * rng.randn(n_time, n_lm)) + A = (rng.randn(len(starts), n_lm) + 1j * rng.randn(len(starts), n_lm)) + fracs = rng.rand(len(starts)) + scale = np.max(np.abs(Q)) * np.max(np.abs(A)) * n_lm + for interp in FL.TIME_INTERP_CHOICES: + s = np.round(starts + fracs).astype(np.int32) if interp == 'nearest' else starts.astype(np.int32) + f = np.zeros(len(starts)) if interp == 'nearest' else fracs + cpu = _cpu_reference(Q, A, s, f, npts, interp) + gpu = _gpu(Q, A, s, f, npts, interp) + d = float(np.max(np.abs(cpu - gpu))) / scale + print("(GPU) %-18s interp=%-8s : max|diff|/scale = %.3e" % (label, interp, d)) + assert d < TOL_REL, "%s kernel disagrees with CPU (%s): %g" % (label, interp, d) + + +def test_kernels_match_cpu_interior(): + """Level 2a: windows well inside the buffer, where no tap is ever dropped.""" + if not HAVE_GPU: + if skip_without_gpu(HAVE_GPU, _WHY): return + n_time, npts, n_lm = 2048, 32, 5 + starts = np.random.RandomState(11).randint(64, n_time - 64 - npts, size=64) + _kernel_case("interior", n_time, npts, n_lm, starts) + + +def test_kernels_match_cpu_at_edges(): + """Level 2b: windows hanging off BOTH ends. + + This is the case that separates a correct kernel from a plausible one. The sinc stencil is + 2a=16 taps wide, so it reaches much further past the buffer than the cubic's 4, and the + weights are normalised over the FULL stencil before any tap is dropped -- dropped taps are + NOT renormalised away. A kernel that renormalised the surviving taps, or that let a + negative index wrap, would still look perfect in the interior test above. + """ + if not HAVE_GPU: + if skip_without_gpu(HAVE_GPU, _WHY): return + n_time, npts, n_lm = 512, 24, 3 + a = FL.SINC_HALFWIDTH_DEFAULT + # deliberately straddle 0 and n_time by more than the widest stencil + starts = np.array( + list(range(-a - 2, a + 3)) + + list(range(n_time - npts - a - 2, n_time - npts + a + 3)), + dtype=np.int32) + _kernel_case("edge/zero-extend", n_time, npts, n_lm, starts, seed=5) + + +if __name__ == "__main__": + test_weight_backends_agree() + test_kernels_match_cpu_interior() + test_kernels_match_cpu_at_edges() + print("Q WINDOW GPU PARITY DONE" if HAVE_GPU else "Q WINDOW GPU PARITY SKIPPED (no GPU)") diff --git a/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_cauchy_schwarz.py b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_cauchy_schwarz.py new file mode 100644 index 000000000..7e38af1ba --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_cauchy_schwarz.py @@ -0,0 +1,204 @@ +"""test_slowrot_cauchy_schwarz : the rotation likelihood must be a real - (1/2). + +For ANY single template h, lnL = - (1/2) <= (1/2). That is Cauchy-Schwarz, not +an approximation, so it holds whatever the model error is -- a truncated delay expansion, a wrong +sky position, the wrong waveform family. The only way a "likelihood" can exceed it is by +evaluating its two terms for DIFFERENT h. + +That is exactly the failure this file guards. The precompute builds its elementary templates +chi_a(u) = e^{i n Omega u} h^{(p)}(u) on the template's INTRINSIC time u, while the physical +response modulation e^{i n Omega (t'-tref)} lives on absolute time. Placing the template at +arrival time t makes the two differ by exp(i n Omega (t - tref)) -- the post-phase carried by +rotation_post_phase(). Drop it from the model norm, or apply it to only one of the two terms, +and lnL overshoots the bound by O(n Omega (t - tref)) * : ~1e-4 of at the physical +90-minute-BNS rate. That is invisible next to any ordinary convergence check and fatal to the +one statement about a likelihood that cannot be argued with. + +THE ARRIVAL OFFSET MUST BE NONZERO, AND THAT IS THE WHOLE POINT. +The post-phase is exp(i n Omega (t - tref)); at t = tref it is the identity and the defect is +invisible. So the data here places the signal at the detector's true geometric arrival time +(+10.2 ms for H1 at this sky position, 42 samples), which is where a real analysis evaluates it. +A version of this test with the signal at t = tref passes on the BROKEN code. + +Three checks, in order, because the later ones are worthless without the earlier ones: + + (A) TEETH. With the modulation switched off (f_sidereal=0) against the SAME rotating data, the + deficit must be LARGE. If it is not, this configuration does not exercise rotation and + (B),(C) would pass on an untested code path. + (B) THE BOUND. No sampled lnL(t) may exceed (1/2). No interpolation is involved, so no + estimator tolerance is needed: every sampled value is a genuine lnL for its arrival time. + The data is the exact Path-A model, so at the true arrival sample lnL sits ON the bound and + the check is maximally tight -- there is no slack for an inconsistency to hide in. + (C) THE MECHANISM. lnL(t) must equal a directly constructed - (1/2) for the model + the likelihood implies, built explicitly in the time domain and contracted with the same + band-limited, noise-weighted inner product. (B) can only detect a violation; (C) pins the + value from an independent construction. + +(C) scans only NON-NEGATIVE arrival offsets. RIFT's mode arrays start with the tapered onset of +the inspiral at index 0 and park the merger near the end, so a circular shift to earlier times +wraps real signal across the segment boundary, where the FFT correlation the precompute uses and +an explicit time-domain roll legitimately disagree (by exp(i n Omega * seglen) on the wrapped +samples). That is a property of the finite segment, not of the likelihood; shifting later wraps +only the decayed ringdown and is clean to machine precision. + +Run: source ~/RIFT_develUWM/bin/activate; + PYTHONPATH=/MonteCarloMarginalizeCode/Code python +""" +from __future__ import print_function, division +import numpy as np +import lal +import lalsimulation as lalsim +import RIFT.lalsimutils as lsu +import RIFT.likelihood.factored_likelihood as fl +import RIFT.likelihood.factored_likelihood_with_rotation as flwr +import RIFT.likelihood.slowrot_response as srr + +fmin = 30.; fmax = 1700.; event_time = 1e9; t_window = 0.1; Lmax = 2 +deltaT = 1. / 4096.; seglen = 4.; deltaF = 1. / seglen +fNyq = 1. / 2. / deltaT; N = int(round(seglen / deltaT)) +det = 'H1' +HARM = (-2, -1, 0, 1, 2) +# Omega * T_segment equal to a 90-minute (5400 s) signal at the true sidereal rate. The +# 5-harmonic antenna expansion is EXACT at any Omega, so inflating it costs no accuracy. +INFL = 5400. / seglen +OMEGA = flwr.OMEGA_EARTH * INFL +FSID = OMEGA / (2.0 * np.pi) +RA, DEC, PSI, INCL, PHIREF = 1.0, 0.2, 0.5, 0.7, 0.9 +DLOUD = fl.distMpcRef * 1e6 * lsu.lsu_PC / 30. # loud, so lnL sits near the bound + +TOL_BOUND = 1e-6 # nats above (1/2) that we call a violation +TOL_DIRECT = 1e-6 # nats of disagreement with the explicit model +MIN_STATIC_DEFICIT = 1.0 # (A): rotation must be worth at least this much here +NPTS_SCAN = 164 # +-20 ms +SCAN_HALF = 10 # (C) samples either side of the arrival sample + + +def _ifft_arr(hf): + n = hf.data.length; dt = 1. / (n * hf.deltaF) + ts = lal.CreateCOMPLEX16TimeSeries("h", hf.epoch, 0., dt, lal.DimensionlessUnit, n) + lal.COMPLEX16FreqTimeFFT(ts, hf, lal.CreateReverseCOMPLEX16FFTPlan(n, 0)) + return np.array(ts.data.data) + + +def _to_fd(arr, epoch, dt, n): + ts = lal.CreateCOMPLEX16TimeSeries("h", epoch, 0., dt, lal.DimensionlessUnit, n) + ts.data.data[:] = arr[:n] + hf = lal.CreateCOMPLEX16FrequencySeries("hf", epoch, 0., 1. / dt / n, lsu.lsu_HertzUnit, n) + lal.COMPLEX16TimeFreqFFT(hf, ts, lal.CreateForwardCOMPLEX16FFTPlan(n, 0)) + return hf + + +Psig = lsu.ChooseWaveformParams( + fmin=fmin, radec=True, incl=INCL, phiref=PHIREF, theta=DEC, phi=RA, psi=PSI, + m1=30 * lal.MSUN_SI, m2=25 * lal.MSUN_SI, detector=det, dist=200e6 * lal.PC_SI, + deltaT=deltaT, tref=event_time, deltaF=deltaF) + +lald = lalsim.DetectorPrefixToLALDetector(det) +DELAY = float(lal.TimeDelayFromEarthCenter(np.asarray(lald.location), RA, DEC, + lal.LIGOTimeGPS(event_time))) +K_ARR = int(round(DELAY / deltaT)) # arrival sample offset from tref +assert K_ARR > 0, ("this test needs the signal placed at a POSITIVE arrival offset (see the " + "module docstring): the post-phase is the identity at zero offset, and a " + "negative one wraps the inspiral onset. Geometric delay here is %g s." % DELAY) + +# ---------------------------------------------------------------- data: the exact Path-A model, +# placed at the detector's geometric arrival time. +Pm = Psig.manual_copy(); Pm.dist = DLOUD +hlms_d, _ = fl.internal_hlm_generator(Pm, Lmax, verbose=False, quiet=True) +lm0 = list(hlms_d.keys())[0] +epoch_intr = float(hlms_d[lm0].epoch) +u_grid = epoch_intr + np.arange(N) * deltaT # data-grid intrinsic time = t' - tref +hY_data = np.zeros(N, dtype=complex) +for lm in hlms_d: + hY_data += _ifft_arr(hlms_d[lm]) * lal.SpinWeightedSphericalHarmonic(INCL, -PHIREF, -2, + lm[0], lm[1]) +g_ev = float(lal.GreenwichMeanSiderealTime(lal.LIGOTimeGPS(event_time))) - RA +Atil = {n: v * np.exp(1j * n * g_ev) + for n, v in srr.antenna_harmonics(lald.response, DEC, PSI).items()} +F_of_u = sum(Atil[n] * np.exp(1j * n * OMEGA * u_grid) for n in Atil) +data = _to_fd(np.real(F_of_u * np.roll(hY_data, K_ARR)), + lal.LIGOTimeGPS(epoch_intr + event_time), deltaT, N) +data_dict = {det: data} +psd_dict = {det: lalsim.SimNoisePSDaLIGOZeroDetHighPower} +IPc = lsu.ComplexIP(fmin, fmax, fNyq, data.deltaF, psd_dict[det], True, False, 0.) +HALF_DD = 0.5 * IPc.ip(data, data).real +print("INFL=%.1f (Omega*T_seg=%.3f rad) arrival offset %+d samples (%+.2f ms) 0.5=%.6f" + % (INFL, OMEGA * seglen, K_ARR, 1e3 * K_ARR * deltaT, HALF_DD)) + + +def rotation_lnL_t(f_sidereal): + """lnL(t) from the maintained rotation NoLoop, plus the arrival sample offsets it used.""" + P = Psig.manual_copy() + bank = flwr.PrecomputeLikelihoodTermsWithRotation( + event_time, t_window, P, data_dict, psd_dict, Lmax, fmax, harmonics=HARM, p_max=0, + f_sidereal=f_sidereal, analyticPSD_Q=True, verbose=False, quiet=True, + skip_interpolation=True) + meta = bank[4] + lk, rho_b, U_b, V_b, epd = flwr.pack_rotation_arrays(meta, bank[3], bank[1], bank[2]) + Pv = Psig.manual_copy() + for key, v in [('phi', RA), ('theta', DEC), ('incl', INCL), ('phiref', PHIREF), + ('psi', PSI), ('dist', DLOUD)]: + setattr(Pv, key, np.ones(1) * v) + Pv.tref = event_time; Pv.deltaT = deltaT + tvals = -0.02 + np.arange(NPTS_SCAN) * deltaT + lnL_t = flwr.DiscreteFactoredLogLikelihoodViaArrayVectorNoLoopWithRotation( + tvals, Pv, meta, lk, rho_b, U_b, V_b, epd, Lmax=Lmax, array_output=True)[0] + # Reproduce the NoLoop's own indexing so we know which arrival sample each output is. + off = float(Pv.tref - float(epd[det])) + ifirst = int(np.round((off + DELAY + tvals[0]) / deltaT)) + kvals = ifirst + np.arange(NPTS_SCAN) - int(round(off / deltaT)) + return np.asarray(lnL_t), kvals + + +# ---------------------------------------------------------------- (A) teeth +lnL_static, _ = rotation_lnL_t(0.0) +static_deficit = HALF_DD - float(np.max(lnL_static)) +print("(A) rotation OFF vs rotating data: deficit = %.4f nats" % static_deficit) +assert static_deficit > MIN_STATIC_DEFICIT, ( + "this configuration does not exercise rotation (static deficit %g <= %g), so the bound and " + "direct-model checks below would be vacuous" % (static_deficit, MIN_STATIC_DEFICIT)) + +# ---------------------------------------------------------------- (B) the bound +lnL_rot, kvals = rotation_lnL_t(FSID) +overshoot = float(np.max(lnL_rot)) - HALF_DD +jpeak = int(np.argmax(lnL_rot)) +print("(B) rotation ON : max lnL = %.6f at k=%+d deficit = %+.6e" + % (np.max(lnL_rot), kvals[jpeak], HALF_DD - np.max(lnL_rot))) +assert kvals[jpeak] == K_ARR, ( + "lnL peaks at arrival sample %d, not the %d the data was built at -- the test is no longer " + "sitting on the bound and (B) has lost its teeth" % (kvals[jpeak], K_ARR)) +assert overshoot <= TOL_BOUND, ( + "Cauchy-Schwarz VIOLATED: max lnL exceeds 0.5 by %g nats. lnL = - (1/2) " + "cannot exceed (1/2) for any h, so term1 and term2 are being evaluated for different " + "templates -- see rotation_post_phase()." % overshoot) + +# ---------------------------------------------------------------- (C) the mechanism +# The model the likelihood implies, built explicitly: +# h(t') = invDist * Re[ F(t'-tref) * hY(t' - t_arr) ], F from the SAME A_tilde harmonics. +Pref = Psig.manual_copy() +Pref.dist = fl.distMpcRef * 1e6 * lsu.lsu_PC +Pref.deltaF = data.deltaF +hlms_r, _ = fl.internal_hlm_generator(Pref, Lmax, verbose=False, quiet=True) +Ylm_r = fl.ComputeYlms(Lmax, INCL, -PHIREF, selected_modes=list(hlms_r.keys())) +hY_ref = np.zeros(N, dtype=complex) +for lm in hlms_r: + hY_ref += Ylm_r[lm] * _ifft_arr(hlms_r[lm]) +invDist = fl.distMpcRef / (DLOUD / (lsu.lsu_PC * 1e6)) +data_epoch = lal.LIGOTimeGPS(epoch_intr + event_time) + +worst = 0.0; n_cmp = 0 +for j in range(max(0, jpeak - SCAN_HALF), min(NPTS_SCAN, jpeak + SCAN_HALF + 1)): + k = int(kvals[j]) + if k < 0: # see the docstring: negative shifts wrap the inspiral onset + continue + hf = _to_fd(np.real(F_of_u * np.roll(hY_ref, k)) * invDist, data_epoch, deltaT, N) + lnL_direct = IPc.ip(hf, data).real - 0.5 * IPc.ip(hf, hf).real + worst = max(worst, abs(lnL_direct - lnL_rot[j])); n_cmp += 1 +print("(C) vs explicit time-domain model over %d samples about the peak: max|d lnL| = %.3e" + % (n_cmp, worst)) +assert n_cmp >= SCAN_HALF, "too few comparable samples (%d) for (C) to mean anything" % n_cmp +assert worst < TOL_DIRECT, ( + "rotation NoLoop disagrees with the explicit - (1/2) for the model it implies " + "by %g nats" % worst) + +print("ALL SLOWROT CAUCHY-SCHWARZ CHECKS PASSED") diff --git a/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_fd_ops.py b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_fd_ops.py index 7c42a1890..8d87cf757 100644 --- a/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_fd_ops.py +++ b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_fd_ops.py @@ -7,8 +7,11 @@ 2. Which signed frequency LAL assigns to a tone, vs evaluate_fvals_from_length -> fixes the sign FT_SIGN in the time-derivative weight. 3. fd_apply_time_derivative reproduces d^p/dt^p exactly for a multi-tone signal. - 4. _lal_freq_modulate reproduces exp(i coef Omega t) multiplication exactly. - 5. the O(N^2) reference apply_sidereal_modulation_array agrees with the LAL round trip. + 4. fd_apply_time_derivative COMMUTES with conjugation and maps real -> real when the + signal has Nyquist-bin content, AND gives the right VALUE there at both parities of p + (issue #159). + 5. _lal_freq_modulate reproduces exp(i coef Omega t) multiplication exactly. + 6. the O(N^2) reference apply_sidereal_modulation_array agrees with the LAL round trip. Run: python test_slowrot_fd_ops.py (also usable under pytest) """ @@ -58,8 +61,13 @@ def _reverse(hf): def _multitone(): """h(t) = sum_j c_j exp(2 pi i f_j t), f_j on distinct grid bins.""" - bins = [3, 7, -5, 12, -11] - coeffs = [1.0, 0.5 - 0.3j, -0.8j, 0.4, 0.2 + 0.1j] + # 120/-119 are NEAR Nyquist on purpose. Without them the highest tone is bin 12 of 128, so + # ANY mask down to ~0.1*fNyq passes every test here -- verified: w[abs(f) >= 0.9*fn] = 0. + # survives the whole file. A mask that eats the top of the band is a silent likelihood + # error (production |H(+fNyq)| is 0.02-0.14 of |H(100 Hz)|), and it is exactly what the + # implementation comment in factored_likelihood_with_rotation says it is guarding against. + bins = [3, 7, -5, 12, -11, 120, -119] + coeffs = [1.0, 0.5 - 0.3j, -0.8j, 0.4, 0.2 + 0.1j, 0.3, 0.25 - 0.1j] h = np.zeros(N, dtype=complex) for b, c in zip(bins, coeffs): h += c * np.exp(2.0j * np.pi * (b * DELTA_F) * _T) @@ -109,6 +117,107 @@ def test_time_derivative_exact(): assert err < 1e-9, "derivative order %d inexact: %g" % (p, err) +def test_derivative_commutes_with_conjugation_at_nyquist(): + """d/dt conj(h) == conj(d/dt h), with the Nyquist bin POPULATED. See issue #159. + + This packing carries +fNyq (index 0) but not -fNyq, so a derivative weight -- odd in f -- + has no consistent value there. Left at +(2 pi i fNyq)^p, the two routes below disagree in + that one bin by a sign for odd p. Nothing in the U cross terms notices, because both + factors come from the same template family; V = pairs the two routes + against each other, and the sidereal modulation (a sub-bin shift done as a time-domain + phase) then spreads that single bin across the whole band. In the p_max=1 slow-rotation + bank that was worth 1.5e-07 of the model norm -- enough to break Cauchy-Schwarz. + + Zeroing the Nyquist weight AT ODD p is what makes these two routes agree AND keeps + d^p/dt^p of a real series real; both are asserted here, at odd and even p alike (even p + already commutes, and must keep doing so). Without the Nyquist tone this test passes + either way, so keep the tone. Consistency does NOT pin the weight's value -- any real + w[+fNyq] passes this test -- so read it together with + test_nyquist_derivative_value_both_parities, which does. + """ + h, bins, coeffs = _multitone() + h = h + 0.6 * np.exp(2.0j * np.pi * (N // 2 * DELTA_F) * _T) # the +fNyq bin + hf_nyq = _forward(_make_timeseries(h)).data.data[0] + assert abs(hf_nyq) > 1e-3 * np.max(np.abs(h)), ( + "this test is vacuous unless the Nyquist bin actually carries power (got %g)" + % abs(hf_nyq)) + for p in range(1, 7): + a = np.conj(_reverse(flwr.fd_apply_time_derivative( + _forward(_make_timeseries(h)), p)).data.data) # differentiate, then conj + b = _reverse(flwr.fd_apply_time_derivative( + _forward(_make_timeseries(np.conj(h))), p)).data.data # conj, then differentiate + err = np.max(np.abs(a - b)) / np.max(np.abs(b)) + print("conj/derivative commutation p=%d: rel err = %.2e" % (p, err)) + # 1e-9 is the same gate test_time_derivative_exact uses, and it is a ROUNDOFF + # bound, not slack: the two routes are the same arithmetic through different FFTs, + # and (2 pi f)^p amplifies the round trip, so the residual grows with p while the + # odd-p normalisation shrinks (the zeroed Nyquist term drops out of the + # denominator). Measured with the fix in: 2.8e-15 / 6.1e-16 / 3.3e-13 / 4.2e-16 / + # 3.3e-11 / 4.6e-16 at p = 1..6. Without it the residual is 1.7e+00 to 3.0e+01 -- + # eight orders clear of this gate, so tightening it buys nothing and p >= 5 would + # fail on precision alone. + assert err < 1e-9, ( + "d/dt does not commute with conjugation at order %d (rel %g): the Nyquist bin of " + "time_derivative_weight is inconsistent, and crossTermsV_rot pairs the two orders " + "-- see issue #159" % (p, err)) + + # ... and the derivative of a REAL series must be real. + r = np.real(h) + dr = _reverse(flwr.fd_apply_time_derivative( + _forward(_make_timeseries(r.astype(complex))), p)).data.data + imag = np.max(np.abs(np.imag(dr))) / np.max(np.abs(dr)) + print("real-in real-out p=%d: |Im|/|.| = %.2e" % (p, imag)) + assert imag < 1e-9, ( + "d^%d/dt^%d of a real series came back complex (|Im|/|.| = %g)" % (p, p, imag)) + + +def test_nyquist_derivative_value_both_parities(): + """Pin the VALUE of the Nyquist weight, at both parities. See issue #159. + + The commutation test below is necessary but NOT sufficient: ANY REAL value of + w[+fNyq] commutes with conjugation and keeps a real series real, so consistency alone + does not pin the weight. This one does, from the sampled signal: + + * the real Nyquist component is (-1)^j = cos(2 pi fNyq t) sampled. Its ODD + derivatives are -2 pi fNyq sin(2 pi fNyq t) etc, which vanish at every sample, so + the correct weight at odd p is exactly ZERO -- and that is also the only value that + can serve both +fNyq and -fNyq, which share this one bin. + * its EVEN derivatives are (-(2 pi fNyq)^2)^(p/2) (-1)^j, exactly representable, so + the untouched weight is correct and zeroing it would be a regression. An earlier + revision of the #159 fix zeroed every p >= 1: that removes the even-p Nyquist term + ENTIRELY, so it fails below at rel err 1.00 (90% at p = 2 and 99% at p = 4 when + measured against a full multitone rather than the isolated tone). + """ + fnyq = 1.0 / (2.0 * DELTA_T) + nyq = np.exp(2.0j * np.pi * (N // 2 * DELTA_F) * _T) # == (-1)^j, real + assert np.max(np.abs(np.imag(nyq))) < 1e-12 + base, _, _ = _multitone() + h = np.real(base) + 0.6 * np.real(nyq) # real, WITH Nyquist power + hf = _forward(_make_timeseries(h.astype(complex))) + assert abs(hf.data.data[0]) > 1e-3 * np.max(np.abs(h)), ( + "vacuous unless the Nyquist bin carries power (got %g)" % abs(hf.data.data[0])) + + for p in range(1, 7): # p >= 5 too: --rotation-p-max is an unbounded int + # the Nyquist tone's own contribution, isolated: differentiate it alone. + hf_n = _forward(_make_timeseries((0.6 * np.real(nyq)).astype(complex))) + got_n = _reverse(flwr.fd_apply_time_derivative(hf_n, p)).data.data + scale = np.max(np.abs(_reverse(flwr.fd_apply_time_derivative(hf, 0)).data.data)) + if p % 2: + err = np.max(np.abs(got_n)) / (scale * (2.0 * np.pi * fnyq) ** p) + print("nyquist value p=%d (odd, want 0): |d^p x_nyq| / scale = %.2e" % (p, err)) + assert err < 1e-12, ( + "odd derivative of the sampled Nyquist component must vanish (got %g of " + "the naive weight); w[+fNyq] is not zero -- see issue #159" % err) + else: + want = 0.6 * (-(2.0 * np.pi * fnyq) ** 2) ** (p // 2) * np.real(nyq) + err = np.max(np.abs(got_n - want)) / np.max(np.abs(want)) + print("nyquist value p=%d (even, want exact): rel err = %.2e" % (p, err)) + assert err < 1e-10, ( + "even derivative of the Nyquist component IS representable and must be " + "exact (rel %g) -- do not zero the Nyquist weight for even p, see #159" + % err) + + def test_sidereal_modulation_exact(): h, _, _ = _multitone() f_sid = 0.05 * DELTA_F # exaggerated so coef*f_sid is an appreciable sub-bin shift @@ -135,10 +244,125 @@ def test_reference_matrix_matches_lal_modulation(): assert err < 1e-9, "reference matrix disagrees with LAL: %g" % err +def test_nyquist_guard_clauses_on_synthetic_axes(): + """The guard clauses in time_derivative_weight, which no production axis reaches. + + Every current caller (fd_apply_time_derivative, and the jax ladder's _FVALS) passes a + two-sided evaluate_fvals_from_length axis, which carries +fNyq and not -fNyq. So the + "one-sided axis", "symmetric axis" and "fftfreq ordering" branches are dead in the suite, + and FOUR mutations of them survived the rest of this file: + + f.size < 2 -> f.size < 0 killed here by sub-case (iv), degenerate axes + drop the `not np.any(f < 0)` term killed by (i), one-sided axis + remove the paired-axis early return killed by (ii), symmetric axis + w[np.abs(f) >= fn] -> w[f >= fn] killed by (iii), fftfreq ordering + + The fourth is the easiest to miss: dropping abs() is a no-op on every production axis, + where the unpaired bin is at +fNyq, and only shows up when it sits at -fNyq. They are + cheap to pin directly, so pin them -- a defensive branch nothing exercises is a branch + that silently rots. + + FRAGILITY: all four of those mutants die on assertions in THIS ONE function, so deleting it + resurrects all four at once. If you split or rename it, keep every sub-case (i)-(iv) -- + each is the only thing standing between one guard clause and a silent regression. + """ + p = 1 # odd: the only parity that touches any of this + + # (i) ONE-SIDED axis (no negative frequencies): nothing is unpaired, so nothing may be + # zeroed. Zeroing here would eat the top of an rfft-style band. + f_one = np.arange(0, 65) * DELTA_F + w = flwr.time_derivative_weight(f_one, p) + assert np.all(w != 0) or np.all(f_one[w == 0] == 0.), ( + "one-sided axis: weight was zeroed at %s, but a one-sided axis has no unpaired " + "Nyquist bin" % (f_one[w == 0],)) + assert np.allclose(w, (flwr.FT_SIGN * 2.0j * np.pi * f_one) ** p), \ + "one-sided axis: weight is not the plain analytic weight" + + # (ii) SYMMETRIC axis (both +fn and -fn present): the extreme bin IS paired, so the + # weight is well defined and both ends must survive. Keying on |f| == max alone would + # blank both ends here -- that is what the paired early return prevents. + f_sym = np.arange(-64, 65) * DELTA_F + w = flwr.time_derivative_weight(f_sym, p) + assert np.count_nonzero(w == 0) == 1 and w[f_sym == 0.][0] == 0., ( + "symmetric axis: %d bins zeroed (only the f=0 bin should vanish, and only because " + "the analytic weight is 0 there)" % np.count_nonzero(w == 0)) + assert np.allclose(w, (flwr.FT_SIGN * 2.0j * np.pi * f_sym) ** p), \ + "symmetric axis: weight is not the plain analytic weight" + + # (iii) FFTFREQ ordering, where the unpaired bin sits at -fNyq rather than +fNyq. This + # is why the mask tests abs(f) and not f: `w[f >= fn] = 0.` finds nothing here. + f_np = np.fft.fftfreq(8, d=1.0 / (8 * DELTA_F)) # [0,1,2,3,-4,-3,-2,-1]*DELTA_F + assert f_np.min() < 0 and f_np.max() < abs(f_np.min()), "fftfreq axis is not -fNyq-heavy" + w = flwr.time_derivative_weight(f_np, p) + nyq = np.abs(f_np) >= np.max(np.abs(f_np)) + assert np.all(w[nyq] == 0), \ + "fftfreq ordering: the unpaired bin at -fNyq was NOT zeroed (mask is not using abs())" + assert np.all(w[~nyq] == ((flwr.FT_SIGN * 2.0j * np.pi * f_np[~nyq]) ** p)), \ + "fftfreq ordering: a paired bin was disturbed" + + # (iv) DEGENERATE axes: too short to have a Nyquist pair at all. Must not zero anything. + for f_deg in (np.array([DELTA_F]), np.array([-DELTA_F])): + w = flwr.time_derivative_weight(f_deg, p) + assert np.all(w == (flwr.FT_SIGN * 2.0j * np.pi * f_deg) ** p), \ + "degenerate axis %s: weight was modified" % (f_deg,) + + print("nyquist guard clauses: one-sided / symmetric / fftfreq / degenerate all correct") + + +def test_rotation_post_phase_is_not_the_identity(): + """rotation_post_phase() against a known answer, because nothing else pins it. + + This helper is the DOCUMENTED convention -- ~20 comments across the likelihood and the jax + port name it as the thing an evaluator must apply to both terms -- but it has exactly one + call site (the scalar evaluator), and production routes through the NoLoop, which inlines + its own copy. Consequence, measured: neutering this function to `return dict(C)` leaves + BOTH the numpy slowrot suite and test/jax/test_jax_slowrot_cauchy_schwarz.py green. The + Cauchy-Schwarz ladder built to guard exactly this fix does not see it, because the ladder + exercises the NoLoop's inline copy. + + So the helper is untested by construction, and it is what a new evaluator would call. Pin + it directly: known values, and an explicit assertion that it MOVES the coefficients at a + physically reachable arrival offset -- the identity is what a dropped post-phase looks like. + """ + omega = 2.0 * np.pi * 1.16e-5 # ~sidereal + delta = 1.02e-2 # 10 ms, the scale of a real geometric arrival offset + C = {(0, 2): 1.0 + 0.0j, (1, -3): 2.0 - 1.0j, (0, 0): 3.0 + 4.0j} + out = flwr.rotation_post_phase(C, omega, delta) + + for a, c in C.items(): + want = c * np.exp(1.0j * a[1] * omega * delta) + assert abs(out[a] - want) <= 1e-15 * max(1.0, abs(want)), ( + "rotation_post_phase wrong at a=%r: got %r want %r" % (a, out[a], want)) + + # n = 0 carries no phase; every n != 0 entry MUST move. Without this the neutered + # `return dict(C)` mutant passes the loop above only if the loop is also neutered, but + # this assertion states the intent independently of the formula. + assert out[(0, 0)] == C[(0, 0)], "n=0 must be untouched" + for a in ((0, 2), (1, -3)): + moved = abs(out[a] - C[a]) / abs(C[a]) + assert moved > 1e-8, ( + "rotation_post_phase left a=%r unchanged (rel move %.2e) -- a post-phase that is " + "the identity at a 10 ms arrival offset is a DROPPED post-phase, which is the " + "defect PR #117 fixed" % (a, moved)) + + # broadcasting: delta may be an array, and the input must not be mutated in place + darr = np.array([0.0, delta]) + outa = flwr.rotation_post_phase(C, omega, darr) + assert np.allclose(outa[(0, 2)], C[(0, 2)] * np.exp(1.0j * 2 * omega * darr)), \ + "rotation_post_phase does not broadcast an array delta" + assert C[(0, 2)] == 1.0 + 0.0j, "rotation_post_phase mutated its input" + + print("rotation_post_phase: known-answer, non-identity, broadcast and purity all hold") + + if __name__ == "__main__": test_roundtrip_identity() test_tone_frequency_assignment_and_FT_SIGN() + test_derivative_commutes_with_conjugation_at_nyquist() + test_nyquist_derivative_value_both_parities() test_time_derivative_exact() test_sidereal_modulation_exact() test_reference_matrix_matches_lal_modulation() + test_nyquist_guard_clauses_on_synthetic_axes() + test_rotation_post_phase_is_not_the_identity() print("ALL FD-PRIMITIVE CHECKS PASSED") diff --git a/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_freqresponse.py b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_freqresponse.py index 5d295859e..86c0ecb58 100644 --- a/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_freqresponse.py +++ b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_freqresponse.py @@ -6,6 +6,10 @@ machine precision, over many random (ra,dec,psi) and H1/L1/V1/K1. KEY CHECK. (B) FREE-SPECTRAL-RANGE STRUCTURE: the single-arm transfer's first null sits at the expected frequency c / (L (1 + a.n)); |F(f)| departs from |F(0)| on the f_FSR scale. + (D) THE UNPAIRED NYQUIST BIN: the response weights must be Hermitian on the grid, which + at the one bin that stands for both +fNyq and -fNyq means REAL -- pinned both as a + consistency property (conj(W h) == W conj(h), which crossTermsV_fr relies on) and by + VALUE (the Hermitian average). See issue #164. (C) IN-BAND MAGNITUDE: fractional response change |F(f)/F(0) - 1| at 1 kHz and 2 kHz for (i) 4-km LIGO and (ii) a 40-km CE arm -- quantifies whether the effect matters in band. @@ -123,6 +127,214 @@ def test_fsr_scale_departure(): # ---- (C) in-band magnitude: LIGO vs CE ---------------------------------------------- +# ---------------------------------------------------------------- (D) the unpaired Nyquist bin +def _rift_fvals(npts, deltaF): + """RIFT two-sided packing, f[k] = deltaF*(npts/2 - k): +fNyq at k=0, no -fNyq.""" + return deltaF * (npts / 2.0 - np.arange(npts)) + + +def _geom(L): + return dict(L=float(L), T=float(L) / lal.C_SI) + + +_NYQ_GEOM = _geom(4000.0) + +# The projection must fire for EVERY geometry and basis size, not just the one that +# happened to expose the bug. --freqresponse-arm-length and --freqresponse-qmax are both +# user-settable (bin/integrate_likelihood_extrinsic_batchmode), and the defect's size at the +# unpaired bin depends strongly on L: |Im W_p|/|W_p| for p = 1..5 is +# L = 4 km 0.9935 0.9853 0.1708 0.9853 0.1708 +# L = 10 km 0.9596 0.9093 0.4162 0.9093 0.4162 +# L = 40 km 0.4655 0.1456 0.9893 0.1456 0.9893 (CE; 47% of |W| there) +# A builder that projects only at (4 km, Qmax=4) passed every check in this file until +# these loops existed. +# (arm length, Qmax, npts) -- npts varies for the same reason: a builder that projects +# only at npts = 16384 passed every check here until the grid size moved too. +_NYQ_CASES = [(4000.0, 4, 16384), (4000.0, 0, 8192), (4000.0, 1, 32768), + (4000.0, 6, 4096), (10000.0, 4, 16384), (40000.0, 2, 32768), + (40000.0, 6, 8192)] + + +def test_unpaired_extreme_bin_predicate(): + """The mask must fire on the RIFT packing and on NOTHING else that is well defined.""" + f = _rift_fvals(16, 1.0) + m = fr.unpaired_extreme_bin(f) + assert m.sum() == 1 and m[0], "RIFT packing: expected exactly bin 0 (%r)" % np.where(m) + for name, axis in [ + ("one-sided band", np.arange(30., 513.)), # top of a band is NOT Nyquist + ("symmetric", np.arange(-4., 5.)), # extreme bin HAS a partner + ("fftfreq order", np.concatenate((np.arange(0., 4.), np.arange(-4., 0.)))), + ("single sample", np.array([7.])), + ("all zero", np.zeros(4))]: + mm = fr.unpaired_extreme_bin(axis) + if name == "fftfreq order": + # -fNyq is the unpaired one there; it must still be found, and only it. + assert mm.sum() == 1 and axis[mm][0] == -4., "%s: got %r" % (name, axis[mm]) + else: + assert not mm.any(), "%s: nothing is unpaired here, but mask flagged %r" % ( + name, axis[mm]) + + +def test_weights_hermitian_on_the_grid(): + """W_p(-f) = conj(W_p(f)) at every PAIRED bin, and real at the unpaired one.""" + for L, Qmax, npts in _NYQ_CASES: + _hermitian_one_case(L, Qmax, npts) + + +def _hermitian_one_case(L, Qmax, npts): + deltaF = 0.25 + f = _rift_fvals(npts, deltaF) + W = fr.finite_size_response_weights(f, _geom(L), Qmax) + k = np.arange(1, npts) # every bin except the self-paired k=0 + for p in range(W.shape[0]): + d = np.max(np.abs(W[p][npts - k] - np.conj(W[p][k]))) + scale = np.max(np.abs(W[p])) + print("L=%6.0f Qmax=%d W_%d: paired-bin Hermiticity %.2e (scale %.2e)" + % (L, Qmax, p, d, scale)) + assert d <= 1e-12 * scale, ( + "W_%d not Hermitian at paired bins (L=%g, Qmax=%d): %g" % (p, L, Qmax, d)) + im = abs(np.imag(W[p][0])) / max(abs(W[p][0]), 1e-300) + print("L=%6.0f Qmax=%d W_%d(+fNyq) = %+.6e %+.6ej |Im|/|W| = %.2e" + % (L, Qmax, p, W[p][0].real, W[p][0].imag, im)) + assert im <= 1e-14, ( + "W_%d is complex at the UNPAIRED Nyquist bin at L=%g, Qmax=%d (|Im|/|W| = %g). " + "That bin stands " + "for both +fNyq and -fNyq, so Hermiticity there means real, and crossTermsV_fr " + "identifies conj(W h) with W conj(h) on the strength of it -- see issue #164" + % (p, L, Qmax, im)) + + +def test_weight_commutes_with_conjugation_at_nyquist(): + """conj(W_p h) == W_p conj(h), the identity crossTermsV_fr is built on. + + CONSISTENCY only: any REAL value at the unpaired bin satisfies this, so read it with + test_nyquist_weight_value_is_the_hermitian_average, which pins the value. + """ + npts, deltaF = 1024, 4.0 + f = _rift_fvals(npts, deltaF) + W = fr.finite_size_response_weights(f, _NYQ_GEOM, 4) + rng = np.random.default_rng(20260819) + h = rng.normal(size=npts) + 1j * rng.normal(size=npts) + h[0] = 3.0 - 1.5j # make the Nyquist bin carry real weight + assert abs(h[0]) > 1e-3 * np.max(np.abs(h)), "vacuous without Nyquist content" + for p in range(W.shape[0]): + # conj in the TIME domain <-> conjugate-and-reverse in this packing (k -> npts-k) + def conj_spec(x): + xc = np.conj(x) + return np.concatenate(([xc[0]], xc[1:][::-1])) + a = conj_spec(W[p] * h) # conj(W h) + b = W[p] * conj_spec(h) # W conj(h) + err = np.max(np.abs(a - b)) / np.max(np.abs(b)) + print("W_%d: conj/weight commutation rel err = %.2e" % (p, err)) + assert err <= 1e-14, ( + "conj(W_%d h) != W_%d conj(h) (rel %g): the unpaired Nyquist bin is not real, " + "so crossTermsV_fr = is not the term it claims -- issue #164" + % (p, p, err)) + + +def _continuum_weights(fvals, geom, Qmax): + """W_p(f) straight from the documented formula, with NO Nyquist projection. + + Independent of the projection logic under test, so it can say what the projection is + allowed to touch. W_0 = 1; W_{1+q} = e^{-i2pi f T} c_q(f) - [q==0]. + + THIS IS A HAND COPY of finite_size_response_weights' formula, and deliberately so: the + value guard's reference comes from the production function itself (evaluated on a + one-sided axis, where the projection declines), so it pins "projected == Re(unprojected)" + and nothing about the unprojected value. This copy is the only thing in the file that + would notice the FORMULA changing -- e.g. flipping the sign of the delay phase passes + every other check here. If the formula is deliberately revised, revise this too, and + read a large "bins changed" count above as formula drift rather than a bad projection. + """ + fvals = np.asarray(fvals, dtype=float) + c = fr.finite_size_c_coeffs(fvals, geom['L'], Qmax) + phase = np.exp(-1j * 2.0 * np.pi * fvals * geom['T']) + W = np.empty((Qmax + 2, fvals.shape[0]), dtype=complex) + W[0] = 1.0 + for q in range(Qmax + 1): + W[1 + q] = phase * c[q] - (1.0 if q == 0 else 0.0) + return W + + +def test_weights_untouched_away_from_the_unpaired_bin(): + """The projection must change the UNPAIRED bin and nothing else, on any axis. + + Without this, a builder that took the real part of EVERY bin -- destroying the entire + response phase -- passes the Hermiticity, commutation and value checks above, because a + wholly real weight is trivially Hermitian and its unpaired bin is trivially its own real + part. Same for one that projects the top of a ONE-SIDED analysis band, which is not a + Nyquist bin at all. Both were live holes until this test existed (issue #164). + """ + cases = [("two-sided RIFT packing", _rift_fvals(4096, 1.0), 1), + ("two-sided, other npts", _rift_fvals(2048, 0.5), 1), + ("two-sided, large npts", _rift_fvals(32768, 0.125), 1), + ("one-sided band", np.arange(30., 1025.), 0), + ("symmetric axis", np.arange(-64., 65.), 0)] + for L, Qmax, _npts in _NYQ_CASES: + for label, f, n_expected in cases: + _scope_one_case("%s L=%g Q=%d" % (label, L, Qmax), f, n_expected, L, Qmax) + + +def _scope_one_case(label, f, n_expected, L, Qmax): + if True: + W = fr.finite_size_response_weights(f, _geom(L), Qmax) + ref = _continuum_weights(f, _geom(L), Qmax) + changed = np.where(np.any(np.abs(W - ref) > 0, axis=0))[0] + print("%-24s bins changed by the projection: %d (expected %d)" + % (label, changed.size, n_expected)) + assert changed.size == n_expected, ( + "%s: projection touched %d bins (f = %r), expected %d.\n" + " A SMALL excess means the projection over-reached -- it must change only a " + "genuinely unpaired extreme bin (issue #164).\n" + " A LARGE excess (most/all bins) instead means the PRODUCTION FORMULA moved " + "away from _continuum_weights below, which is a hand copy of it; fix the copy " + "or the formula, not the projection." + % (label, changed.size, f[changed][:8], n_expected)) + if n_expected: + assert changed[0] == 0 and f[0] == np.max(np.abs(f)), ( + "%s: the changed bin is not +fNyq" % label) + # and it changed by exactly dropping the imaginary part + assert np.max(np.abs(W[:, 0] - ref[:, 0].real)) <= 1e-300 + 1e-14 * np.max( + np.abs(ref[:, 0])), "%s: unpaired bin is not Re(continuum)" % label + + +def test_nyquist_weight_value_is_the_hermitian_average(): + """PIN THE VALUE: the unpaired bin must be Re W_p(+fNyq), not merely some real number. + + The reference is the UNPROJECTED continuum weight, obtained by evaluating on a + one-sided axis (where unpaired_extreme_bin correctly declines to touch anything, since + the top of a one-sided band is not a Nyquist bin). Zeroing the bin, or taking |W|, or + any other real value, fails here while passing the commutation test above. + """ + for L, Qmax, npts in _NYQ_CASES: + _value_one_case(L, Qmax, npts) + + +def _value_one_case(L, Qmax, npts): + deltaF = 0.25 + f = _rift_fvals(npts, deltaF) + fnyq = deltaF * npts / 2.0 + geom = _geom(L) + W = fr.finite_size_response_weights(f, geom, Qmax) + + one_sided = np.array([1.0, 10.0, 100.0, fnyq]) # positive only -> no projection + assert not fr.unpaired_extreme_bin(one_sided).any() + W_cont = fr.finite_size_response_weights(one_sided, geom, Qmax)[:, -1] + + for p in range(W.shape[0]): + want = 0.5 * (W_cont[p] + np.conj(W_cont[p])) # the Hermitian average, = Re + got = W[p][0] + d = abs(got - want) / max(abs(W_cont[p]), 1e-300) + print("L=%6.0f Qmax=%d W_%d(+fNyq): got %+.9e want Re = %+.9e " + "(|W_cont| = %.3e, rel %.2e)" + % (L, Qmax, p, got.real, want.real, abs(W_cont[p]), d)) + assert d <= 1e-14, ( + "W_%d at the unpaired Nyquist bin (L=%g, Qmax=%d) is %r, not the Hermitian " + "average %r of the continuum weight. Any real value passes the commutation " + "check; only this one is the response the grid's real (-1)^j Nyquist mode " + "actually sees -- see #164" % (p, L, Qmax, got, want)) + + def _fractional_change(det, L, freqs, n_sky=4000): """Median-over-sky of complex |F(f)/F(0)-1| AND amplitude-only ||F(f)|-|F(0)||/|F(0)|, excluding sky positions near antenna-pattern nulls (|F(0)|<0.3) where the ratio blows @@ -186,6 +398,11 @@ def test_ce_is_100x_longer_effect(): if __name__ == "__main__": + test_unpaired_extreme_bin_predicate() + test_weights_hermitian_on_the_grid() + test_weight_commutes_with_conjugation_at_nyquist() + test_weights_untouched_away_from_the_unpaired_bin() + test_nyquist_weight_value_is_the_hermitian_average() print("=" * 78) wA = test_long_wavelength_limit_matches_lal() test_zero_frequency_is_real() diff --git a/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_freqresponse_gpu.py b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_freqresponse_gpu.py index 22e9dcf11..0f9d7a090 100644 --- a/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_freqresponse_gpu.py +++ b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_freqresponse_gpu.py @@ -23,6 +23,8 @@ if not getattr(fl, "numba_on", True): fl.lalylm = np.vectorize(lal.SpinWeightedSphericalHarmonic, otypes=[complex]) +from RIFT.likelihood._gpu_test_support import skip_without_gpu + try: import cupy _ = cupy.array(1.0) + 1.0 # force a real device op @@ -67,7 +69,7 @@ def _to_gpu(rho_by_p, U_by_pp, V_by_pp): def test_gpu_matches_cpu(): if not HAVE_GPU: - print("(GPU) SKIPPED: cupy/GPU unavailable (%s)" % _WHY); return + if skip_without_gpu(HAVE_GPU, _WHY): return bk = flfr.PrecomputeLikelihoodTermsFreqResponse( event_time, t_window, Psig, data_dict, psd_dict, Lmax, fmax, Qmax=Qmax, L_arm=L_CE, analyticPSD_Q=True, verbose=False, quiet=True, @@ -76,7 +78,7 @@ def test_gpu_matches_cpu(): lk, rbp, ubp, vbp, ep = flfr.pack_freqresponse_arrays(bk[4], bk[3], bk[1], bk[2]) Pv = _P_vec() tvals = np.arange(int(2 * 0.03 / deltaT)) * deltaT - 0.03 - for interp in ('nearest', 'cubic'): + for interp in ('nearest', 'cubic', 'sinc'): lnL_cpu = flfr.DiscreteFactoredLogLikelihoodFreqResponseNoLoop( tvals, Pv, meta, lk, rbp, ubp, vbp, ep, Lmax=Lmax, time_interp=interp, xpy=np) rG, uG, vG = _to_gpu(rbp, ubp, vbp) diff --git a/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_gpu.py b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_gpu.py index af0cd645b..3267abf7e 100644 --- a/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_gpu.py +++ b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_gpu.py @@ -21,6 +21,8 @@ if not getattr(fl, "numba_on", True): fl.lalylm = np.vectorize(lal.SpinWeightedSphericalHarmonic, otypes=[complex]) +from RIFT.likelihood._gpu_test_support import skip_without_gpu + try: import cupy _ = cupy.array(1.0) + 1.0 # force a real device op @@ -65,7 +67,7 @@ def _to_gpu(rho_by_a, U_by_aa, V_by_aa): def test_gpu_matches_cpu(): if not HAVE_GPU: - print("(GPU) SKIPPED: cupy/GPU unavailable (%s)" % _WHY); return + if skip_without_gpu(HAVE_GPU, _WHY): return ri, ct, ctV, rho, meta = flwr.PrecomputeLikelihoodTermsWithRotation( event_time, t_window, Psig, data_dict, psd_dict, Lmax, fmax, harmonics=HARM, p_max=0, f_sidereal=flwr.F_SIDEREAL, analyticPSD_Q=True, @@ -73,7 +75,7 @@ def test_gpu_matches_cpu(): lk, rbn, ubn, vbn, ep = flwr.pack_rotation_arrays(meta, rho, ct, ctV) Pv = _P_vec() tvals = np.arange(int(2 * 0.03 / deltaT)) * deltaT - 0.03 - for interp in ('nearest', 'cubic'): + for interp in ('nearest', 'cubic', 'sinc'): lnL_cpu = flwr.DiscreteFactoredLogLikelihoodViaArrayVectorNoLoopWithRotation( tvals, Pv, meta, lk, rbn, ubn, vbn, ep, Lmax=Lmax, time_interp=interp, xpy=np) rG, uG, vG = _to_gpu(rbn, ubn, vbn) diff --git a/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_harmonic_width.py b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_harmonic_width.py new file mode 100644 index 000000000..1a74d0ca1 --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_harmonic_width.py @@ -0,0 +1,254 @@ +""" +test_slowrot_harmonic_width : the precompute must carry EVERY harmonic the response +coefficients populate (issue #142). + +`rotation_coefficients` builds C_{(p,ntilde)} by convolving the antenna harmonics +(|n| <= 2) with the delay-drift harmonics (|m| <= 1) once per derivative order, so the +harmonic index widens by exactly one per order and the bank needs |ntilde| <= 2 + p_max. +`PrecomputeLikelihoodTermsWithRotation` builds one elementary-template band per requested +harmonic, and a coefficient with no band is dropped WITHOUT COMPLAINT by both maintained +evaluators (the NoLoop's Cg/Cg_d return zero for a missing `a`; the JAX packer in +jax_ile.banded packs only `a_list`). A too-narrow `harmonics` therefore used to yield a +quietly truncated model. + +Checks: + W0 the antenna / delay half-widths the module hard-codes are the ones slowrot_response + actually produces (so N_ANTENNA_HARMONICS / N_DELAY_HARMONICS cannot drift silently) + W1 the measured index set of rotation_coefficients (and _vector) is exactly + -(2+p_max) .. +(2+p_max), for p_max = 0..3 -- i.e. required_harmonic_width is + MEASURED, not asserted + W2 a too-narrow request is widened, and says so (RuntimeWarning naming the width) + W3 the resulting bank drops NO response coefficient -- the property that matters, and + the one that is evaluator-independent + W4 the control: with widen_harmonics=False (the pre-fix behaviour) the same request DOES + drop coefficients, is flagged meta['harmonics_truncated'], and moves lnL. Without + this, W2/W3 would be guards nobody has seen fail. + W5 the JAX packer: every key jax_ile.response_slowrot produces has a band, and + jax_ile.banded.build_rotation_data itself packs the full widened bank and refuses to + accept a truncated one in silence + W6 the MAINTAINED evaluator: pack_rotation_arrays + the vectorized NoLoop. The fix + changes what that path receives (|a_list| 10 -> 14 for the default request at + p_max=1), so the widened bank must run through it and must move lnL relative to the + truncated one; and packing a truncated bank must warn rather than evaluate quietly. + +Run: PYTHONPATH=.../Code python RIFT/likelihood/test_slowrot_harmonic_width.py +""" +from __future__ import print_function, division + +import warnings + +import numpy as np +import lal +import lalsimulation as lalsim + +import RIFT.lalsimutils as lsu +import RIFT.likelihood.factored_likelihood_with_rotation as flwr +import RIFT.likelihood.slowrot_response as srr + +fmin = 30.; fmax = 1700.; event_time = 1e9; t_window = 0.1; Lmax = 2 +deltaT = 1 / 4096.; deltaF = 1 / 4. +DET = 'H1' +P_MAX = 1 # the first p_max at which the (-2..2) default is too narrow +NARROW = (-2, -1, 0, 1, 2) # the module default: the p_max=0 answer +# Truncation must move lnL by at least this much. Measured on this configuration: +# 7.66e+03 nats (scalar) / 7.48e+03 nats (NoLoop), so this is ~3.5 orders of margin -- far +# above float noise, and it asserts the truncation is MATERIAL, not merely nonzero. +DLNL_MIN = 1.0 + +Psig = lsu.ChooseWaveformParams( + fmin=fmin, radec=True, incl=0.3, phiref=0.0, theta=0.2, phi=1.0, psi=0.4, + m1=30 * lal.MSUN_SI, m2=25 * lal.MSUN_SI, detector=DET, + dist=200e6 * lal.PC_SI, deltaT=deltaT, tref=event_time, deltaF=deltaF) +data_dict = {DET: lsu.non_herm_hoff(Psig)} +psd_dict = {DET: lalsim.SimNoisePSDaLIGOZeroDetHighPower} + +extr = lsu.ChooseWaveformParams(radec=True, phi=1.0, theta=0.2, psi=0.4, incl=0.3, + phiref=0.0, tref=event_time, dist=200e6 * lal.PC_SI) + +_BANKS = {} + + +def _bank(widen): + """Precompute with the NARROW default request; widen=False is the pre-fix behaviour.""" + if widen not in _BANKS: + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + rr = flwr.PrecomputeLikelihoodTermsWithRotation( + event_time, t_window, Psig, data_dict, psd_dict, Lmax, fmax, + harmonics=NARROW, p_max=P_MAX, f_sidereal=flwr.F_SIDEREAL, + analyticPSD_Q=True, verbose=False, quiet=True, + skip_interpolation=False, widen_harmonics=widen) + msgs = [str(c.message) for c in caught + if issubclass(c.category, RuntimeWarning) and 'harmonics' in str(c.message)] + _BANKS[widen] = (rr, msgs) + return _BANKS[widen] + + +def _coef_keys(p_max): + """Every (p, ntilde) the response coefficients actually populate at these extrinsics.""" + return set(flwr.rotation_coefficients(DET, extr.phi, extr.theta, extr.psi, + event_time, p_max)) + + +def _lnL(rr): + return float(flwr.FactoredLogLikelihoodWithRotation(extr, rr[0], rr[1], rr[2], rr[4], Lmax)) + + +def _lnL_noloop(rr): + """Same bank through the MAINTAINED vectorized path. Returns (lnL, warning messages).""" + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + lk, ra, cu, cv, ep = flwr.pack_rotation_arrays(rr[4], rr[3], rr[1], rr[2]) + msgs = [str(c.message) for c in caught + if issubclass(c.category, RuntimeWarning) and 'pack_rotation_arrays' in str(c.message)] + Pv = Psig.manual_copy() + for k, v in [('phi', extr.phi), ('theta', extr.theta), ('incl', extr.incl), + ('phiref', extr.phiref), ('psi', extr.psi), ('dist', extr.dist)]: + setattr(Pv, k, np.ones(1) * v) + Pv.tref = event_time; Pv.deltaT = deltaT + tvals = np.arange(200) * deltaT - 0.01 + out = flwr.DiscreteFactoredLogLikelihoodViaArrayVectorNoLoopWithRotation( + tvals, Pv, rr[4], lk, ra, cu, cv, ep, Lmax=Lmax, array_output=False, xpy=np) + return float(out[0]), msgs + + +# --------------------------------------------------------------------------- +def test_W0_antenna_and_delay_half_widths(): + lald = lalsim.DetectorPrefixToLALDetector(DET) + A = srr.antenna_harmonics(lald.response, 0.2, 0.5) + B = srr.delay_harmonics(lald.location, 0.2) + wA = max(abs(int(n)) for n in A) + wB = max(abs(int(m)) for m in B) + print("W0 antenna half-width=%d (module says %d) delay half-width=%d (module says %d)" + % (wA, flwr.N_ANTENNA_HARMONICS, wB, flwr.N_DELAY_HARMONICS)) + assert wA == flwr.N_ANTENNA_HARMONICS, \ + "antenna half-width drifted: %d vs N_ANTENNA_HARMONICS=%d" % (wA, flwr.N_ANTENNA_HARMONICS) + assert wB == flwr.N_DELAY_HARMONICS, \ + "delay half-width drifted: %d vs N_DELAY_HARMONICS=%d" % (wB, flwr.N_DELAY_HARMONICS) + + +def test_W1_required_width_is_measured(): + for p_max in (0, 1, 2, 3): + C = flwr.rotation_coefficients(DET, 1.0, 0.2, 0.5, event_time, p_max) + ns = sorted(set(n for (_, n) in C)) + Cv = flwr.rotation_coefficients_vector(DET, np.array([1.0]), np.array([0.2]), + np.array([0.5]), event_time, p_max) + nsv = sorted(set(n for (_, n) in Cv)) + w = flwr.required_harmonic_width(p_max) + print("W1 p_max=%d -> harmonic indices %s ; required_harmonic_width=%d" % (p_max, ns, w)) + assert ns == nsv, "scalar/vector coefficient index sets disagree: %s vs %s" % (ns, nsv) + assert ns == list(range(-w, w + 1)), \ + "required_harmonic_width(%d)=%d does not match the measured index set %s" % (p_max, w, ns) + + +def test_W2_narrow_request_is_widened_and_says_so(): + rr, msgs = _bank(True) + meta = rr[4] + w = flwr.required_harmonic_width(P_MAX) + print("W2 requested=%s -> carried=%s (required half-width %d); warning: %s" + % (meta['harmonics_requested'], meta['harmonics'], w, + msgs[0] if msgs else "NONE")) + assert meta['harmonics_requested'] == NARROW + assert set(range(-w, w + 1)).issubset(set(meta['harmonics'])), \ + "bank still too narrow: %s" % (meta['harmonics'],) + assert meta['harmonics_required'] == w + assert meta['harmonics_truncated'] is False + assert meta['harmonics'] == tuple(sorted(set(NARROW) | set(range(-w, w + 1)))), \ + "widened set is not the union of the request with the required range: %s" % (meta['harmonics'],) + assert msgs, "widening happened silently -- no RuntimeWarning was raised" + # not `str(w) in msgs[0]`: "3" also appears in "p_max=1" arithmetic and in "(-3, -2, ...". + assert ("2 + p_max = %d" % w) in msgs[0], \ + "the warning does not name the required width as such: %s" % msgs[0] + + +def test_W3_widened_bank_drops_no_coefficient(): + rr, _ = _bank(True) + a_list = set(rr[4]['a_list']) + missing = sorted(_coef_keys(P_MAX) - a_list) + print("W3 widened bank: |a_list|=%d, response coefficients with no band: %s" + % (len(a_list), missing)) + assert not missing, \ + "response coefficients %s have no elementary-template band and will be dropped" % (missing,) + + +def test_W4_control_narrow_bank_really_does_truncate(): + """The guard above is only worth something if it can fail. widen_harmonics=False is + the pre-fix behaviour, in-tree: it must drop coefficients, flag itself, and move lnL.""" + rr_n, msgs_n = _bank(False) + rr_w, _ = _bank(True) + a_list = set(rr_n[4]['a_list']) + missing = sorted(_coef_keys(P_MAX) - a_list) + lnL_n, lnL_w = _lnL(rr_n), _lnL(rr_w) + print("W4 narrow bank: |a_list|=%d, dropped %s, truncated=%s" + % (len(a_list), missing, rr_n[4]['harmonics_truncated'])) + print("W4 lnL narrow=%.9f widened=%.9f dlnL=%+.6e nats" % (lnL_n, lnL_w, lnL_w - lnL_n)) + assert missing, "widen_harmonics=False did not truncate -- W3 cannot fail, so it proves nothing" + assert rr_n[4]['harmonics_truncated'] is True, "truncation was not recorded in meta" + assert not msgs_n, "widen_harmonics=False should not warn about widening it did not do" + assert abs(lnL_w - lnL_n) > DLNL_MIN, \ + "truncation moved lnL by only %.3e nats -- W4 is not exercising the bug" % abs(lnL_w - lnL_n) + + +def test_W5_jax_packer_loses_nothing(): + try: + import jax # noqa: F401 + except ImportError: + print("W5 SKIPPED (no jax)") + return + import RIFT.likelihood.jax_ile.response_slowrot as jrs + rr, _ = _bank(True) + a_list = [(int(p), int(n)) for (p, n) in rr[4]['a_list']] + lald = lalsim.DetectorPrefixToLALDetector(DET) + gmst = float(lal.GreenwichMeanSiderealTime(lal.LIGOTimeGPS(float(event_time)))) + cdict = jrs.rotation_coefficients_dict( + np.asarray(lald.response), np.asarray(lald.location), + np.array([extr.phi]), np.array([extr.theta]), np.array([extr.psi]), gmst, P_MAX) + missing = sorted(set((int(p), int(n)) for (p, n) in cdict) - set(a_list)) + print("W5 jax coefficient keys with no band in a_list: %s" % (missing,)) + assert not missing, "the JAX packer would silently drop %s" % (missing,) + + # ...and go through the real packer, which is where the drop would happen. + from RIFT.likelihood.jax_ile.banded import build_rotation_data + tvals = np.arange(200) * deltaT - 0.01 + for widen, want_warn in ((True, False), (False, True)): + b = _bank(widen)[0] + lk, ra, cu, cv, ep = flwr.pack_rotation_arrays(b[4], b[3], b[1], b[2]) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + data = build_rotation_data(b[4], lk, ra, cu, cv, ep, deltaT, tvals) + got = [str(c.message) for c in caught + if issubclass(c.category, RuntimeWarning) and 'build_rotation_data' in str(c.message)] + print("W5 jax packer, widen=%s: A=%d bands, warned=%s" + % (widen, len(data.band['a_list']), bool(got))) + assert len(data.band['a_list']) == len(b[4]['a_list']) + assert bool(got) is want_warn, \ + "build_rotation_data warning: got %r, wanted %r (widen=%s)" % (bool(got), want_warn, widen) + + +def test_W6_maintained_noloop_path(): + """The fix changes what the NoLoop is handed; run it, and make the truncated bank + announce itself at the packer instead of evaluating a short model in silence.""" + rr_w, _ = _bank(True) + rr_n, _ = _bank(False) + lnL_w, msgs_w = _lnL_noloop(rr_w) + lnL_n, msgs_n = _lnL_noloop(rr_n) + print("W6 NoLoop |a_list| widened=%d narrow=%d" % (len(rr_w[4]['a_list']), len(rr_n[4]['a_list']))) + print("W6 NoLoop lnL widened=%.9f narrow=%.9f dlnL=%+.6e nats" % (lnL_w, lnL_n, lnL_w - lnL_n)) + print("W6 packer warning on the truncated bank: %s" % (msgs_n[0] if msgs_n else "NONE")) + assert np.isfinite(lnL_w), "the widened bank does not evaluate through the NoLoop: %r" % lnL_w + assert not msgs_w, "the widened bank must not warn at the packer: %s" % msgs_w + assert abs(lnL_w - lnL_n) > DLNL_MIN, \ + "truncation is invisible to the MAINTAINED path (dlnL=%.3e)" % abs(lnL_w - lnL_n) + assert msgs_n, "pack_rotation_arrays accepted a truncated bank silently" + assert 'TRUNCATED' in msgs_n[0], "the packer warning does not say the model is truncated: %s" % msgs_n[0] + + +if __name__ == "__main__": + test_W0_antenna_and_delay_half_widths() + test_W1_required_width_is_measured() + test_W2_narrow_request_is_widened_and_says_so() + test_W3_widened_bank_drops_no_coefficient() + test_W4_control_narrow_bank_really_does_truncate() + test_W5_jax_packer_loses_nothing() + test_W6_maintained_noloop_path() + print("ALL SLOWROT HARMONIC-WIDTH CHECKS PASSED") diff --git a/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_likelihood_v1.py b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_likelihood_v1.py index abb2adb0f..bfe1c92c7 100644 --- a/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_likelihood_v1.py +++ b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_likelihood_v1.py @@ -11,11 +11,28 @@ V1a (assembly algebra): with f_sidereal -> 0 the rotation lnL must equal the baseline FactoredLogLikelihood exactly (all modulations become identity, sum_n A_tilde_n -> F(tref)). This validates the whole harmonic contraction incl. the V-term's A_{-nu}. - V1b (rotation physics): with the real sidereal rate, the rotation lnL must equal a - brute-force Path-R likelihood that applies the FULL time-varying antenna pattern - F_k(t) (sampled from lal.ComputeDetAMResponse, independent of the A_n harmonic - decomposition) directly to the data (term1) and to the modes (term2). This validates - that Q^{(n)} is paired with the correct conj(A_tilde_n), i.e. the physics. + V1b (harmonic decomposition): with the real sidereal rate, the rotation lnL must agree + with a brute-force Path-R likelihood that applies the FULL time-varying antenna + pattern F_k(t), sampled from lal.ComputeDetAMResponse and so independent of the A_n + harmonic decomposition. That is what V1b validates: that the 5-harmonic expansion + reproduces the true F_k(t), and that Q^{(n)} is paired with the right conj(A_tilde_n). + + READ THIS BEFORE TRUSTING V1b FOR ANYTHING ELSE. Its reference is NOT + convention-free: _pathR_lnL pushes the modulation onto the data for term1 + (conj(F) * d, an identity that fails for a noise-weighted overlap) and samples F for + the modes with the template pinned at event_time for term2 (no arrival-time + post-phase). Those are exactly the two mistakes that once made this likelihood + exceed 0.5; a reference that shares them cannot detect them. V1b is therefore + blind to the post-phase, and its tolerance (1e-4 of |lnL|, i.e. ~0.6 nats here) is far + too loose to notice. Since the post-phase was restored, V1b reads |diff| ~ 1.3e-3 + rather than the ~3e-9 it read while both sides were wrong -- that gap IS the + post-phase plus the term1 commutator, not drift. + + What actually guards this: the Cauchy-Schwarz assertion on the scalar path in + test_slowrot_pathB.py, and, for the maintained NoLoop, test_slowrot_cauchy_schwarz.py + plus the rewritten convention-free reference in test_slowrot_noloop_bruteforce.py. + The scalar entry point here is non-preferred -- production routes through the NoLoop + -- so this reference was deliberately NOT rebuilt. """ from __future__ import print_function, division diff --git a/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_noloop_bruteforce.py b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_noloop_bruteforce.py index 675d4cf44..8396447fe 100644 --- a/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_noloop_bruteforce.py +++ b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_noloop_bruteforce.py @@ -1,12 +1,18 @@ """ test_slowrot_noloop_bruteforce : the definitive rotation-physics validation. -The vectorized rotation NoLoop lnL_t (real sidereal rate) is compared, over the full -time window, to an INDEPENDENT brute-force likelihood that applies the true time-varying -antenna pattern F_k(t) -- sampled directly from lal.ComputeDetAMResponse -- to the data -(term1) and to the modes (term2), reusing RIFT's own overlaps. This confirms both that -the vectorized harmonic contraction is correct AND that the (large, at high SNR) shift the -sidereal rotation induces in the marginalized lnL is genuine physics, not an artifact. +The vectorized rotation NoLoop lnL_t (real sidereal rate) is compared, over the full time +window, to an INDEPENDENT brute-force likelihood that builds the real detector strain + h(t') = Re[ F(t') * sum_lm Y_lm h_lm(t' - t_arr) ], F from lal.ComputeDetAMResponse, +explicitly in the time domain at every arrival sample, and takes BOTH inner products of that +one series. This confirms that the vectorized harmonic contraction is correct AND that the +(large, at high SNR) shift the sidereal rotation induces in the marginalized lnL is genuine +physics, not an artifact. + +The reference deliberately shares NO convention with the implementation -- it never pushes the +modulation onto the data and never pins the template's arrival time -- so, unlike the version +this replaced, it cannot agree with a broken likelihood by making the same mistake. See +test_slowrot_cauchy_schwarz.py and rotation_post_phase() for what that used to hide. Run: source ~/RIFT_develUWM/bin/activate; PYTHONPATH=~/RIFT_slowrot/MonteCarloMarginalizeCode/Code python @@ -38,36 +44,41 @@ def Fsample(det,epoch,n,dt): Ylms=fl.ComputeYlms(Lmax,INCL,-PHIREF,selected_modes=list(hlms.keys())) distMpc=DIST/(lsu.lsu_PC*1e6);invD=fl.distMpcRef/distMpc npts=400 +# CONVENTION-FREE REFERENCE. An earlier version of this brute force pushed the F(t) modulation +# onto the DATA for term1 ( == ) and evaluated term2 for the template pinned +# at event_time. Both shortcuts are exactly the ones the likelihood used to take, so the test +# agreed to 3e-10 while BOTH were wrong -- the failure mode SLOWROT_HANDOFF.md calls out as the +# critical lesson. The first identity holds only for the UNWEIGHTED overlap (a frequency shift +# does not commute with the 1/S(f) band weight); the second drops the arrival-time post-phase +# exp(i n Omega (t-tref)). +# +# So this reference now shares nothing with the implementation: it builds the real strain +# h(t') = invD * Re[ F(t') * hY(t' - t_arr) ] +# in the time domain at EACH arrival sample and takes both inner products of that one series. +# It is therefore a genuine Cauchy-Schwarz-respecting likelihood by construction. def bf_lnLt(det): data=data_dict[det];psd=psd_dict[det];n=data.data.length;dt=1./(n*data.deltaF) t_det=fl.ComputeArrivalTimeAtDetector(det,RA,DEC,event_time) rho_epoch=data.epoch-hlms[list(hlms.keys())[0]].epoch - t_shift=float(float(t_det)-float(t_window)-float(rho_epoch));N_shift=int(t_shift/deltaT+0.5);N_window=int(2*t_window/deltaT) - tgrid=np.arange(N_window)*deltaT+float(rho_epoch+N_shift*deltaT) - Fd=Fsample(det,float(data.epoch),n,dt);dtd=to_td(data) - df=lal.CreateCOMPLEX16TimeSeries("dF",data.epoch,0.,dt,lal.DimensionlessUnit,n);df.data.data[:]=np.conj(Fd)*dtd.data.data - rr=fl.ComputeModeIPTimeSeries(hlms,lsu.DataFourier(df),psd,fmin,fmax,fNyq,N_shift,N_window,True,False,0.) - ri=fl.InterpolateRholms(rr,tgrid,verbose=False) - modes=list(ri.keys()) - # window aligned like NoLoop - ifirst=int(round((float(t_det)-0.02-float(rr[list(rr.keys())[0]].epoch))/deltaT)+0.5) - tsel=np.array([float(rr[list(rr.keys())[0]].epoch)+(ifirst+j)*deltaT for j in range(npts)]) - term1=np.zeros(npts,dtype=complex) - for m in modes: - term1+=np.conj(Ylms[m])*np.array([ri[m](tt) for tt in tsel]) - term1=term1.real*invD + t_shift=float(float(t_det)-float(t_window)-float(rho_epoch));N_shift=int(t_shift/deltaT+0.5) + rr_epoch=float(rho_epoch)+N_shift*deltaT + # The arrival samples the NoLoop lands on, as array shifts of the template against the data. + # The origin is rho_epoch = data.epoch - hlms.epoch, NOT event_time: the data and the modes + # are generated separately here and their epochs differ by ~0.36 s, so index m of the data + # holds intrinsic template time (m + (rho_epoch - t_arr)/deltaT). + ifirst=int(round((float(t_det)-0.02-rr_epoch)/deltaT)+0.5) + kvals=[N_shift+ifirst+j for j in range(npts)] + Fd=Fsample(det,float(data.epoch),n,dt) # F(t') on the absolute data time axis + hY=np.zeros(n,dtype=complex) + for m in hlms: hY+=Ylms[m]*np.array(to_td(hlms[m]).data.data) IP=lsu.ComplexIP(fmin,fmax,fNyq,data.deltaF,psd,True,False,0.) - modF={};modC={} - for m in modes: - htd=to_td(hlms[m]);Fm=Fsample(det,event_time+float(hlms[m].epoch),hlms[m].data.length,dt) - pr=lal.CreateCOMPLEX16TimeSeries("Fh",hlms[m].epoch,0.,dt,lal.DimensionlessUnit,hlms[m].data.length);pr.data.data[:]=Fm*htd.data.data;modF[m]=lsu.DataFourier(pr) - pc=lal.CreateCOMPLEX16TimeSeries("Fc",hlms[m].epoch,0.,dt,lal.DimensionlessUnit,hlms[m].data.length);pc.data.data[:]=np.conj(Fm*htd.data.data);modC[m]=lsu.DataFourier(pc) - t2=0j - for p1 in modes: - for p2 in modes: - t2+=IP.ip(modF[p1],modF[p2])*np.conj(Ylms[p1])*Ylms[p2]+IP.ip(modC[p1],modF[p2])*Ylms[p1]*Ylms[p2] - t2=-t2.real/4./(distMpc/fl.distMpcRef)**2 - return term1+t2 + out=np.zeros(npts) + for j,k in enumerate(kvals): + hs=lal.CreateCOMPLEX16TimeSeries("h",data.epoch,0.,dt,lal.DimensionlessUnit,n) + hs.data.data[:]=np.real(Fd*np.roll(hY,k))*invD + hf=lsu.DataFourier(hs) + out[j]=IP.ip(hf,data).real-0.5*IP.ip(hf,hf).real + return out bf=sum(bf_lnLt(det) for det in data_dict) m=np.max(bf);bf_marg=m+np.log(np.trapz(np.exp(bf-m),dx=deltaT)) # ---- vec rotation, real Omega, same window ---- diff --git a/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_pathB_bruteforce.py b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_pathB_bruteforce.py index 262a552a3..e9e84176c 100644 --- a/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_pathB_bruteforce.py +++ b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_pathB_bruteforce.py @@ -5,9 +5,20 @@ import RIFT.likelihood.factored_likelihood as fl import RIFT.likelihood.factored_likelihood_with_rotation as flwr import RIFT.likelihood.slowrot_response as srr -event_time=1e9; Lmax=2; t_window=0.1; det='H1' -psd=lalsim.SimNoisePSDaLIGOZeroDetHighPower; apx=lalsim.GetApproximantFromString("IMRPhenomD") import os +event_time=1e9; Lmax=2; det='H1' +# t_window sets how much Q^a_lm(t) the precompute retains, and therefore CAPS the lnL(t) +# scan: NWMS+GUARDMS above ~t_window/2 overruns the Q buffer with a broadcast error (the guard +# samples of the peak estimator are ordinary lnL(t) samples and count against the same buffer). +# Raise them together. Cost is small -- the precompute is dominated by FFTs of length N, not by +# the retained window. +t_window=float(os.environ.get("TWIN","0.1")) +psd=lalsim.SimNoisePSDaLIGOZeroDetHighPower; # APPROX: default IMRPhenomD is an FD model, which routes through hlmoft_FromFD_dict -> +# SimInspiralTDModesFromPolarizations and inherits LAL's minimal post-ringdown pad (~9 ms +# after the peak), bypassing RIFT's own fd_centering_factor=0.9 (which would reserve 10% of +# the segment). That 9 ms is SHORTER than the Earth light-crossing delay, so the delayed +# lookup clips the loudest samples. Use a TD model (TaylorT4) for development. +apx=lalsim.GetApproximantFromString(os.environ.get("APPROX","IMRPhenomD")) OMEGA_INF=flwr.OMEGA_EARTH*float(os.environ.get("INFL","340")); FSID_INF=OMEGA_INF/(2*np.pi) def _ifft(hf_d): o={} @@ -23,31 +34,221 @@ def _to_fd(re,epoch,dt,N): def _peak(lt): lt=np.asarray(lt,float); x=np.arange(len(lt)); sp=InterpolatedUnivariateSpline(x,lt,k=4) xs=np.linspace(0,len(lt)-1,len(lt)*32); return float(np.max(sp(xs))) -fmin,fmax,deltaT,seglen=25.,512.,1/2048.,16.; deltaF=1./seglen; fNyq=1/2./deltaT; N=int(round(seglen/deltaT)) +def _peak_bandlimited(lt,guard,upsample=64,band_frac=0.5): + """Peak of lnL(t) over the REQUESTED scan interval, by guarded Whittaker-Shannon interpolation. + + lt holds guard + n_scan + guard uniformly spaced samples; the maximum is taken over the MIDDLE + n_scan samples only, and the outer `guard` samples enter solely as reconstruction support. + `guard` has NO default on purpose: an unguarded call is the failure mode below, and it should + be impossible to make one by accident. + + NOT a periodic (zero-padded FFT) interpolation. lnL(t) restricted to a scan window is not + periodic on that window, and an earlier revision of this routine removed a LINE through the + endpoints before an FFT and called the result exact. It is not: that detrend perturbs even a + sinusoid the n-point DFT represents exactly, and the Gibbs ringing on the reintroduced ramp + OVERSHOOTS the true maximum (~1.6% of amplitude on cos(2*pi*0.1*x+0.37) with n=80). That is + enough to fake a negative Cauchy-Schwarz deficit -- the artefact this estimator exists to + remove -- so any reported deficit taken with it is not trustworthy. + + lnL(t) = Re[sum_a conj(C_a) sum_lm conj(Ylm) Q^a_lm(t)] + term2, term2 is constant in t, and + every Q^a_lm(t) is the inverse transform of something supported on [fmin,fmax]. So lnL(t) is + band-limited to fmax and the Whittaker-Shannon series over the sampled grid reconstructs it. + Two refinements keep the TRUNCATED series accurate away from the array ends: + * the constant pedestal is removed before the sum and added back after. A constant is + reproduced exactly, so this is not an approximation; it just leaves a residual that has + decayed at the window edges, which is what the truncation error sees. + * band_frac = fmax*deltaT < 1/2 means the grid is oversampled, and that freedom buys a + Fourier-tapered kernel sinc(u)*sinc(beta*u), beta = 1-2*band_frac, whose transform is + still exactly 1 on |f| <= fmax and 0 below the first alias, but which decays like 1/u^2 + instead of 1/u. band_frac=1/2 (the default) gives the plain sinc: always valid, slower + decay, so more guard is needed for the same accuracy. + The value returned is one the reconstruction actually takes on a fine grid, so it cannot + overshoot except by that truncation error -- which is small only while the dropped samples sit + near the pedestal. Check that with _peak_edge_residual; nothing here can rescue a peak that + sits on the edge of the scan window. + """ + lt=np.asarray(lt,float); n=lt.size; lo=int(guard); hi=n-1-int(guard) + if n<4 or hi<=lo: return float(np.max(lt)) + nu=min(max(float(band_frac),0.),0.5); beta=max(0.,1.-2.*nu) + c=float(np.median(lt)); g=lt-c # pedestal: reconstructed exactly, so subtracting it is free + k=np.arange(n,dtype=float); x=lo+np.arange(int(round((hi-lo)*upsample))+1)/float(upsample) + best=-np.inf; step=max(1,int(2**21)//max(n,1)) # chunked: srate 16384 is a 400 MB kernel matrix + for s in range(0,x.size,step): + u=x[s:s+step,None]-k[None,:] + best=max(best,float(np.max(np.dot(np.sinc(u)*np.sinc(beta*u),g)))) + return best+c +def _peak_edge_residual(lt): + """|lnL(edge)-pedestal| / peak height: how much of the peak leaks past the evaluated window. + + The truncated Whittaker sum in _peak_bandlimited is accurate only while the samples it drops + (everything outside lt) sit near the pedestal. ~0 means the peak is contained; O(1) means it + sits on the window edge, and then NO estimator on this window -- this one included -- can be + trusted, because the samples that would fix it were never computed. + """ + lt=np.asarray(lt,float); c=float(np.median(lt)); amp=float(np.max(lt)-c) + if not amp>0: return float('nan') + return float(max(abs(lt[0]-c),abs(lt[-1]-c))/amp) +# SRATE (default 2048) and FMAXHZ (default 512) are knobs for diagnosing the deficit floor: +# raising SRATE refines the lnL time grid (tvals spacing is locked to deltaT by the NoLoop +# window logic) and lowers f/f_s for the cubic interpolator. +_SRATE=float(os.environ.get('SRATE','2048')); _FMAX=float(os.environ.get('FMAXHZ','512')) +# SEGLEN/FMINHZ: the DEFAULTS ARE UNPHYSICAL and are kept only for continuity with earlier +# results. A 2.2+1.8 Msun binary from 25 Hz lasts ~48.5 s; in a 16 s segment it is wrapped, +# with the merger landing ~10 ms from the segment edge. Never trust a number from a +# configuration where the signal does not fit: use SEGLEN=64 (fits at fmin=25) or FMINHZ=50 +# (fits in 16 s). The script warns when the chirp time exceeds the segment. +_SEGLEN=float(os.environ.get('SEGLEN','16')); _FMIN=float(os.environ.get('FMINHZ','25')) +fmin,fmax,deltaT,seglen=_FMIN,_FMAX,1/_SRATE,_SEGLEN; deltaF=1./seglen; fNyq=1/2./deltaT; N=int(round(seglen/deltaT)) RA,DEC,PSI,INCL,PHIREF=1.2,0.3,0.5,0.4,0.0; DLOUD=fl.distMpcRef*1e6*lsu.lsu_PC/30. Psig=lsu.ChooseWaveformParams(fmin=fmin,radec=True,incl=INCL,phiref=PHIREF,theta=DEC,phi=RA,psi=PSI, m1=2.2*lal.MSUN_SI,m2=1.8*lal.MSUN_SI,detector=det,dist=200e6*lal.PC_SI,deltaT=deltaT,tref=event_time,deltaF=deltaF); Psig.approx=apx +_mt=(2.2+1.8)*lal.MSUN_SI*lal.G_SI/lal.C_SI**3; _eta=2.2*1.8/(2.2+1.8)**2 +_tchirp=5./256.*_mt/(_eta*(np.pi*_mt*fmin)**(8./3.)) +print("seglen=%.0fs fmin=%.0fHz chirp_time=%.1fs (%.0f%% of segment) FITS=%s" + %(seglen,fmin,_tchirp,100*_tchirp/seglen,_tchirp=seglen: + print(" *** WARNING: signal is TRUNCATED/WRAPPED in this segment ***") +# NOT a warning: MEASURED not to matter. A "marginal segment" caution was added here and then +# removed, because holding the signal fixed (fmin=25, 48.5 s chirp, srate 4096) and doubling +# seglen 64 -> 128 s -- halving the fill fraction from 76%% to 38%% -- moved the Cauchy-Schwarz +# deficit from -0.075485 to -0.075585, i.e. 0.13%% and in the WRONG direction. Segment headroom +# is not what breaks the slow-rotation likelihood. ACTUAL truncation (chirp >= seglen) very much +# is -- see the WARNING above, worth ~85%% of the deficit in the v1 configuration -- but do not +# re-derive a headroom rule from that: it has been tested and there is none. Pm=Psig.manual_copy(); Pm.dist=DLOUD -hlms_fd,_=fl.internal_hlm_generator(Pm,Lmax,verbose=False,quiet=True); hlmsT=_ifft(hlms_fd) +# GWSIGNAL path: the SEOBNRv5 family is NOT exposed through GetApproximantFromString at all, +# only through the gwsignal generator interface -- which is also the only route that accepts +# lmax_nyquist. lmax_nyquist=1 disables the ringdown-vs-Nyquist check entirely (no mode has +# l<2), which is what lets a light system run below srate 16384. Requires a py>=3.9 env with +# gwsignal importable (e.g. ~/.conda/envs/junior_rift); it will NOT import under RIFT_develUWM. +# The SAME kwargs go to the precompute, so data and template use the same generator -- passing +# them to only one would silently compare a v5 signal against a default-approximant template. +HLM_KW={} +if os.environ.get("GWSIG"): + HLM_KW=dict(use_gwsignal=True, use_gwsignal_approx=os.environ.get("GWSIG_APPROX","SEOBNRv5PHM"), + extra_waveform_kwargs={"lmax_nyquist":int(os.environ.get("LMAXNYQ","1"))}) + print("gwsignal: approx=%s lmax_nyquist=%s"%(HLM_KW["use_gwsignal_approx"], + HLM_KW["extra_waveform_kwargs"]["lmax_nyquist"])) +if HLM_KW: + # pyseobnr rejects f_ref=0, which is RIFT's default. Set it ONLY on this path so the + # previously measured non-gwsignal configurations are bit-for-bit unperturbed. Both Psig + # (template, via the precompute) and Pm (data) get it, or they would disagree. + Psig.fref=fmin; Pm.fref=fmin +hlms_fd,_=fl.internal_hlm_generator(Pm,Lmax,verbose=False,quiet=True,**HLM_KW); hlmsT=_ifft(hlms_fd) lm0=list(hlmsT.keys())[0]; nn=hlmsT[lm0].data.length; dt=hlmsT[lm0].deltaT; ep=float(hlmsT[lm0].epoch); tt=ep+np.arange(nn)*dt Sig=np.zeros(nn,complex) for lm in hlmsT: Sig+=hlmsT[lm].data.data*lal.SpinWeightedSphericalHarmonic(INCL,-PHIREF,-2,lm[0],lm[1]) +# NOTE (unresolved): NEITHER generator route leaves room after the peak for the delay lookup. +# IMRPhenomD (FD -> hlmoft_FromFD_dict -> SimInspiralTDModesFromPolarizations) inherits LAL's +# ~9.28 ms post-ringdown pad; TaylorT4 (TD) terminates at ISCO with a 0 ms gap. max|tau| is +# ~9.5 ms, so in both cases the delayed lookup reads past the end and nan_to_num deletes the +# loudest samples -- 2.6e-3 of the power for PhenomD (floor 0.207), 1.3e-2 for TaylorT4 (floor +# 1.49). RIFT's own FD-modes path reserves 10% of the segment (fd_centering_factor=0.9, +# fd_alignment_postevent_time) but this route never reaches it. +# Rolling the array to make trailing room is NOT a valid fix: it wraps the head around, and the +# head is quiet only in configurations that do not have the problem in the first place. The fix +# is to ZERO-EXTEND after the merger (grow the array past the peak, keeping the epoch), with +# Psig.deltaF kept consistent so the template is built on the same grid. Not yet implemented. reS=CubicSpline(tt,Sig.real,extrapolate=False); imS=CubicSpline(tt,Sig.imag,extrapolate=False) lald=lalsim.DetectorPrefixToLALDetector(det); g_ev=lal.GreenwichMeanSiderealTime(lal.LIGOTimeGPS(event_time))-RA A=srr.antenna_harmonics(lald.response,DEC,PSI); At={k:A[k]*np.exp(1j*k*g_ev) for k in A} B=srr.delay_harmonics(lald.location,DEC); Bt={k:B[k]*np.exp(1j*k*g_ev) for k in B} tau_t=np.real(sum(Bt[k]*np.exp(1j*k*OMEGA_INF*tt) for k in Bt)) F_t=sum(At[k]*np.exp(1j*k*OMEGA_INF*tt) for k in At) -Sig_d=np.nan_to_num(reS(tt-tau_t)+1j*imS(tt-tau_t)) +# EDGE=nan (default)|wrap. 'nan' is the original construction: extrapolate=False makes +# Sig(t-tau) NaN wherever the delayed time leaves the sampled span, and nan_to_num ZEROES it, +# deleting a ~|tau| sliver (~9.5 ms here) from the data that the model still contains. 'wrap' +# resamples from a periodic extension instead, which is what the FD model actually assumes. +if os.environ.get("EDGE","nan")=="wrap": + _pad=int(np.ceil((np.abs(tau_t).max()+10*dt)/dt)) + _tte=np.concatenate([tt[0]-dt*np.arange(_pad,0,-1),tt,tt[-1]+dt*np.arange(1,_pad+1)]) + _sge=np.concatenate([Sig[-_pad:],Sig,Sig[:_pad]]) + _re=CubicSpline(_tte,_sge.real,extrapolate=False); _im=CubicSpline(_tte,_sge.imag,extrapolate=False) + Sig_d=np.nan_to_num(_re(tt-tau_t)+1j*_im(tt-tau_t)) +else: + Sig_d=np.nan_to_num(reS(tt-tau_t)+1j*imS(tt-tau_t)) data=_to_fd(np.real(F_t*Sig_d),lal.LIGOTimeGPS(float(hlmsT[lm0].epoch)+event_time),dt,N); data_dict={det:data}; psd_dict={det:psd} IPc=lsu.ComplexIP(fmin,fmax,fNyq,data.deltaF,psd,True,False,0.); HALF_DD=0.5*IPc.ip(data,data).real print("inflated seglen=%.0fs 0.5=%.4f"%(seglen,HALF_DD)) Pv=Psig.manual_copy() for k,v in [('phi',RA),('theta',DEC),('incl',INCL),('phiref',PHIREF),('psi',PSI),('dist',DLOUD)]: setattr(Pv,k,np.ones(1)*v) -Pv.tref=event_time; Pv.deltaT=deltaT; Nw=int(0.02/deltaT); tvals=np.arange(-Nw,Nw)*deltaT +# NWMS: half-width of the lnL(t) scan window in ms (default 20). The window SPAN is fixed in +# TIME, so raising SRATE adds samples without widening it -- which is why a srate ladder cannot +# distinguish a sub-sample effect from a window-span effect. DUMPLNL saves lnL(t) itself. +_NWMS=float(os.environ.get("NWMS","20")) +Pv.tref=event_time; Pv.deltaT=deltaT; Nw=int(1e-3*_NWMS/deltaT); tvals=np.arange(-Nw,Nw)*deltaT +# GUARDMS: extra lnL(t) samples evaluated on BOTH sides of the requested scan window, used only as +# support for the peak estimator -- the maximum is still taken over the requested window alone. +# Default: as much guard as the retained Q buffer allows (|t| must stay inside t_window; keep to +# the documented t_window/2 with a 1 ms margin), capped at the scan half-width because more than +# that buys nothing. These are ordinary tvals in the same vectorized NoLoop call, so the extra +# cost is a longer time axis in one contraction, not another precompute. +_GMS=float(os.environ.get("GUARDMS","-1")) +if _GMS<0: _GMS=min(_NWMS,max(0.,500.*t_window-_NWMS-1.)) +Ng=max(0,int(1e-3*_GMS/deltaT)); tvals_ext=np.arange(-Nw-Ng,Nw+Ng)*deltaT +if Ng<8: + print(" *** WARNING: only %d guard samples for the peak estimator (GUARDMS=%.1f ms); the" + " reconstruction is edge-limited near the ends of the scan window -- raise TWIN ***"%(Ng,_GMS)) +# INFL=340 reproduces the Omega*T of the worst physical case -- a 90-minute (5400 s) BNS at the +# true sidereal rate -- on this 16 s segment (5400/16 = 337.5 ~ 340). So INFL/340 is the rotation +# rate as a multiple of that worst physical case; it is the quantity the paper quotes. +# The invariant that matters is Omega*T over the SIGNAL, not the segment. The worst physical +# case is a 90-minute (5400 s) BNS at the true sidereal rate, so the equivalent inflation is +# 5400/T_signal, with T_signal = min(chirp_time, seglen) -- the chirp if it fits, the segment if +# it is truncated. At the old defaults (fmin=25, chirp 48.5 s truncated to 16 s) that gives 337.5 +# ~ 340, which is where the historical anchor came from; at fmin=50 (7.6 s chirp) it is ~711. +T_SIGNAL=min(_tchirp,seglen); PHYS_INFL=5400.0/T_SIGNAL +# TINTERP=nearest (default)|cubic -- the sub-bin time sampling used for the data term. 'nearest' +# leaves a ~0.2 nat peak-resolution floor on the deficit; 'cubic' is the calmarg_in_loop +# interpolation and should remove it. +TINTERP=os.environ.get("TINTERP","nearest") +lnL_by_pmax={}; deficit_by_pmax={}; lnL_raw_by_pmax={}; overshoot_by_pmax={}; lnL_spline_by_pmax={}; lnL_bl_by_pmax={}; edge_by_pmax={} for pmax in [0,1,2,3]: nh=2+pmax - bk=flwr.PrecomputeLikelihoodTermsWithRotation(event_time,t_window,Psig,data_dict,psd_dict,Lmax,fmax,harmonics=tuple(range(-nh,nh+1)),p_max=pmax,f_sidereal=FSID_INF,analyticPSD_Q=True,verbose=False,quiet=True,skip_interpolation=True) + bk=flwr.PrecomputeLikelihoodTermsWithRotation(event_time,t_window,Psig,data_dict,psd_dict,Lmax,fmax,harmonics=tuple(range(-nh,nh+1)),p_max=pmax,f_sidereal=FSID_INF,analyticPSD_Q=True,verbose=False,quiet=True,skip_interpolation=True,**HLM_KW) lk,rbn,ubn,vbn,epd=flwr.pack_rotation_arrays(bk[4],bk[3],bk[1],bk[2]) - lnL=_peak(flwr.DiscreteFactoredLogLikelihoodViaArrayVectorNoLoopWithRotation(tvals,Pv,bk[4],lk,rbn,ubn,vbn,epd,Lmax=Lmax,array_output=True)[0]) - print(" p_max=%d : lnL=%.5f deficit=%.5f"%(pmax,lnL,HALF_DD-lnL)) + # Evaluated on the GUARD-EXTENDED axis; _lt is the requested scan window, so the raw max, the + # spline diagnostic and DUMPLNL keep their old meaning and the guard only feeds the estimator. + _lt_ext=flwr.DiscreteFactoredLogLikelihoodViaArrayVectorNoLoopWithRotation(tvals_ext,Pv,bk[4],lk,rbn,ubn,vbn,epd,Lmax=Lmax,array_output=True,time_interp=TINTERP)[0] + _lt=np.asarray(_lt_ext,float)[Ng:Ng+2*Nw] + # _peak() splines (k=4) and oversamples 32x, which can OVERSHOOT the sampled maximum and + # push the deficit negative -- a Cauchy-Schwarz 'violation' that is the estimator, not the + # likelihood. Record the raw grid max too so the overshoot is visible rather than folded in. + lnL_spline=_peak(_lt); lnL_bl=_peak_bandlimited(_lt_ext,Ng,band_frac=fmax*deltaT) + lnL=lnL_bl if os.environ.get('PEAK','bandlimited')=='bandlimited' else lnL_spline + lnL_raw=float(np.max(np.asarray(_lt,float))) + lnL_raw_by_pmax[str(pmax)]=lnL_raw; overshoot_by_pmax[str(pmax)]=lnL-lnL_raw + lnL_spline_by_pmax[str(pmax)]=lnL_spline; lnL_bl_by_pmax[str(pmax)]=lnL_bl + # Validity of the truncated reconstruction: O(1) means the peak is at the window edge, and the + # deficit on this configuration says more about NWMS/GUARDMS than about the likelihood. + edge_by_pmax[str(pmax)]=_peak_edge_residual(_lt_ext) + lnL_by_pmax[str(pmax)]=float(lnL); deficit_by_pmax[str(pmax)]=float(HALF_DD-lnL) + if os.environ.get("DUMPLNL") and pmax==2: + np.savez(os.environ["DUMPLNL"], tvals=np.asarray(tvals,float), lnLt=np.asarray(_lt,float), + tvals_ext=np.asarray(tvals_ext,float), lnLt_ext=np.asarray(_lt_ext,float), + n_guard=Ng, half_dd=HALF_DD, srate=_SRATE, infl=float(os.environ.get("INFL","340")), + nwms=_NWMS, deltaT=deltaT) + print(" p_max=%d : lnL=%.5f deficit=%.5f edge_residual=%.2e"%(pmax,lnL,HALF_DD-lnL,edge_by_pmax[str(pmax)])) +# Opt-in persistence: set OUT=.json. Default behaviour (print only) is unchanged. +_out=os.environ.get("OUT") +if _out: + import json + with open(_out,"w") as _fh: + json.dump({"time_interp":TINTERP,"srate":_SRATE,"edge":os.environ.get("EDGE","nan"), + "infl":float(os.environ.get("INFL","340")), + "infl_physical_reference":PHYS_INFL, + "omega_ratio_vs_physical":float(os.environ.get("INFL","340"))/PHYS_INFL, + "t_signal":float(T_SIGNAL), + "half_dd":float(HALF_DD), + "deficit_by_pmax":deficit_by_pmax,"lnL_by_pmax":lnL_by_pmax, + "lnL_raw_by_pmax":lnL_raw_by_pmax,"peak_overshoot_by_pmax":overshoot_by_pmax, + "peak_estimator":os.environ.get("PEAK","bandlimited"), + "peak_guard_samples":int(Ng),"peak_guard_ms":float(_GMS), + "peak_band_frac":float(fmax*deltaT), + "peak_edge_residual_by_pmax":edge_by_pmax, + "lnL_spline_by_pmax":lnL_spline_by_pmax,"lnL_bandlimited_by_pmax":lnL_bl_by_pmax, + "approx":(HLM_KW.get("use_gwsignal_approx") or os.environ.get("APPROX","IMRPhenomD")), + "lmax_nyquist":HLM_KW.get("extra_waveform_kwargs",{}).get("lmax_nyquist"), + "epoch_s":float(ep),"peak_frac":float(int(np.argmax(np.abs(Sig)))/float(nn)),"nw_ms":_NWMS,"npts_tvals":int(2*Nw),"seglen":float(seglen),"fmin":float(fmin),"chirp_time":float(_tchirp),"signal_fits":bool(_tchirp merger transition +RINGDOWN_OVER_ISCO = 3.9 # (2,2) ringdown of an a~0.7 remnant +RINGDOWN_Q = 3.0 # QNM quality factor; the Lorentzian width is f_ring / (2 Q) +CUTOFF_OVER_RINGDOWN = 3.0 # where the ringdown Lorentzian has fallen far enough to drop + + +def imr_amplitude_sq(freqs, m_total_msun=None): + """|h(f)|^2 for an inspiral-merger-ringdown signal, up to an arbitrary constant. + + Piecewise, in the standard IMRPhenom shape: + + f < f_merg inspiral |h| ~ f^(-7/6) -> |h|^2 ~ f^(-7/3) + f < f_ring merger |h| ~ f^(-2/3) -> |h|^2 ~ f^(-4/3) + f >= f_ring ringdown Lorentzian of width f_ring / (2 Q) + + WHY NOT SIMPLY TRUNCATE AT f_ISCO. An earlier version of this function did, and it was + wrong in a way that mattered: f_ISCO is where an inspiral-only APPROXIMANT terminates, not + where a binary stops radiating. Truncating there hard-codes the artifact -- it also made the + whole estimator degenerate into f_ISCO, reproducing the 7.4x drift that made an f_ISCO-based + stencil rule unusable in the first place. Here f_ISCO only sets the SCALE of the merger and + ringdown features; real power continues to ~4x it. + + With no mass supplied this falls back to the pure inspiral power law, because the merger + scale is unknown -- that is the one case where the caller genuinely has nothing better. + """ + freqs = np.asarray(freqs, dtype=float) + amp_sq = np.zeros_like(freqs) + good = freqs > 0 + amp_sq[good] = freqs[good] ** (-7.0 / 3.0) + + m_total = None + if m_total_msun: + try: + m_total = float(m_total_msun) + except (TypeError, ValueError): + m_total = None + if m_total is not None and not (np.isfinite(m_total) and m_total > 0): + m_total = None + if m_total is None: + return amp_sq + + f_isco = 4397.0 / m_total + f_merg = MERGER_OVER_ISCO * f_isco + f_ring = RINGDOWN_OVER_ISCO * f_isco + sigma = f_ring / (2.0 * RINGDOWN_Q) + f_cut = f_ring + CUTOFF_OVER_RINGDOWN * sigma + + # merger: |h|^2 ~ f^(-4/3), matched to the inspiral value at f_merg so the spectrum is + # continuous (the absolute normalisation is irrelevant -- only the SHAPE sets the quantile). + merger = good & (freqs >= f_merg) & (freqs < f_ring) + if np.any(merger): + scale = f_merg ** (-7.0 / 3.0) / (f_merg ** (-4.0 / 3.0)) + amp_sq[merger] = scale * freqs[merger] ** (-4.0 / 3.0) + + # ringdown: Lorentzian in |h|, so |h|^2 is the square, matched at f_ring + ring = good & (freqs >= f_ring) & (freqs <= f_cut) + if np.any(ring): + amp_ring = f_merg ** (-7.0 / 6.0) / (f_merg ** (-2.0 / 3.0)) * f_ring ** (-2.0 / 3.0) + lorentz = 1.0 / (1.0 + ((freqs[ring] - f_ring) / (0.5 * sigma)) ** 2) + amp_sq[ring] = (amp_ring * lorentz) ** 2 + + amp_sq[freqs > f_cut] = 0.0 + return amp_sq + + +# Backwards-compatible alias. The old name promised inspiral-only behaviour, which is no longer +# what this does; keep it working but point callers at the accurate name. +inspiral_amplitude_sq = imr_amplitude_sq + + +def bandwidth_from_psd(freqs, psd_values, fmin, fmax, m_total_msun=None, + quantile=DEFAULT_POWER_QUANTILE): + """Frequency below which `quantile` of the matched-filter SNR^2 accumulates, or None. + + The integrand is |h(f)|^2 / S(f) over [fmin, fmax] -- the same thing the likelihood + integrates -- so this reports where the analysis actually has sensitivity, not merely where + the band edges were set. + + Returns None on any unusable input, so a caller can distinguish "no estimate" from a number. + """ + if freqs is None or psd_values is None: + return None + freqs = np.asarray(freqs, dtype=float) + psd_values = np.asarray(psd_values, dtype=float) + if freqs.size < 2 or freqs.size != psd_values.size: + return None + try: + fmin = float(fmin) + fmax = float(fmax) + except (TypeError, ValueError): + return None + if not (np.isfinite(fmin) and np.isfinite(fmax)) or fmax <= fmin: + return None + if not (0.0 < float(quantile) < 1.0): + return None + + band = (freqs >= fmin) & (freqs <= fmax) & np.isfinite(psd_values) & (psd_values > 0) + if band.sum() < 2: + return None + f = freqs[band] + s = psd_values[band] + integrand = imr_amplitude_sq(f, m_total_msun) / s + if not np.any(integrand > 0): + # the whole in-band integrand was killed, e.g. f_ISCO below fmin (a binary too heavy to + # radiate in this band at all). No meaningful bandwidth; say so. + return None + cumulative = np.cumsum(integrand) + total = cumulative[-1] + if not np.isfinite(total) or total <= 0: + return None + idx = int(np.searchsorted(cumulative, quantile * total)) + idx = min(idx, len(f) - 1) + return float(f[idx]) + + +def estimate_signal_bandwidth(psd_names, fmin, fmax, m_total_msun=None, + quantile=DEFAULT_POWER_QUANTILE): + """Top-level: estimate the occupied bandwidth in Hz from a {ifo: psd_path} mapping. + + Returns (bandwidth_hz, ifo_used, reason). bandwidth_hz is None whenever no estimate could be + made, and `reason` then says why in a form fit for a log line -- callers should report it + rather than silently substituting a default. + + NOTHING HERE RAISES. A missing or half-copied PSD set is an ordinary mid-setup state; the + contract is that the caller falls back to its SAFE choice on None. + """ + if not psd_names: + return None, None, "no PSDs available" + if choose_representative_ifo(list(psd_names.keys())) is None: + return None, None, "no usable detector names in the PSD set" + # One bad file must not sink the estimate if a sibling is readable -- but the fallback has to + # keep obeying the PREFERENCE order, not dict insertion order. Re-running the chooser over + # the remaining candidates is what makes {'H1': malformed, 'V1': ok, 'L1': ok} pick L1; a + # plain iteration over the mapping picks whichever happens to come first, which for that + # example is Virgo, silently violating this module's stated representative-detector invariant. + remaining = list(psd_names.keys()) + data = None + while remaining: + ifo = choose_representative_ifo(remaining) + if ifo is None: + break + data = _read_psd(psd_names.get(ifo), ifo) + if data is not None: + break + remaining = [x for x in remaining if x != ifo] + if data is None: + return None, ifo, "PSD for %s not readable (missing or malformed)" % (ifo,) + freqs, values = data + bw = bandwidth_from_psd(freqs, values, fmin, fmax, m_total_msun, quantile) + if bw is None: + return None, ifo, "PSD for %s read, but no bandwidth could be computed in [%s, %s]" % ( + ifo, fmin, fmax) + return bw, ifo, "from %s PSD, %.4g%% SNR^2 quantile" % (ifo, 100.0 * quantile) diff --git a/MonteCarloMarginalizeCode/Code/RIFT/misc/test_psd_bandwidth.py b/MonteCarloMarginalizeCode/Code/RIFT/misc/test_psd_bandwidth.py new file mode 100644 index 000000000..a71743f28 --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/RIFT/misc/test_psd_bandwidth.py @@ -0,0 +1,262 @@ +#!/usr/bin/env python3 +"""test_psd_bandwidth -- representative-detector choice, and the fallback contract. + +Two things are guarded, both of which are about behaviour under imperfect input rather than +about the arithmetic: + + 1. VIRGO IS NOT THE REPRESENTATIVE unless Virgo is all there is. Its noise curve differs + enough from H/L that characterising an H/L/V network by it would misdescribe the band -- + but a V-only analysis is legitimate and must still get an answer. + 2. EVERY FAILURE RETURNS None WITH A REASON, and none of them raise. PSDs get copied into a + run directory late, so "no PSD yet" is an ordinary mid-setup state, not an error. The + contract is that callers fall back to their SAFE option on None -- a tool that raised, or + that quietly guessed, would be worse than no tool. + +Self-contained: numpy only, no lal, no data. Runs instantly. + + python3 test_psd_bandwidth.py # or: pytest test_psd_bandwidth.py +""" +from __future__ import print_function + +import numpy as np + +from RIFT.misc.psd_bandwidth import ( + IFO_PREFERENCE, + bandwidth_from_psd, + choose_representative_ifo, + estimate_signal_bandwidth, + imr_amplitude_sq, +) + + +def test_virgo_is_last_but_not_excluded(): + """The rule RO'S asked for: not Virgo unless V-only.""" + assert choose_representative_ifo(['H1', 'L1', 'V1']) == 'H1' + assert choose_representative_ifo(['V1', 'L1']) == 'L1' + assert choose_representative_ifo(['V1', 'K1']) == 'K1' + # ...but a V-only run must still get an answer, not None + assert choose_representative_ifo(['V1']) == 'V1' + print("V1 chosen only when alone; H1/L1/K1 preferred otherwise: OK") + + +def test_representative_choice_is_order_independent_and_total(): + """The answer must not depend on dict/list ordering, and unknown names must not give None.""" + for order in (['H1', 'L1', 'V1'], ['V1', 'H1', 'L1'], ['L1', 'V1', 'H1']): + assert choose_representative_ifo(order) == 'H1', order + # unknown instrument names: deterministic, and never None just because we do not know them + got = choose_representative_ifo(['X9', 'A3']) + assert got == 'A3', got + # ...but a known name still wins over an unknown one + assert choose_representative_ifo(['X9', 'L1']) == 'L1' + # empty / degenerate input is the one case that legitimately gives None + for empty in ([], None, ['', ' ']): + assert choose_representative_ifo(empty) is None, empty + print("choice is order-independent, total over unknown names, None only when empty: OK") + + +def test_every_failure_returns_none_with_a_reason_and_never_raises(): + """The fallback contract. A caller must be able to tell 'no estimate' from a number.""" + cases = [ + ({}, "empty mapping"), + (None, "None mapping"), + ({'H1': '/nonexistent/path/to/H1-psd.xml.gz'}, "missing file"), + ({'H1': None}, "None path"), + ({'': ''}, "blank names"), + ] + for psd_names, label in cases: + bw, ifo, reason = estimate_signal_bandwidth(psd_names, 20.0, 1700.0, m_total_msun=30.0) + assert bw is None, "%s must give no estimate, got %r" % (label, bw) + assert isinstance(reason, str) and reason, "%s must give a reason for the log" % label + print("all failure modes return None with a reason, none raise: OK") + + +def _flat_psd(f_lo=5.0, f_hi=4096.0, df=0.25): + freqs = np.arange(f_lo, f_hi + df, df) + return freqs, np.ones_like(freqs) * 1e-46 + + +def test_bandwidth_is_bounded_by_the_band_and_by_the_mass(): + """Sanity that the estimate means what it says, on a flat PSD where the answer is analytic.""" + freqs, psd = _flat_psd() + bw = bandwidth_from_psd(freqs, psd, 20.0, 1700.0, m_total_msun=2.6) + assert bw is not None and 20.0 <= bw <= 1700.0, bw + + # A heavier binary must give a LOWER bandwidth: f_ISCO falls as 1/M. + bw_light = bandwidth_from_psd(freqs, psd, 20.0, 1700.0, m_total_msun=2.6) + bw_heavy = bandwidth_from_psd(freqs, psd, 20.0, 1700.0, m_total_msun=80.0) + print("flat PSD, fmin 20, fmax 1700: M=2.6 -> %.1f Hz, M=80 -> %.1f Hz" % (bw_light, bw_heavy)) + assert bw_heavy < bw_light, "a heavier binary must occupy a narrower band (%g vs %g)" % ( + bw_heavy, bw_light) + + # Raising fmin must not lower the bandwidth -- the band only loses low-frequency content. + bw_lo = bandwidth_from_psd(freqs, psd, 20.0, 1700.0, m_total_msun=5.0) + bw_hi = bandwidth_from_psd(freqs, psd, 150.0, 1700.0, m_total_msun=5.0) + print("M=5: fmin 20 -> %.1f Hz, fmin 150 -> %.1f Hz" % (bw_lo, bw_hi)) + assert bw_hi >= bw_lo, "raising fmin must not reduce the estimated bandwidth" + + +def test_binary_too_heavy_for_the_band_gives_no_estimate(): + """f_ISCO below fmin means the system does not radiate in band at all. + + Returning a number here would be worse than returning None: it would be a bandwidth for a + signal that is not there. + """ + freqs, psd = _flat_psd() + bw = bandwidth_from_psd(freqs, psd, 100.0, 1700.0, m_total_msun=1000.0) # f_ISCO ~ 4.4 Hz + assert bw is None, "a binary with f_ISCO below fmin must give no estimate, got %r" % bw + print("binary too heavy to radiate in band -> no estimate: OK") + + +def test_malformed_psd_inputs_return_none(): + freqs, psd = _flat_psd() + assert bandwidth_from_psd(None, psd, 20, 1700) is None + assert bandwidth_from_psd(freqs, None, 20, 1700) is None + assert bandwidth_from_psd(freqs, psd[:-5], 20, 1700) is None # length mismatch + assert bandwidth_from_psd(freqs, psd, 1700, 20) is None # inverted band + assert bandwidth_from_psd(freqs, psd, 'x', 1700) is None # unparseable + assert bandwidth_from_psd(freqs, np.zeros_like(psd), 20, 1700) is None # PSD all zero + assert bandwidth_from_psd(freqs, psd, 20, 1700, quantile=1.5) is None # bad quantile + print("malformed PSD inputs return None: OK") + + +def test_amplitude_is_a_power_law_in_the_inspiral(): + f = np.array([10.0, 100.0, 1000.0]) + a_untrunc = imr_amplitude_sq(f) + assert np.all(a_untrunc > 0) + # power-law shape in the inspiral, f^(-7/3) + ratio = a_untrunc[0] / a_untrunc[1] + assert abs(ratio - 10.0 ** (7.0 / 3.0)) < 1e-6 * ratio + print("inspiral amplitude is f^(-7/3): OK") + + +def test_signal_has_power_above_f_isco(): + """f_ISCO is the TERMINATION POINT OF AN APPROXIMANT, not where a binary stops radiating. + + An earlier version of this module truncated |h|^2 at f_ISCO. That hard-coded TaylorT4's + behaviour (it terminates at ISCO by construction) as if it were physics, and it made the + whole estimator degenerate into f_ISCO -- inheriting the 7.4x drift that made an f_ISCO-based + stencil rule unusable. A real IMR signal keeps radiating through merger and ringdown, to + ~4x f_ISCO. + """ + for m_total in (5.0, 20.0, 55.0): + f_isco = 4397.0 / m_total + probe = np.array([0.5, 1.5, 3.0, 8.0]) * f_isco + amp = imr_amplitude_sq(probe, m_total_msun=m_total) + assert amp[0] > 0 and amp[1] > 0, "inspiral and merger must carry power" + assert amp[2] > 0, ( + "M=%g: no power at 3x f_ISCO -- the spectrum is being truncated at the approximant's " + "termination point rather than modelling merger-ringdown" % m_total) + assert amp[3] == 0, "power must eventually cut off well above ringdown" + print("IMR spectrum carries power to ~4x f_ISCO, not truncated at it: OK") + + +def test_quieter_high_frequency_noise_widens_the_band(): + """THE STRUCTURAL GUARD, and the forward-looking one. + + Real detector high-frequency walls are NOT steep -- aLIGO ZDHP is only ~3.7x its minimum at + 1500 Hz -- and future detectors are flatter still. So the estimate must respond to the + high-frequency noise level in the right direction: making the detector quieter up there must + WIDEN the occupied band, because more high-frequency signal becomes measurable. + + A tool that failed this would be reporting the waveform's scale while ignoring the detector, + which is the failure mode that motivated writing it. + """ + df = 0.25 + freqs = np.arange(df, 2048.0 + df, df) + # a realistic shape: flat bucket, GENTLE high-frequency rise (not the steep wall it is + # tempting to write -- see the module docstring) + base = 1e-46 * (1.0 + (freqs / 800.0) ** 2) + + prev = None + for factor in (1.0, 3.0, 10.0, 100.0): + psd = base.copy() + psd[freqs > 300.0] /= factor + bw = bandwidth_from_psd(freqs, psd, 30.0, 1700.0, m_total_msun=20.0) + assert bw is not None + print("high-f noise divided by %5.0f -> bandwidth %6.1f Hz" % (factor, bw)) + if prev is not None: + assert bw > prev, ( + "reducing high-frequency noise by %gx did not widen the band (%.1f -> %.1f Hz); " + "the estimator is ignoring the detector" % (factor, prev, bw)) + prev = bw + + +def test_fallback_after_unreadable_psd_returns_the_preferred_READABLE_detector(): + """The requested scenario: H1 MALFORMED while both L1 and V1 are READABLE -> must return L1. + + An earlier version of this test made every _read_psd call fail and only checked the order of + attempts. That is not the same claim: it never established which detector is actually USED, + which is the invariant ("not Virgo unless V-only") the fallback was violating. Here the + siblings really do return usable data, so the assertion is on the RESULT. + + Asserted across insertion orders, because the original bug was that the fallback followed + dict order -- a dict that happens to be ordered favourably would hide it. + """ + import RIFT.misc.psd_bandwidth as mod + + df = 0.25 + freqs = np.arange(df, 2048.0 + df, df) + # distinguishable curves, so a wrong pick would also change the number + curves = {'L1': 1e-46 * (1.0 + (freqs / 800.0) ** 2), + 'V1': 1e-45 * (1.0 + (freqs / 200.0) ** 2)} # noisier, and rolls off sooner + + orig = mod._read_psd + try: + def fake_read(path, ifo): + if ifo == 'H1': + return None # malformed / half-copied, the realistic mid-setup state + return (freqs, curves[ifo]) + mod._read_psd = fake_read + + results = {} + for order in (['H1', 'V1', 'L1'], ['H1', 'L1', 'V1'], ['V1', 'L1', 'H1']): + psd_names = dict((k, '/wherever/%s-psd.xml.gz' % k) for k in order) + bw, ifo, reason = mod.estimate_signal_bandwidth( + psd_names, 30.0, 1700.0, m_total_msun=20.0) + print("insertion %-18s -> used %s, bandwidth %s Hz" + % (order, ifo, ("%.1f" % bw) if bw else None)) + assert ifo == 'L1', ( + "insertion order %s selected %r; with H1 unreadable and BOTH L1 and V1 readable " + "the representative must be L1. Selecting V1 violates the module's stated " + "invariant, and it happens precisely when a PSD file is bad." % (order, ifo)) + assert bw is not None, "a readable sibling must still yield an estimate" + assert 'L1' in reason, "the reason line must name the detector actually used: %r" % reason + results[tuple(order)] = bw + + # the answer must not depend on insertion order either + assert len(set(results.values())) == 1, \ + "bandwidth varied with dict insertion order: %r" % results + + # ...and the V1 curve really is distinguishable, so the assertion above has teeth: + # if V1 had been chosen the number would differ. + bw_v_only, ifo_v, _ = mod.estimate_signal_bandwidth( + {'V1': '/wherever/V1-psd.xml.gz'}, 30.0, 1700.0, m_total_msun=20.0) + assert ifo_v == 'V1', "a V-only network must still be answered" + assert abs(bw_v_only - list(results.values())[0]) > 1.0, ( + "the L1 and V1 curves give the same bandwidth (%.1f), so 'it returned L1' is not " + "actually distinguishable from 'it returned V1' -- strengthen the fixture" + % bw_v_only) + print("V-only network answered with V1 (%.1f Hz), distinct from L1: OK" % bw_v_only) + finally: + mod._read_psd = orig + + +def test_preference_list_is_sane(): + assert IFO_PREFERENCE[-1] == 'V1', "V1 must be last in the preference order" + assert IFO_PREFERENCE[0] in ('H1', 'L1') + assert len(set(IFO_PREFERENCE)) == len(IFO_PREFERENCE), "no duplicates" + + +if __name__ == "__main__": + test_virgo_is_last_but_not_excluded() + test_representative_choice_is_order_independent_and_total() + test_every_failure_returns_none_with_a_reason_and_never_raises() + test_bandwidth_is_bounded_by_the_band_and_by_the_mass() + test_binary_too_heavy_for_the_band_gives_no_estimate() + test_malformed_psd_inputs_return_none() + test_amplitude_is_a_power_law_in_the_inspiral() + test_signal_has_power_above_f_isco() + test_quieter_high_frequency_noise_widens_the_band() + test_fallback_after_unreadable_psd_returns_the_preferred_READABLE_detector() + test_preference_list_is_sane() + print("\nPASS") diff --git a/MonteCarloMarginalizeCode/Code/RIFT/misc/tracer_placement/README.md b/MonteCarloMarginalizeCode/Code/RIFT/misc/tracer_placement/README.md index 14cf4fcc4..21428cce1 100644 --- a/MonteCarloMarginalizeCode/Code/RIFT/misc/tracer_placement/README.md +++ b/MonteCarloMarginalizeCode/Code/RIFT/misc/tracer_placement/README.md @@ -18,14 +18,45 @@ samplers/ — kernels with the production signature surrogate.py — legacy in-engine quadratic helper (used by toys, not by tools) _knn.py — numpy-only kNN helpers (no scipy) fits/ — surrogate builders the tools call - __init__.py — exposes build(method, X, Y, sigma=None) + __init__.py — exposes build(method, X, Y, sigma=None, lnl_floor_delta=None) _rf.py — RandomForest (default, production) _rbf.py — scipy RBFInterpolator _quadratic.py — Tikhonov-regularized quadratic (smoke tests only) _polynomial.py — degree-N polynomial (default 3) + _gp_linmean.py — linear-mean RBF GP, numpy-only; extrapolates + real sigma _base.py — FitBase with FD gradient + _dispatch.py — build(), plus the optional lnL floor ``` +## Extrapolating fits, and why the mean function matters + +`rf` (the production default) is piecewise-constant: outside the convex hull of +the training points it is exactly FLAT (`smooth_gradient = False`). When the lnL +peak is clipped at a box edge — the grid was drawn too narrow and lnL is still +rising as it leaves the sampled region — a flat surrogate gives placement +nothing to chase and the next iteration re-piles points on the wall. + +`gp_linmean` fits an RBF GP with a LINEAR MEAN, so extrapolation follows the +fitted global trend outward instead of relaxing to a flat prior. (A zero-mean GP +has the same failure as `rf` here, and worse: it relaxes to 0 — cf. CIP's +`--lnL-shift-prevent-overflow` help text.) It also exposes a calibrated +`predict_with_std`, which is what `samplers/ucb.py` wants for +`mu + kappa*sigma`. Pass `mean="const"` for the conservative behaviour. + +Ported from the R3 kilonova placement study, where the same construction +recovered a lnL peak clipped at the `v_outer` box edge. + +## lnL floor vs lnL cut + +`build(..., lnl_floor_delta=D)` — CLI `--tracer-lnl-floor-delta` on both tools, +**default off** — clamps training lnL at `max(lnL) - D` rather than cutting +those points as RIFT does elsewhere +(`indx_ok = Y > np.max(Y) - opts.lnL_offset`). With catastrophic-fit outliers +(a failed model can land lnL at -1e9) cutting discards the geometry of the +known-bad region entirely; clamping keeps those points as anchors that still +pin the surrogate's length scale and signal variance. With the default `None` +the training data is passed through untouched. + ## Sampler signature All three samplers expose: diff --git a/MonteCarloMarginalizeCode/Code/RIFT/misc/tracer_placement/__init__.py b/MonteCarloMarginalizeCode/Code/RIFT/misc/tracer_placement/__init__.py index fb47ee49b..82ba0bdf0 100644 --- a/MonteCarloMarginalizeCode/Code/RIFT/misc/tracer_placement/__init__.py +++ b/MonteCarloMarginalizeCode/Code/RIFT/misc/tracer_placement/__init__.py @@ -12,7 +12,8 @@ -> (X_new, info) samplers.smc_mala(...) -> (X_new, info) samplers.birth_death(...) -> (X_new, info) - fits.build(method, X, Y, sigma=None) -> Fit (callable + .grad helper) + fits.build(method, X, Y, sigma=None, lnl_floor_delta=None) + -> Fit (callable + .grad helper) """ from . import samplers, fits diff --git a/MonteCarloMarginalizeCode/Code/RIFT/misc/tracer_placement/fits/__init__.py b/MonteCarloMarginalizeCode/Code/RIFT/misc/tracer_placement/fits/__init__.py index 6d246e596..7a38d28c7 100644 --- a/MonteCarloMarginalizeCode/Code/RIFT/misc/tracer_placement/fits/__init__.py +++ b/MonteCarloMarginalizeCode/Code/RIFT/misc/tracer_placement/fits/__init__.py @@ -1,11 +1,18 @@ """Fits for the tracer engine. -Public entry point: build(method, X, Y, sigma=None) -> Fit. +Public entry point: build(method, X, Y, sigma=None, lnl_floor_delta=None) -> Fit. + +`lnl_floor_delta` (default None = off, legacy behaviour bit-for-bit) clamps the +training lnL from below at max(lnL) - delta instead of cutting those points; +see _dispatch.apply_lnl_floor. Fit objects expose: - .predict(Z) -> ndarray of len(Z) - .grad(Z) -> ndarray (len(Z), d) (analytic where available, FD otherwise) + .predict(Z) -> ndarray of len(Z) + .predict_with_std(Z) -> (mean, std); real std only where + .has_uncertainty is True (rf, gp_linmean) + .grad(Z) -> ndarray (len(Z), d) (analytic where available, + FD otherwise) """ -from ._dispatch import build +from ._dispatch import apply_lnl_floor, build -__all__ = ["build"] +__all__ = ["build", "apply_lnl_floor"] diff --git a/MonteCarloMarginalizeCode/Code/RIFT/misc/tracer_placement/fits/_dispatch.py b/MonteCarloMarginalizeCode/Code/RIFT/misc/tracer_placement/fits/_dispatch.py index ed781f2c1..b1b0004ea 100644 --- a/MonteCarloMarginalizeCode/Code/RIFT/misc/tracer_placement/fits/_dispatch.py +++ b/MonteCarloMarginalizeCode/Code/RIFT/misc/tracer_placement/fits/_dispatch.py @@ -1,6 +1,59 @@ -"""build(method, X, Y, sigma=None) -> Fit.""" -def build(method, X, Y, sigma=None, **kw): - method = method.lower() +"""build(method, X, Y, sigma=None, lnl_floor_delta=None) -> Fit.""" +import sys + +import numpy as np + + +def apply_lnl_floor(Y, delta): + """Clamp lnL from below at max(lnL) - delta, returning the clamped copy. + + RIFT elsewhere CUTS instead: `indx_ok = Y > np.max(Y) - opts.lnL_offset`. + Cutting is right when the discarded points are uninformative, but with + catastrophic-fit outliers (a failed waveform / failed radiative-transfer + model can land lnL at -1e9) it also discards the GEOMETRY of the known-bad + region: the surrogate is then fit only to the good ridge and has no idea + the cliff exists. Clamping keeps those points as anchors that still pin the + surrogate's length scale and signal variance -- which is what makes a GP's + sf^2 meaningful -- while removing the numerical damage of a -1e9 value. + + `delta=None` (the default everywhere) returns Y untouched, so the legacy + behaviour is bit-for-bit unchanged. + """ + if delta is None: + return Y + delta = float(delta) + if not np.isfinite(delta) or delta <= 0: + raise ValueError(f"lnl_floor_delta must be a positive finite number, " + f"got {delta!r}") + Yv = np.asarray(Y, dtype=float) + finite = np.isfinite(Yv) + if not finite.any(): + raise ValueError("lnl_floor_delta given but no finite lnL values") + # A floor cannot rescue +inf, and letting it through would fail downstream + # with a message telling the user to apply the floor they just applied. + n_posinf = int(np.sum(np.isposinf(Yv))) + if n_posinf: + raise ValueError( + f"lnl_floor_delta cannot handle {n_posinf} +inf lnL value(s): a " + "floor clamps from below only. A +inf likelihood is an upstream " + "bug, not an outlier to be tamed here.") + floor = float(np.max(Yv[finite])) - delta + # NaN and -inf both compare False here, so both are clamped to the floor: + # a failed evaluation is the same kind of anchor as a catastrophic one. + n_below = int(np.sum(~(Yv >= floor))) + n_nonfinite = int(np.sum(~finite)) + if n_below: + detail = f" ({n_nonfinite} of them non-finite)" if n_nonfinite else "" + sys.stderr.write( + f"fits.build: lnL floor at max-{delta:g} = {floor:.4g} clamped " + f"{n_below}/{len(Yv)} training point(s){detail} (kept as anchors " + f"rather than cut).\n") + return np.where(Yv >= floor, Yv, floor) + + +def build(method, X, Y, sigma=None, lnl_floor_delta=None, **kw): + method = method.lower().replace("-", "_") + Y = apply_lnl_floor(Y, lnl_floor_delta) if method == "rf": from ._rf import RandomForestFit return RandomForestFit(X, Y, sigma=sigma, **kw) @@ -13,4 +66,7 @@ def build(method, X, Y, sigma=None, **kw): if method == "polynomial": from ._polynomial import PolynomialFit return PolynomialFit(X, Y, sigma=sigma, **kw) + if method == "gp_linmean": + from ._gp_linmean import LinearMeanGPFit + return LinearMeanGPFit(X, Y, sigma=sigma, **kw) raise ValueError(f"unknown fit method {method!r}") diff --git a/MonteCarloMarginalizeCode/Code/RIFT/misc/tracer_placement/fits/_gp_linmean.py b/MonteCarloMarginalizeCode/Code/RIFT/misc/tracer_placement/fits/_gp_linmean.py new file mode 100644 index 000000000..12d5980d0 --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/RIFT/misc/tracer_placement/fits/_gp_linmean.py @@ -0,0 +1,268 @@ +"""Gaussian-process fit with a LINEAR mean function and a real posterior std. + +Why this exists (and why the mean function is not zero) +------------------------------------------------------ +The production default fit is the random forest (`_rf.py`). A forest is +piecewise-constant: outside the convex hull of the training points every tree +returns its boundary leaf, so the surrogate is exactly FLAT there +(`smooth_gradient = False`). When the lnL peak is clipped against a box edge -- +the grid was drawn too narrow and the likelihood is still rising as it leaves +the sampled region -- a flat surrogate gives placement nothing to chase, and +the next iteration re-piles points on the wall. + +A zero-mean GP is no better: away from data it relaxes to its prior mean, so +the surrogate falls back to 0 instead of following the trend. That failure mode +is already documented in CIP's own `--lnL-shift-prevent-overflow` help text +("If you shift the result to be below zero, because the GP relaxes to 0, you +will get crazy answers"). + +This fit therefore uses a LINEAR mean function: the GP kernel explains local +structure inside the sampled region, while the fitted hyperplane carries the +global trend outward. Extrapolation past the training hull follows that trend +rather than flattening, so UCB / SMC placement can chase a peak that lies +outside the region sampled so far. `mean="const"` is available for the +conservative behaviour (revert to a flat prior away from data); it is the +right choice when the trend is not believed and you would rather explore by +posterior variance alone. + +The GP also supplies a calibrated posterior variance, so this is the fit the +UCB sampler asks for (`_base.FitBase.predict_with_std`): sigma is small where +data constrains the surface and grows to the signal amplitude out in the +unsampled frontier. + +Ported from the R3 kilonova-placement study (`placement/propose_gp_resample.py`, +class `LinearMeanGP`), where the same construction was introduced to recover a +lnL peak clipped at the v_outer box edge. numpy-only: no sklearn or scipy +dependency, so this fit is usable in the same minimal environments the rest of +the tracer engine runs in. + +Cost is the usual dense-GP O(n^3) factorization / O(n^2) memory in the number +of training points, which is fine at the tracer's design size (10^2 - 10^3 +points per iteration) but is NOT a drop-in replacement for the forest on very +large unions; a warning is emitted past `_N_WARN`. +""" +import sys + +import numpy as np + +from ._base import FitBase + +# Above this training-set size the dense Cholesky starts to dominate the +# per-iteration cost of the placement tool; warn rather than refuse. +_N_WARN = 2000 + +# Rows per block when evaluating a candidate pool. The (n, chunk) kernel block +# is the peak allocation, so this bounds memory independently of how many +# candidates the caller hands us -- samplers.ucb routinely passes 2e4. +_CHUNK = 2048 + + +def _sqdist(A, B): + """Pairwise squared Euclidean distance |a|^2 + |b|^2 - 2 a.b. + + Written as three 2-D products rather than a (len(A), len(B), d) broadcast + so the candidate pools UCB hands us (~2e4 rows) stay in cache. + """ + d2 = (np.sum(A * A, axis=1)[:, None] + np.sum(B * B, axis=1)[None, :] + - 2.0 * (A @ B.T)) + return np.clip(d2, 0.0, None) + + +class LinearMeanGPFit(FitBase): + """RBF-kernel GP with a linear (or constant) mean, fit in a standardized basis. + + Parameters + ---------- + X : (n, d) array + Training coordinates, in the sampler's coordinate basis. + Y : (n,) array + Training lnL values. Must be finite -- see `--tracer-lnl-floor-delta` + (fits.build's `lnl_floor_delta`) for the supported way to tame + catastrophic-fit outliers, which also maps -inf onto the floor. + sigma : (n,) array, optional + Per-point lnL uncertainty, used as heteroscedastic observation noise. + `None` means "use `sigma_floor` everywhere" (a small nugget). + length_scale : float, optional + RBF length scale in the standardized basis. Default: median pairwise + distance / sqrt(2), the usual scale-free heuristic. + mean : {"linear", "const"} + Mean function. "linear" extrapolates the global trend past the data + edge (chases a clipped peak, but bets on the trend continuing); + "const" reverts to a flat prior away from data (conservative). + sigma_floor : float + Observation-noise floor, in lnL units. lnL uncertainties below this are + not meaningful in RIFT and drive the kernel matrix towards singularity. + jitter : float + Initial diagonal jitter added before the Cholesky. Escalated by + factors of 10 if the factorization fails. + """ + + has_uncertainty = True + smooth_gradient = True + + def __init__(self, X, Y, sigma=None, length_scale=None, mean="linear", + sigma_floor=1e-2, jitter=1e-8): + if mean not in ("linear", "const"): + raise ValueError(f"LinearMeanGPFit: mean must be 'linear' or " + f"'const', got {mean!r}") + X = np.atleast_2d(np.asarray(X, dtype=float)) + Y = np.asarray(Y, dtype=float).ravel() + if len(X) != len(Y): + raise ValueError(f"LinearMeanGPFit: X has {len(X)} rows but Y has " + f"{len(Y)} entries") + if len(X) < 2: + raise ValueError("LinearMeanGPFit: need at least 2 training points") + if not np.all(np.isfinite(X)) or not np.all(np.isfinite(Y)): + raise ValueError( + "LinearMeanGPFit: non-finite value in X or Y. Catastrophic-fit " + "lnL outliers should be tamed with fits.build(..., " + "lnl_floor_delta=...) (--tracer-lnl-floor-delta), which clamps " + "them to max(lnL) - delta instead of discarding them.") + n, self.d = X.shape + + # The linear mean has d+1 coefficients. Below that the lstsq solve is + # underdetermined and returns the minimum-norm hyperplane, which is an + # arbitrary choice among infinitely many that fit the data equally + # well -- and this fit exists precisely to EXTRAPOLATE along that + # hyperplane. Getting the trend's sign wrong out past the training hull + # is entirely possible, so say so rather than quietly placing on it. + if mean == "linear" and n <= self.d + 1: + how = ("underdetermined (minimum-norm solution; the extrapolation " + "direction is arbitrary)" if n < self.d + 1 else + "exactly determined (zero residual, so the kernel term " + "contributes nothing and the fit is a bare hyperplane)") + sys.stderr.write( + f"fits._gp_linmean: {n} training points for a {self.d}-D linear " + f"mean ({self.d + 1} coefficients) -- the mean function is {how}. " + f"Extrapolation past the training hull is not trustworthy here; " + f"use mean='const', or a fit that does not extrapolate, until " + f"there are more points.\n") + + if n > _N_WARN: + sys.stderr.write( + f"fits._gp_linmean: fitting a dense GP to {n} points " + f"(O(n^3) factorization, O(n^2) memory). Consider " + f"--tracer-fit-method rf for large unions.\n") + + # --- standardized basis: makes one isotropic length scale defensible + self._mu_x = X.mean(axis=0) + self._sd_x = X.std(axis=0) + self._sd_x[self._sd_x == 0] = 1.0 + Xs = (X - self._mu_x) / self._sd_x + + # --- mean function + A = np.column_stack([np.ones(n), Xs]) + self._beta = np.linalg.lstsq(A, Y, rcond=None)[0] + if mean == "const": + self._beta = np.zeros_like(self._beta) + self._beta[0] = float(Y.mean()) + self.mean_kind = mean + resid = Y - A @ self._beta + + # --- kernel hyperparameters + d2 = _sqdist(Xs, Xs) + if length_scale is None: + iu = np.triu_indices(n, 1) + med = float(np.median(np.sqrt(d2[iu]))) if len(iu[0]) else 1.0 + length_scale = max(med / np.sqrt(2.0), 1e-2) + length_scale = float(length_scale) + # Guard explicitly: ls=0 divides by zero and yields all-NaN predictions, + # and a negative ls is silently squared away into a DIFFERENT fit than + # the caller asked for. Both are silent-wrong, so refuse. + if not np.isfinite(length_scale) or length_scale <= 0: + raise ValueError(f"LinearMeanGPFit: length_scale must be a positive " + f"finite number, got {length_scale!r}") + self.length_scale = length_scale + # Signal variance is the residual scatter about the mean function. This + # is exactly where a lnL FLOOR beats a lnL CUT: floored known-bad points + # stay in the fit as anchors and keep sf2 (and the length scale) honest, + # where cutting them throws that geometry away. + self.sf2 = max(float(np.var(resid)), 1e-6) + + if sigma is None: + noise_var = np.full(n, sigma_floor ** 2) + else: + s = np.asarray(sigma, dtype=float).ravel() + s = np.where(np.isfinite(s), s, sigma_floor) + noise_var = np.maximum(s, sigma_floor) ** 2 + + # --- Cholesky, with escalating jitter on failure + K0 = self.sf2 * np.exp(-0.5 * d2 / self.length_scale ** 2) + self._L = None + for k in range(6): + K = K0.copy() + K[np.diag_indices_from(K)] += noise_var + jitter * (10.0 ** k) + try: + self._L = np.linalg.cholesky(K) + break + except np.linalg.LinAlgError: + continue + if self._L is None: + raise np.linalg.LinAlgError( + "LinearMeanGPFit: kernel matrix not positive-definite even " + f"with jitter {jitter * 1e5:g}; check for duplicate training " + "points or a degenerate coordinate.") + + # Form L^{-1} once. Every predict_with_std / grad call then costs + # O(m n^2) instead of re-solving (and re-factorizing) per call, which + # matters because samplers.ucb polishes each selected point one at a + # time. This is the same Cholesky solve, just staged. + self._Linv = np.linalg.solve(self._L, np.eye(n)) + self._alpha = self._Linv.T @ (self._Linv @ resid) + self._Xs = Xs + + self.train_rms = float(np.sqrt(np.mean((self.predict(X) - Y) ** 2))) + + # ------------------------------------------------------------------ # + + def _standardize(self, Z): + Z = np.atleast_2d(np.asarray(Z, dtype=float)) + return (Z - self._mu_x) / self._sd_x + + def _kstar(self, Zs): + return self.sf2 * np.exp(-0.5 * _sqdist(Zs, self._Xs) + / self.length_scale ** 2) + + def _mean_from(self, Zs, ks): + return (self._beta[0] + Zs @ self._beta[1:]) + ks @ self._alpha + + def predict(self, Z): + Zs = self._standardize(Z) + mean = np.empty(len(Zs)) + # Chunked for the same reason predict_with_std is: an unchunked + # (m, n) kernel block is the peak allocation, and at m=2e4 / n=1.5e3 + # that alone is enough to blow a modest Condor memory request. + for i0 in range(0, len(Zs), _CHUNK): + Zc = Zs[i0:i0 + _CHUNK] + mean[i0:i0 + _CHUNK] = self._mean_from(Zc, self._kstar(Zc)) + return mean + + def predict_with_std(self, Z): + """Return (mean, std): the GP posterior mean and standard deviation. + + std -> ~0 at well-constrained training points and -> sqrt(sf2) far from + any data, which is the behaviour samplers.ucb needs from + `mu + kappa * sigma`. + """ + Zs = self._standardize(Z) + mean = np.empty(len(Zs)) + var = np.empty(len(Zs)) + for i0 in range(0, len(Zs), _CHUNK): + Zc = Zs[i0:i0 + _CHUNK] + ks = self._kstar(Zc) + mean[i0:i0 + _CHUNK] = self._mean_from(Zc, ks) + v = self._Linv @ ks.T + var[i0:i0 + _CHUNK] = self.sf2 - np.sum(v * v, axis=0) + return mean, np.sqrt(np.maximum(var, 1e-12)) + + def grad(self, Z, eps=None): + """Analytic gradient of the posterior mean (eps is ignored).""" + Zs = self._standardize(Z) + out = np.empty_like(Zs) + for i0 in range(0, len(Zs), _CHUNK): + Zc = Zs[i0:i0 + _CHUNK] + # d/dZs_j [ks @ alpha] = -(1/ls^2) sum_i alpha_i ks_ij (Zs_j - Xs_ij) + Aa = self._kstar(Zc) * self._alpha[None, :] + term = Zc * Aa.sum(axis=1)[:, None] - Aa @ self._Xs + out[i0:i0 + _CHUNK] = self._beta[1:][None, :] - term / self.length_scale ** 2 + return out / self._sd_x diff --git a/MonteCarloMarginalizeCode/Code/RIFT/misc/tracer_placement/fits/_rf.py b/MonteCarloMarginalizeCode/Code/RIFT/misc/tracer_placement/fits/_rf.py index 4e5d1e6de..21f146b85 100644 --- a/MonteCarloMarginalizeCode/Code/RIFT/misc/tracer_placement/fits/_rf.py +++ b/MonteCarloMarginalizeCode/Code/RIFT/misc/tracer_placement/fits/_rf.py @@ -5,8 +5,9 @@ it is the empirical spread of the per-tree predictions, which is large in unexplored regions (because trees disagree on extrapolation) and small in well-sampled regions (because trees fit similar values). That qualitative -behavior is what UCB needs; for calibration use a GP fit when one becomes -available. +behavior is what UCB needs; for a calibrated posterior std, and for a surrogate +that can extrapolate past the training hull instead of going flat, use +--tracer-fit-method gp_linmean (_gp_linmean.py). """ import numpy as np from ._base import FitBase diff --git a/MonteCarloMarginalizeCode/Code/RIFT/misc/tracer_placement/samplers/ucb.py b/MonteCarloMarginalizeCode/Code/RIFT/misc/tracer_placement/samplers/ucb.py index 37226c1e1..4dc2df16b 100644 --- a/MonteCarloMarginalizeCode/Code/RIFT/misc/tracer_placement/samplers/ucb.py +++ b/MonteCarloMarginalizeCode/Code/RIFT/misc/tracer_placement/samplers/ucb.py @@ -160,8 +160,8 @@ def iterate(particles, *, surrogate, surrogate_prev=None, sys.stderr.write( "samplers.ucb: surrogate has no uncertainty estimate " "(predict_with_std returns zeros); UCB will degenerate to greedy " - "mean-maximization. Use --tracer-fit-method rf (tree disagreement) " - "or a GP fit if available.\n") + "mean-maximization. Use --tracer-fit-method gp_linmean (calibrated " + "GP posterior variance) or rf (tree disagreement).\n") # 1. Build candidate pool cand = _candidates(rng, X_in, prior_box, n_candidates) diff --git a/MonteCarloMarginalizeCode/Code/RIFT/simulation_manager/DESIGN.md b/MonteCarloMarginalizeCode/Code/RIFT/simulation_manager/DESIGN.md index 981779da7..5c4c42ec8 100644 --- a/MonteCarloMarginalizeCode/Code/RIFT/simulation_manager/DESIGN.md +++ b/MonteCarloMarginalizeCode/Code/RIFT/simulation_manager/DESIGN.md @@ -189,12 +189,39 @@ def same_q(params_a, params_b) -> bool: """Reflexive, symmetric, transitive equality on parameters. Defaults to exact equality (params_a == params_b).""" -def lookup_key(params) -> Hashable: - """Maps params to a coarse hashable bucket for fast dedup. +def lookup_key(params) -> "JSON-serializable": + """Maps params to a coarse bucket for fast dedup. Must be consistent with same_q: same_q(a, b) == True implies lookup_key(a) == lookup_key(b). Defaults to str(params).""" ``` +**`lookup_key` must be JSON-serializable, not merely hashable.** The +bucket key is a *persisted* value — written to `index.jsonl`, with the +dedup buckets rebuilt from that file on every `Archive` construction. So +the real requirement is that it survive the archive's JSON normalization +unchanged. That is both stricter and weaker than hashability: a +`frozenset` is hashable but cannot be persisted, while a plain `list` can +be persisted but is not hashable. + +The engine normalizes on the way in and canonicalizes the same way on the +way out, so these are handled rather than silently breaking dedup: + +* **tuples** — JSON has no tuple type, so a tuple key returns as a list; + both canonicalize to the same form. +* **dict keys** — JSON coerces them to strings, and not via `str()`: + `True`/`False`/`None` become `"true"`/`"false"`/`"null"`, float + infinities `"Infinity"`. Keys colliding once coerced + (`{True: 'a', "true": 'b'}`) collapse last-wins, consistently on both + sides. + +A key JSON cannot represent at all raises at `register()` with a message +naming this contract, rather than surfacing as a `sorted()` TypeError +from inside the index write. + +The safest choice is a **string**: it round-trips to itself, sorts, and +cannot collide by coercion. RIFT's own `gw_pe_synthetic` returns a tuple, +which the normalization handles. + These together give O(1) average lookup: bucket by `lookup_key`, then run `same_q` against only the (typically zero or one) entries in that bucket. The archive keeps an in-memory `{lookup_key: [sim_name, ...]}` @@ -556,6 +583,94 @@ OSG site-selection knobs (`+DESIRED_SITES`, `+UNDESIRED_SITES`, the manifest). The bindings appear verbatim as additional `key = value` lines in every per-(sim, level) submit description. +Because those lines are emitted **last**, a key that the queue already +writes would replace its line rather than extend it — and +`condor_submit` reports success either way. `transfer_input_files`, +`transfer_output_files`, `transfer_output_remaps` and +`periodic_release` are therefore refused in `extra_condor_cmds` +(case-insensitively; HTCondor command names are). Each has an +append-only alternative: + +| instead of `extra_condor_cmds[...]` | use | +|---|---| +| `transfer_input_files` | `extra_transfer_input_files` (appended) | +| `transfer_output_files` | `extra_transfer_output_files` (appended, `{level}`/`{sim_name}` substituted) | +| `periodic_release` | `extra_periodic_release` (OR'd in) | +| `request_memory` | the `request_memory` argument, or `Archive.set_resources` per sim | + +`extra_periodic_release` takes a single-line ClassAd expression for +sites whose pool holds jobs for reasons the queue does not model — an +opportunistic pool produces transient holds a dedicated cluster never +sees. While `auto_release_on_oom` is on, the term is scoped away from +whatever codes `oom_hold_codes` names, so `oom_max_retries` remains a +real cap and `request_memory` cannot be multiplied without bound; the +term governs every other hold code. With the OOM policy off it governs +all of them. + +### The OOM policy is site configuration + +`auto_release_on_oom` releases a job held for running out of memory and +raises its request. Three parts of that are properties of the **site**, +not of HTCondor, and are arguments rather than constants: + +| key | default | what it is | +|---|---|---| +| `oom_hold_codes` | `(34, 26)` | codes this site reports for a memory hold | +| `oom_hold_subcode_exclusions` | `{}` | `{code: [subcode, ...]}` to carve out | +| `oom_retry_counter` | `"NumJobStarts"` | expression rationing the retries | + +34 is the unambiguous memory code. **26 is `SystemPolicy`** — it means +whatever the site's `SYSTEM_PERIODIC_HOLD` expressions say it means. On +the clusters this policy came from that is usually memory; on an OSG +access point it may be an anti-thrash limiter whose precondition is a +high `NumJobStarts`, in which case releasing it with a bigger memory +request fights the pool's own protection. Sub-codes exist for the finer +case: every `SYSTEM_PERIODIC_HOLD` at a site reports one hold code, so +only the sub-code separates "over memory" from "restarted too many +times". + +The counter is site-dependent too. `NumJobStarts` counts execution +attempts, so preemption spends the budget. `NumHolds` counts holds of +every kind, including input-transfer failures that increment it while +the job has never run. Neither is "the number of memory holds" +everywhere. + +These are two alternatives, not one configuration: an exclusion on a +code `oom_hold_codes` does not list is refused, since it could not have +had any effect. Either disown 26 entirely — + +```python +DualCondorRunQueue( + auto_release_on_oom=True, + oom_hold_codes=(34,), # 26 means something else here + oom_retry_counter="NumHolds", +) +``` + +— or keep it and carve out the sub-codes the limiter reports: + +```python +DualCondorRunQueue( + auto_release_on_oom=True, + oom_hold_codes=(34, 26), + oom_hold_subcode_exclusions={26: (100, 101)}, # 26, minus the limiter + oom_retry_counter="NumHolds", +) +``` + +**Which site is which is not recorded here.** That belongs in whatever +inventory you already keep about your own infrastructure; a table of +site facts in this file would be stale immediately and wrong for every +site it did not name. `condor_config_val -dump | grep SYSTEM_PERIODIC_HOLD` +on the access point is what answers it. + +```python +DualCondorRunQueue( + auto_release_on_oom=True, + extra_periodic_release="(HoldReasonCode =!= 1) && (NumJobStarts < 50)", +) +``` + ## Hyperpipeline / glue.pipeline integration diff --git a/MonteCarloMarginalizeCode/Code/RIFT/simulation_manager/database.py b/MonteCarloMarginalizeCode/Code/RIFT/simulation_manager/database.py index 2fc7b79c2..a069899df 100644 --- a/MonteCarloMarginalizeCode/Code/RIFT/simulation_manager/database.py +++ b/MonteCarloMarginalizeCode/Code/RIFT/simulation_manager/database.py @@ -39,6 +39,8 @@ import json import logging import os +import warnings +from types import MappingProxyType import shutil import subprocess import sys @@ -46,7 +48,7 @@ import threading import time from pathlib import Path -from typing import Any, Callable, Dict, Iterable, Iterator, List, Optional, Tuple, Union +from typing import Any, Callable, Dict, Iterable, Iterator, List, Optional, Sequence, Tuple, Union, Mapping try: import fcntl # POSIX-only; archive multi-writer safety relies on flock(2) @@ -77,10 +79,23 @@ DEFAULT_GETENV_ALLOWLIST = "LD_LIBRARY_PATH,PATH,PYTHONPATH,*RIFT*,LIBRARY_PATH" -# Sentinel singletons used inside dedup buckets when a parameter set is -# unhashable (lookup_key returns e.g. a dict). We fall back to the -# string repr in that case. -def _safe_hashable(x: Any) -> Any: +def _freeze(x: Any) -> Any: + """Recursively map a JSON-shaped value onto a hashable one. + + Lists and tuples collapse onto the same tuple form. Dicts become a + tuple of (key, frozen-value) pairs sorted by key. Anything still + unhashable falls back to the repr sentinel. + """ + if isinstance(x, (list, tuple)): + return tuple(_freeze(v) for v in x) + if isinstance(x, dict): + # Sort by key alone: after _safe_hashable's JSON pass the keys + # are strings and unique, and sorting on the pair could otherwise + # try to order two frozen values of unrelated types. + return tuple(sorted( + ((str(k), _freeze(v)) for k, v in x.items()), + key=lambda kv: kv[0], + )) try: hash(x) return x @@ -88,6 +103,333 @@ def _safe_hashable(x: Any) -> Any: return ("__unhashable__", repr(x)) +# Canonicalize a lookup_key into something hashable AND identical to what +# comes back out of index.jsonl, because dedup buckets are rebuilt from +# that file on every Archive construction — which makes the bucket key a +# persisted value. +# +# Getting this wrong is silent: the rehydrated bucket key stops matching +# the freshly-computed one, find_existing misses, and register() mints a +# duplicate sim for physics the archive already holds. The caller just +# pays twice, from the second session onward, with nothing in the logs. +# +# Rather than model JSON's coercion rules by hand, we run the value +# through an actual JSON round-trip first, so the canonical form matches +# the persisted form *by construction*. That covers, in one step, every +# way the two could otherwise diverge: +# +# * tuples, which JSON has no type for, coming back as lists; +# * dict keys, which JSON coerces to strings — and not via str(): +# True/False/None serialize as "true"/"false"/"null", and float +# infinities as "Infinity", none of which str() reproduces; +# * dict keys that collide once coerced ({True: 'a', "true": 'b'}), +# which JSON collapses last-wins — applying the same round-trip +# means fresh and rehydrated agree on the survivor instead of +# disagreeing about how many entries there are. +# +# Values that JSON cannot represent at all (a tuple used as a dict key, +# say) fall through to _freeze on the original. Such a lookup_key could +# not have been persisted in the first place, so there is no rehydrated +# form for it to disagree with. +# +# Collisions this introduces between distinct inputs — a list and the +# equal tuple, say — are harmless: buckets only nominate same_q +# candidates, and same_q still makes the decision. +def _json_normalized(x: Any) -> Any: + """The value as it will exist after a round-trip through index.jsonl. + + This is the form that must be *stored*, not merely the form used for + bucketing. Normalizing only at bucket time is not enough: the index + row keeps whatever `lookup_key` returned, and `Index._write_all` + serializes rows with ``sort_keys=True``. A dict key set that JSON + would coerce to strings is still raw at that point, so a key like + ``{True: 'a', 'true': 'b'}`` reaches `sorted()` as a bool beside a + str and raises + + TypeError: '<' not supported between instances of 'str' and 'bool' + + from inside `register`. Normalizing on the way in makes the stored + value sortable and makes persisted and canonical forms identical by + construction. + """ + try: + return json.loads(json.dumps(x)) + except (TypeError, ValueError, RecursionError): + return x + + +def _safe_hashable(x: Any) -> Any: + return _freeze(_json_normalized(x)) + + +def _require_persistable_lookup_key(key: Any) -> Any: + """Normalize a lookup_key for storage, or say clearly why it cannot be. + + Backends control `lookup_key`, and a value JSON cannot represent — + a set, a frozenset, a tuple used as a dict key — cannot live in + index.jsonl at all. Catching it here names the contract instead of + surfacing a json/sorted TypeError from deep in the write path. + """ + try: + json.dumps(key) + except (TypeError, ValueError) as exc: + raise TypeError( + "lookup_key must be JSON-serializable so it can be persisted in " + "index.jsonl and compared after reopen; got {!r} ({}). Return a " + "string, number, or a list/dict of them.".format(key, exc) + ) from exc + return _json_normalized(key) +def _reject_reserved_basename(entry: str, what: str) -> None: + """Refuse an entry whose basename shadows a file the archive stages. + + Condor flattens basenames into the sandbox cwd, so on the input side + this would overwrite the archive's own copy on the worker. On the + OUTPUT side it is worse: the remap points back at sims//, so a + returned `params.json` overwrites the sim's recorded inputs in the + archive itself, corrupting state every later level reads. + """ + base = entry.rstrip("/").rsplit("/", 1)[-1] + if base in _RESERVED_SANDBOX_BASENAMES or ( + base.startswith("level_") and base.endswith(".json")): + raise ValueError( + "{}: {!r} has basename {!r}, which collides with a file the " + "archive itself stages or writes.".format(what, entry, base)) + + +def _reject_duplicate_basenames(entries: Sequence[str], what: str) -> None: + """Refuse two entries that flatten to the same sandbox filename. + + Condor flattens basenames into the job's cwd, so + `osdf:///siteA/data.h5` and `osdf:///siteB/data.h5` are two different + objects that land on top of each other. The reserved-name check does + not see this: neither entry collides with anything the archive + stages, only with the other one. + """ + seen = {} + for entry in entries: + base = str(entry).rstrip("/").rsplit("/", 1)[-1] + if base in seen: + # Identical entries count too: naming the same file twice is + # at best a wasted transfer of a multi-GB object, and on the + # output side it emits a duplicate remap pair. Two templates + # that expand to the same name land here as equal strings. + raise ValueError( + "{}: {!r} and {!r} both resolve to {!r} in the job sandbox, " + "so one would overwrite the other on the worker.".format( + what, seen[base], entry, base)) + seen[base] = str(entry) + + +def _validate_hold_codes(value: Any, *, what: str) -> Tuple[int, ...]: + """Hold codes naming the condition a policy acts on. + + Deliberately data rather than an expression: these round-trip + through the manifest as JSON, and they are what a site operator + reads off their own infrastructure record. Order is preserved so + the emitted expression is stable across runs. + """ + if value is None: + return () + if isinstance(value, (str, bytes)) or not isinstance(value, Iterable): + raise TypeError( + "{0} must be a sequence of integer hold codes, got {1!r}".format( + what, type(value).__name__)) + codes = [] + for entry in value: + if isinstance(entry, bool) or not isinstance(entry, int): + # bool is an int subclass and `True` would silently become 1. + raise TypeError( + "{0} entries must be integer hold codes, got {1!r}".format( + what, entry)) + if entry not in codes: + codes.append(entry) + return tuple(codes) + + +def _validate_subcode_exclusions(value: Any, *, what: str + ) -> Dict[int, Tuple[int, ...]]: + """Sub-codes to carve out of a hold code, as {code: (subcode, ...)}. + + A hold code says which subsystem held the job; the sub-code says + why. `SYSTEM_PERIODIC_HOLD` is the case that forces this to exist -- + every site expression it evaluates produces the same hold code, and + only the sub-code distinguishes "over memory" from "restarted too + many times". + + Keys are coerced from str, because JSON has no integer keys and + these arrive back from the manifest as strings. Skipping that turns + a configured exclusion into a silently ignored one after a round + trip, which is the same class of bug as a lookup_key that is not + JSON-stable. + """ + if value is None: + return {} + if not isinstance(value, Mapping): + raise TypeError( + "{0} must be a mapping of {{hold_code: [subcode, ...]}}, got " + "{1!r}".format(what, type(value).__name__)) + out: Dict[int, Tuple[int, ...]] = {} + for key, subs in value.items(): + if isinstance(key, bool): + raise TypeError("{0} keys must be hold codes".format(what)) + if isinstance(key, str): + try: + key = int(key) + except ValueError: + raise TypeError( + "{0} key {1!r} is not a hold code".format(what, key)) + if not isinstance(key, int): + raise TypeError( + "{0} keys must be hold codes, got {1!r}".format(what, key)) + out[key] = _validate_hold_codes( + subs, what="{0}[{1}]".format(what, key)) + return out + + +def _validate_release_expression(value: Any, *, what: str) -> str: + """Check a ClassAd expression destined for a submit command. + + The expression is not parsed. The HTCondor python bindings are + optional here, and a check that runs only where they happen to be + installed is worse than no check at all: it moves the failure off + the author's machine and onto someone else's. condor_submit rejects + a malformed expression, loudly, at submit time. + + What is checked is the part that is not the author's own mistake to + make. A newline ends a submit command, so a value carrying one -- + from a manifest, a config file, a `run_queue.extra` dict written by + another tool -- would have its remainder read as further submit + commands, free to set `getenv = True` or replace + transfer_output_files. That is refused. + """ + if value is None: + return "" + if not isinstance(value, str): + raise TypeError( + "{0} must be a string ClassAd expression, got {1!r}".format( + what, type(value).__name__)) + text = value.strip() + if not text: + return "" + if "\n" in text or "\r" in text: + raise ValueError( + "{0} must be a single line: a newline would end the submit " + "command and let the rest of the value be read as further " + "commands".format(what)) + return text + + +def _validate_transfer_entries(entries: Any, *, what: str, + remap_syntax: bool = False) -> List[str]: + """Check a backend-supplied transfer list, or say why it is unusable. + + Every rejection here is something HTCondor accepts without complaint + and then gets wrong on a remote worker, which is the worst place to + find out. `condor_submit` exits 0 for all of them. + + * a bare string is a Sequence[str], so it iterates as CHARACTERS + and becomes one transfer request per letter. This is the likeliest + operator mistake and the type annotation invites it. + * transfer_input_files is comma-separated, so an entry containing a + comma silently splits into two bogus entries. URLs with query + strings hit this routinely. + * a newline ends the submit command, so the remainder becomes its + own submit line. Later duplicates win in Condor, so a stray + newline can silently override request_memory, the executable, or + the output remaps. + """ + if entries is None: + return [] + if isinstance(entries, (str, bytes)): + raise TypeError( + "{} must be a list of entries, not a bare string: a string is a " + "Sequence[str] and would iterate as one transfer request per " + "character. Wrap it: [{!r}].".format(what, entries)) + out: List[str] = [] + for entry in entries: + text = str(entry) + if not text.strip(): + raise ValueError("{}: empty entry".format(what)) + bad_chars = [(",", "separates entries in the transfer list"), + ("\n", "ends the submit command"), + ("\r", "ends the submit command")] + if remap_syntax: + # transfer_output_remaps is a ';'-separated list of name=path + # pairs, so either character makes the remap unparseable. + bad_chars += [(";", "separates pairs in transfer_output_remaps"), + ("=", "separates name from path in " + "transfer_output_remaps")] + for bad, why in bad_chars: + if bad in text: + raise ValueError( + "{}: entry {!r} contains {!r}, which {}. HTCondor accepts " + "the submit file and the job fails later on the execute " + "host.".format(what, text, bad, why)) + out.append(text) + return out + + +#: Hold codes this class treats as "the job ran out of memory", and the +#: attribute that rations retries. Both are DEFAULTS, not facts: what a +#: hold code means is a property of the site, not of HTCondor. 34 is the +#: unambiguous memory code; 26 is SystemPolicy, which means whatever the +#: site's SYSTEM_PERIODIC_HOLD expressions say it means -- on the LIGO +#: clusters this policy was written for that is usually memory, and on +#: an OSG access point it is as likely to be an anti-thrash limiter +#: whose precondition is a high NumJobStarts. Sites that differ pass +#: oom_hold_codes / oom_hold_subcode_exclusions / oom_retry_counter +#: rather than editing this. +#: +#: Deliberately NOT recorded here: which sites differ, and how. That +#: belongs in whatever inventory the operator already keeps about their +#: own infrastructure. A table of site facts in shared code is stale the +#: day after it is written and wrong for everyone it does not name. +DEFAULT_OOM_HOLD_CODES = (34, 26) +DEFAULT_OOM_RETRY_COUNTER = "NumJobStarts" + + +#: Submit commands the archive composes itself. A backend that sets any +#: of these through extra_condor_cmds replaces the archive's line rather +#: than extending it, because extra_condor_cmds is emitted last. Stored +#: casefolded: HTCondor command names are case-insensitive, so the guard +#: has to be too. +_PROTECTED_SUBMIT_COMMANDS = frozenset({ + "transfer_input_files", "transfer_output_files", "transfer_output_remaps", + # periodic_release joined this set when extra_periodic_release gave it + # a supported additive alternative. Setting it here replaced the + # queue's line and silently discarded the auto_release_on_oom memory + # policy -- the exact bug the additive hook exists to remove, which + # would otherwise stay reachable, unguarded, right beside the fix. + "periodic_release", + # request_memory is the other half of the same policy. Replacing it + # leaves periodic_release intact, so the job is released the full + # oom_max_retries times at a fixed size and OOMs every time -- it + # spends the whole budget achieving nothing, which is a worse end + # than losing the release arm. Per-sim sizes go through + # Archive.set_resources, which composes rather than substitutes. + "request_memory", +}) + +#: What to use instead of each refused key. Kept beside the set so a new +#: entry cannot be added without answering "and what should they do?" -- +#: a guard that refuses without a remedy just moves the dead end. +_PROTECTED_ALTERNATIVES = { + "transfer_input_files": "extra_transfer_input_files, which appends", + "transfer_output_files": "extra_transfer_output_files, which appends", + "transfer_output_remaps": "extra_transfer_output_files, whose entries " + "accept remap syntax", + "periodic_release": "extra_periodic_release, which is OR'd into the " + "expression instead of replacing it", + "request_memory": "the request_memory argument, or " + "Archive.set_resources for a per-sim override", +} + +#: Basenames the archive itself stages into the worker sandbox. Condor +#: flattens transferred basenames into cwd, so a backend input sharing one +#: of these silently clobbers it on the worker. +_RESERVED_SANDBOX_BASENAMES = ("code", "params.json") + + def _default_same_q(a: Any, b: Any) -> bool: return a == b @@ -594,6 +936,13 @@ def register(self, params: Any, target_level: int = 1, if existing is not None: self._maybe_bump_target(existing, target_level) return existing + # Compute and validate the key BEFORE allocating a name or + # writing anything. Validating after the mkdir left sims// + # with params.json and status.json behind when the key turned + # out to be unpersistable — a half-registered simulation that + # the index has never heard of, and that the next register() + # will silently allocate around. + lk = _require_persistable_lookup_key(self._lookup_key(params)) if name is None: name = str(len(list((self.base / "sims").iterdir())) + 1) sd = self.sim_dir(name) @@ -602,7 +951,6 @@ def register(self, params: Any, target_level: int = 1, (sd / "params.json").write_text(json.dumps(params) + "\n") rec = StatusRecord.new(name, params, target_level=target_level) rec.write(sd) - lk = self._lookup_key(params) self.index.upsert({"name": name, "params": params, "status": "ready", "summary": None, "lookup_key": lk, @@ -961,8 +1309,15 @@ def rebuild_index(self) -> int: "params": params, "status": rec.data.get("status"), "summary": summary, - "lookup_key": (self._lookup_key(params) - if params is not None else None), + # Normalized exactly as register() does. Storing the + # raw key here meant an archive that registered and + # reopened cleanly still blew up in rebuild_index with + # the original sorted() TypeError, because _write_all + # serializes rows with sort_keys=True. + "lookup_key": ( + _require_persistable_lookup_key( + self._lookup_key(params)) + if params is not None else None), "target_level": rec.data.get("target_level", 0), "current_level": rec.data.get("current_level", 0), } @@ -1327,11 +1682,76 @@ class DualCondorRunQueue(RunQueue): explicitly only on sites that allow it. use_singularity : bool singularity_image: str -- required if use_singularity=True + oom_hold_codes : seq -- hold codes this site reports when a + job runs out of memory. Default + DEFAULT_OOM_HOLD_CODES = (34, 26). 34 + is unambiguous; 26 is SystemPolicy and + means whatever the site's + SYSTEM_PERIODIC_HOLD expressions say, + which elsewhere may be an anti-thrash + limiter rather than memory. + oom_hold_subcode_exclusions: {code: [subcode, ...]} -- sub-codes + to carve out of a code above. Needed + because every SYSTEM_PERIODIC_HOLD at + a site reports one hold code and only + the sub-code separates "over memory" + from "restarted too many times". A + sub-code keyed on a code not listed in + oom_hold_codes is refused rather than + ignored. + oom_retry_counter: str -- ClassAd expression rationing the + retries and scaling the bump. Default + DEFAULT_OOM_RETRY_COUNTER = + "NumJobStarts". NumHolds is the other + obvious choice and is not better + everywhere: it counts holds of every + kind, including transfer failures that + increment it without the job ever + running. + extra_periodic_release: str -- a ClassAd expression OR'd into + periodic_release alongside the OOM + policy, for sites that hold jobs for + reasons this class does not model. + While auto_release_on_oom is on, the + term is scoped away from whatever + codes oom_hold_codes names, so + oom_max_retries stays a real cap and + the term governs every other code. + With the OOM policy off it governs all + of them. Setting periodic_release + through extra_condor_cmds is refused + -- it replaced the whole expression and + dropped the memory handling with it. extra_condor_cmds: dict -- additional `key = value` lines appended verbatim to the submit description (e.g. +DESIRED_SITES, +UNDESIRED_SITES for OSG site selection, requirements clauses). + extra_transfer_input_files: list -- extra entries APPENDED to + transfer_input_files for every job. + Intended for bulk inputs addressed + by URL (osdf://, http://) so they + are fetched from a cache instead of + staged through the submit host's + spool. Setting `transfer_input_files` + via extra_condor_cmds would instead + *replace* the archive's own entries + and strip the frozen code/ directory, + leaving the worker nothing to run. + extra_transfer_output_files: list -- products to bring BACK + beyond the level_.json marker, + named relative to the job sandbox. + `{level}` and `{sim_name}` are + substituted, so e.g. "level_{level}" + returns a per-level output directory. + Each is remapped to the same relative + path under sims//. + transfer_output_files is explicit, so + without this HTCondor returns only the + marker and everything else the worker + produced dies with the sandbox — the + job completes having discarded its + own results. The defaults above also apply when DualCondorRunQueue is instantiated via make_queues_from_manifest() — keys absent from @@ -1351,7 +1771,13 @@ def __init__(self, use_singularity: bool = False, singularity_image: Optional[str] = None, extra_condor_cmds: Optional[Dict[str, str]] = None, + extra_transfer_input_files: Optional[Sequence[str]] = None, + extra_transfer_output_files: Optional[Sequence[str]] = None, auto_release_on_oom: bool = True, + extra_periodic_release: Optional[str] = None, + oom_hold_codes: Optional[Sequence[int]] = None, + oom_hold_subcode_exclusions: Optional[Mapping[int, Sequence[int]]] = None, + oom_retry_counter: Optional[str] = None, oom_max_retries: int = 5, oom_memory_factor: float = 1.5, subdag_factory: Optional[Callable[[Any, str, int], str]] = None, @@ -1359,6 +1785,18 @@ def __init__(self, **submit_kwargs: Any): self.run_pool = run_pool self.run_collector = run_collector + self.extra_transfer_input_files = extra_transfer_input_files + self.extra_transfer_output_files = extra_transfer_output_files + if (self.extra_transfer_input_files or self.extra_transfer_output_files) \ + and subdag_factory is not None: + # Fail early for the common case. submit() re-checks, because + # both of these are plain attributes and assigning either after + # construction reaches the same silently-ignoring path. + raise ValueError( + "extra_transfer_{input,output}_files are applied by " + "build_worker, which is bypassed when subdag_factory is set: " + "the sub-DAG owns its own submit descriptions. Put the extra " + "entries in the sub-DAG the factory generates instead.") self.request_memory = int(request_memory) self.request_disk = request_disk self.accounting_group = accounting_group or os.environ.get("LIGO_ACCOUNTING") @@ -1372,6 +1810,13 @@ def __init__(self, self.singularity_image = singularity_image self.extra_condor_cmds = extra_condor_cmds or {} self.auto_release_on_oom = bool(auto_release_on_oom) + self.extra_periodic_release = extra_periodic_release + self.oom_hold_codes = (DEFAULT_OOM_HOLD_CODES if oom_hold_codes is None + else oom_hold_codes) + self.oom_hold_subcode_exclusions = oom_hold_subcode_exclusions + self.oom_retry_counter = (DEFAULT_OOM_RETRY_COUNTER + if oom_retry_counter is None + else oom_retry_counter) self.oom_max_retries = int(oom_max_retries) self.oom_memory_factor = float(oom_memory_factor) # Per-(sim, level) work-unit factory. When set, each level emits @@ -1392,11 +1837,166 @@ def __init__(self, .format(submit_mode)) self.submit_mode = submit_mode self.submit_kwargs = submit_kwargs + if submit_kwargs: + # submit_kwargs is stored and never read. Silence here makes + # the manifest a one-way hatch across versions: a RIFT that + # predates a key lands it in here and submits under different + # policy than the archive was built with, with nothing in the + # log. That is the same silent-substitution failure the + # transfer and periodic_release guards exist to stop, on the + # version axis instead of the config one. + warnings.warn( + "DualCondorRunQueue ignoring unrecognised option(s) {0}. " + "If these came from a manifest's run_queue.extra, this " + "RIFT is older than the archive and the jobs will submit " + "under different policy than intended.".format( + ", ".join(sorted(map(repr, submit_kwargs)))), + RuntimeWarning, stacklevel=2) # Per-archive state. self.dag_cluster_id: Optional[int] = None self.last_wrapper_dag_path: Optional[str] = None # -------- per-(sim, level) submit description -------------------------- + + # These are validated on ASSIGNMENT, not only in __init__. Checking + # once at construction is not protection: they are ordinary public + # attributes, and configuring a queue by assigning to them after the + # fact is the natural thing to do — which walked straight past every + # guard. + @property + def extra_transfer_input_files(self) -> Tuple[str, ...]: + # A tuple, not the live list: returning the list let a caller do + # `q.extra_transfer_input_files.append("/bad,entry")`, which never + # goes through the setter and so skipped every check. Handing back + # something immutable makes that attempt fail at the append. + return tuple(self._extra_transfer_input_files) + + @extra_transfer_input_files.setter + def extra_transfer_input_files(self, value: Any) -> None: + entries = _validate_transfer_entries( + value, what="extra_transfer_input_files") + for entry in entries: + _reject_reserved_basename(entry, "extra_transfer_input_files") + self._extra_transfer_input_files = entries + + @property + def extra_transfer_output_files(self) -> Tuple[str, ...]: + return tuple(self._extra_transfer_output_files) + + @extra_transfer_output_files.setter + def extra_transfer_output_files(self, value: Any) -> None: + entries = _validate_transfer_entries( + value, what="extra_transfer_output_files", remap_syntax=True) + for entry in entries: + _reject_reserved_basename(entry, "extra_transfer_output_files") + self._extra_transfer_output_files = entries + + @property + def extra_periodic_release(self) -> str: + return self._extra_periodic_release + + @extra_periodic_release.setter + def extra_periodic_release(self, value: Any) -> None: + self._extra_periodic_release = _validate_release_expression( + value, what="extra_periodic_release") + + @property + def oom_hold_codes(self) -> Tuple[int, ...]: + return self._oom_hold_codes + + @oom_hold_codes.setter + def oom_hold_codes(self, value: Any) -> None: + # None means "the default", as it does in the constructor and for + # oom_retry_counter. Reading it as "own no codes" would let + # `q.oom_hold_codes = None` disable the memory policy outright, + # which is a thing to have to ask for -- pass () for that. + codes = (DEFAULT_OOM_HOLD_CODES if value is None + else _validate_hold_codes(value, what="oom_hold_codes")) + # Checked against the CANDIDATE, before it is stored. Assigning + # first and validating after leaves the queue holding the value + # the check just rejected, so a caller who catches the ValueError + # submits under it anyway -- the raise reads as "nothing changed" + # and is not. + self._reject_orphan_subcode_exclusions( + codes, getattr(self, "_oom_hold_subcode_exclusions", None)) + self._oom_hold_codes = codes + + @property + def oom_hold_subcode_exclusions(self) -> Mapping[int, Tuple[int, ...]]: + # A read-only view, not a copy: a copy makes + # `q.oom_hold_subcode_exclusions[26] = (100,)` a silent no-op, + # where this makes it raise. Same reasoning as the transfer + # properties handing back tuples rather than live lists. + return MappingProxyType(self._oom_hold_subcode_exclusions) + + @oom_hold_subcode_exclusions.setter + def oom_hold_subcode_exclusions(self, value: Any) -> None: + exclusions = _validate_subcode_exclusions( + value, what="oom_hold_subcode_exclusions") + # Same order as oom_hold_codes: check the candidate, then store. + self._reject_orphan_subcode_exclusions( + getattr(self, "_oom_hold_codes", None), exclusions) + self._oom_hold_subcode_exclusions = exclusions + + @staticmethod + def _reject_orphan_subcode_exclusions( + codes: Optional[Sequence[int]], + orphans: Optional[Mapping[int, Sequence[int]]]) -> None: + """An exclusion on a code the policy does not own does nothing. + + Silently ignoring it means a typo'd key reads as configured and + has no effect -- the site believes it has carved out its + anti-thrash sub-code and has not. Takes the pair to check as + arguments rather than reading the attributes, so each setter can + call it before assigning: a failed assignment then leaves the + previous policy in place. `codes is None` is the constructor's + first setter running before the other attribute exists. + """ + if codes is None or not orphans: + return + unknown = sorted(k for k in orphans if k not in codes) + if unknown: + raise ValueError( + "oom_hold_subcode_exclusions names hold code(s) {0} that " + "oom_hold_codes does not include ({1}), so the exclusion " + "would have no effect".format( + ", ".join(map(str, unknown)), + ", ".join(map(str, codes)) or "none")) + + @property + def oom_retry_counter(self) -> str: + return self._oom_retry_counter + + @oom_retry_counter.setter + def oom_retry_counter(self, value: Any) -> None: + self._oom_retry_counter = _validate_release_expression( + value, what="oom_retry_counter") or DEFAULT_OOM_RETRY_COUNTER + + def _oom_hold_predicate(self, code_attr: str, subcode_attr: str) -> str: + """"This hold is one the OOM policy owns", as a ClassAd expression. + + Built twice per submit description against different attributes: + periodic_release asks about the CURRENT hold, request_memory about + the LAST one. Same policy, two vantage points -- which is why this + is a builder and not a string the caller supplies ready-made. + """ + terms = [] + for code in self._oom_hold_codes: + term = "({0} =?= {1})".format(code_attr, code) + excluded = self._oom_hold_subcode_exclusions.get(code) or () + if excluded: + term = "({0}{1})".format(term, "".join( + " && ({0} =!= {1})".format(subcode_attr, sub) + for sub in excluded)) + terms.append(term) + if not terms: + # No codes configured means the policy owns nothing. Emit a + # constant rather than an empty string, so the surrounding + # expression stays well-formed instead of becoming a parse + # error at submit time. + return "false" + return " || ".join(terms) + def _bootstrap_path(self, archive: Archive) -> Path: path = archive.base / "run_queue" / "workers" / "bootstrap.py" path.parent.mkdir(parents=True, exist_ok=True) @@ -1428,6 +2028,27 @@ def build_worker(self, archive: Archive, sim_name: str, request_disk = res.get("request_disk", self.request_disk) extra_cmds = dict(self.extra_condor_cmds) extra_cmds.update(res.get("extra_condor_cmds") or {}) + # extra_condor_cmds is emitted last, so these would REPLACE the + # lines built above rather than extend them — dropping the frozen + # code/ directory, the sim's params, or the output remaps, with + # condor_submit reporting success either way. + # Compared case-insensitively: HTCondor submit command names are + # case-insensitive, so `Transfer_Input_Files` is the same directive + # as `transfer_input_files` and an exact lowercase match let it + # straight through — reinstating the very substitution this guard + # exists to prevent, with the frozen code/ and params.json silently + # dropped. `extra_cmds` is the merged dict, so per-sim overrides + # from Archive.set_resources are covered by the same pass. + for _key in extra_cmds: + if str(_key).strip().casefold() in _PROTECTED_SUBMIT_COMMANDS: + raise ValueError( + "extra_condor_cmds must not set {0!r}: it is emitted " + "after the archive's own line, so it replaces that line " + "rather than extending it (compared case-insensitively, " + "because HTCondor command names are). Use {1} " + "instead.".format(_key, _PROTECTED_ALTERNATIVES.get( + str(_key).strip().casefold(), + "the corresponding append-only option"))) bootstrap = self._bootstrap_path(archive) log_dir = archive.base / "run_queue" / "logs" @@ -1442,6 +2063,24 @@ def build_worker(self, archive: Archive, sim_name: str, out_base, out_target = archive.expected_output(sim_name, level) transfer_in = [str(archive.base / "code"), str(sd / "params.json")] + prev_paths + # Backend-supplied inputs every job also needs — typically bulk + # objects addressed by URL (osdf://, http://) so they come from a + # cache rather than the submit host's spool. Appended, never + # substituted: dropping the entries above would leave the worker + # with no frozen code to run. + # Re-validated here, not merely at assignment. The output side + # already did this; the input side trusted whatever the attribute + # happened to hold, so writing to the private backing attribute + # reached a submit file with a comma-split entry or an injected + # submit command. Validate what we are about to emit. + extra_in = _validate_transfer_entries( + self._extra_transfer_input_files, + what="extra_transfer_input_files (at submit)") + for entry in extra_in: + _reject_reserved_basename( + entry, "extra_transfer_input_files (at submit)") + transfer_in += extra_in + _reject_duplicate_basenames(transfer_in, "transfer_input_files") lines: List[str] = [ "# Auto-generated by RIFT.simulation_manager.database." @@ -1459,30 +2098,134 @@ def build_worker(self, archive: Archive, sim_name: str, lines.append("transfer_input_files = {}".format(",".join(transfer_in))) lines.append("should_transfer_files = YES") lines.append("when_to_transfer_output = ON_EXIT") - lines.append("transfer_output_files = {}".format(out_base)) - lines.append('transfer_output_remaps = "{}={}"'.format(out_base, out_target)) + # Backend-supplied products, beyond the level_.json marker. + # transfer_output_files is explicit, so HTCondor returns ONLY what + # is named here: anything else the worker wrote is destroyed with + # the sandbox. A backend whose science *is* output files (rather + # than a single JSON marker) has to be able to name them, or its + # jobs complete having thrown their results away. + out_names = [out_base] + out_remaps = ["{}={}".format(out_base, out_target)] + # Validate the raw COLLECTION first, exactly as the input side + # does. Iterating the property alone was not symmetric: a bare + # str reaching the backing attribute tuple()s into one entry per + # character, and each single character then passes the per-entry + # checks cleanly — so build_worker emitted + # `transfer_output_files = level_1.json,w,x,y,z` and returned + # successfully, instead of raising the way the input side does. + for entry in _validate_transfer_entries( + self._extra_transfer_output_files, + what="extra_transfer_output_files (at submit)", + remap_syntax=True): + try: + name = str(entry).format(level=int(level), sim_name=sim_name) + except (KeyError, IndexError, AttributeError) as exc: + raise ValueError( + "extra_transfer_output_files: {!r} uses an unknown " + "placeholder {}; only {{level}} and {{sim_name}} are " + "substituted.".format(entry, exc)) from None + # Re-validate AFTER substitution: the checks at assignment saw + # the template, and expansion can introduce a space or a path + # separator that HTCondor's transfer list cannot express. + _validate_transfer_entries([name], + what="extra_transfer_output_files " + "(after substitution)", + remap_syntax=True) + _reject_reserved_basename( + name, "extra_transfer_output_files (after substitution)") + if " " in name or "/" in name: + raise ValueError( + "extra_transfer_output_files: {!r} expands to {!r}; " + "HTCondor transfer lists cannot express a space or a " + "path separator in an entry.".format(entry, name)) + out_names.append(name) + out_remaps.append("{}={}".format(name, sd / name)) + _reject_duplicate_basenames(out_names, "transfer_output_files") + lines.append("transfer_output_files = {}".format(",".join(out_names))) + lines.append('transfer_output_remaps = "{}"'.format(";".join(out_remaps))) lines.append("getenv = {}".format(self.getenv)) + release_terms = [] if self.auto_release_on_oom: - # Stuart's catch-and-release pattern. On hold codes 26 - # (OUT_OF_MEMORY) or 34 (MEMORY_LIMIT_EXCEEDED), bump - # request_memory by oom_memory_factor and release the job. - # After oom_max_retries the job stays held and we let the - # archive's stuck-detection take over. + # Stuart's catch-and-release pattern: on a hold this site + # calls "out of memory", bump request_memory by + # oom_memory_factor and release. After oom_max_retries the job + # stays held and the archive's stuck-detection takes over. + # + # Which holds those are, and what counts the retries, come from + # oom_hold_codes / oom_hold_subcode_exclusions / + # oom_retry_counter. See DEFAULT_OOM_HOLD_CODES for why they + # cannot be constants. + was_oom = self._oom_hold_predicate( + "LastHoldReasonCode", "LastHoldReasonSubCode") + is_oom = self._oom_hold_predicate( + "HoldReasonCode", "HoldReasonSubCode") lines.append("MY.InitialRequestMemory = {}".format(request_memory)) + # MemoryUsage is the attribute here that can actually be + # undefined: in the job ad it is itself an expression over + # ResidentSetSize, which a job held before it ever executed + # does not have. int(factor * n * undefined) is undefined, an + # undefined request_memory matches no slot, and the job then + # sits Idle with nothing in its log to say why. Fall back to + # the original request: released unchanged it may hold again, + # but the retry cap bounds that, whereas never matching is + # bounded by nothing. lines.append( - "request_memory = ifthenelse(" - "(LastHoldReasonCode =!= 34 && LastHoldReasonCode =!= 26), " - "MY.InitialRequestMemory, " - "int({factor} * NumJobStarts * MemoryUsage))".format( - factor=self.oom_memory_factor)) - lines.append( - "periodic_release = " - "((HoldReasonCode =?= 34) || (HoldReasonCode =?= 26)) " - "&& (NumJobStarts < {})".format(self.oom_max_retries)) + "request_memory = ifthenelse(({was_oom}) && " + "(MemoryUsage =!= undefined), " + "int({factor} * ({counter}) * MemoryUsage), " + "MY.InitialRequestMemory)".format( + was_oom=was_oom, factor=self.oom_memory_factor, + counter=self.oom_retry_counter)) + release_terms.append( + "({is_oom}) && ({counter} < {n})".format( + is_oom=is_oom, counter=self.oom_retry_counter, + n=self.oom_max_retries)) else: lines.append("request_memory = {}M".format(request_memory)) + # A backend with its own release condition contributes a term + # rather than a replacement. Before this, the only way to add one + # was extra_condor_cmds, which is emitted last and so overwrites + # periodic_release outright -- taking the OOM policy above with + # it, silently, and leaving that copy of the expression to drift + # away from this one. Site policy varies enough that the hook is + # necessary (an opportunistic pool holds jobs for reasons a + # dedicated cluster never sees); losing the memory handling to + # get it is not. + if self.extra_periodic_release: + site_term = self.extra_periodic_release + if self.auto_release_on_oom: + # Scope the site term away from the codes the OOM policy + # owns. Without this, OR-ing does not partition anything: + # a term like `(HoldReasonCode =!= 1) && (NumJobStarts < + # 50)` matches 26 and 34 as well, so it re-releases a job + # whose memory budget is deliberately spent. oom_max_retries + # then caps nothing, request_memory keeps being multiplied + # by a NumHolds nothing bounds, and the job climbs past + # every slot in the pool and sits Idle forever -- a worse + # end than the Held state the cap exists to produce. + # ...away from whatever codes the policy is CONFIGURED to + # own, not a second hardcoded copy of the default set. + site_term = "({site}) && !({is_oom})".format( + site=site_term, + is_oom=self._oom_hold_predicate( + "HoldReasonCode", "HoldReasonSubCode")) + release_terms.append(site_term) + if release_terms: + # One term is emitted bare so that configuring no site term + # leaves the expression byte-identical to what this class + # emitted before the hook existed. + # + # Term order is load-bearing when there are two. The OOM term + # comes first and `||` short-circuits on True, so a site term + # that evaluates to Error cannot suppress a memory release. + # Reversing them would let a malformed site expression take + # the memory policy down with it. + body = (release_terms[0] if len(release_terms) == 1 + else " || ".join("({})".format(t) for t in release_terms)) + lines.append("periodic_release = " + body) + lines.append("request_disk = {}".format(request_disk)) if self.accounting_group: lines.append("accounting_group = {}".format(self.accounting_group)) @@ -1541,6 +2284,18 @@ def submit(self, archive: Archive, sim_names: Iterable[str] for lvl in range(cur + 1, tgt + 1): node_id = "{}_lvl{}".format(sim, lvl) if self.subdag_factory is not None: + # Checked here, not just in __init__: subdag_factory and + # the extras are plain attributes, and assigning either + # after construction reached this path with the extras + # silently ignored. + if (self.extra_transfer_input_files + or self.extra_transfer_output_files): + raise ValueError( + "extra_transfer_{input,output}_files are applied by " + "build_worker, which this sub-DAG path bypasses: the " + "sub-DAG owns its own submit descriptions. Put the " + "entries in the DAG the factory generates, or clear " + "subdag_factory.") work_path = self.subdag_factory(archive, sim, lvl) nodes.append((sim, lvl, work_path, True)) else: @@ -1806,6 +2561,8 @@ def __init__(self, # Per-archive bookkeeping: sim_name -> [(level, jobid), ...] self.submitted_jobs: Dict[str, List[Tuple[int, str]]] = {} + + # ---- bootstrap helpers ------------------------------------------------ def _bootstrap_path(self, archive: Archive) -> Path: path = archive.base / "run_queue" / "workers" / "bootstrap.py" diff --git a/MonteCarloMarginalizeCode/Code/RIFT/simulation_manager/tests/test_condor_oom_release.py b/MonteCarloMarginalizeCode/Code/RIFT/simulation_manager/tests/test_condor_oom_release.py new file mode 100644 index 000000000..33ce6bce4 --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/RIFT/simulation_manager/tests/test_condor_oom_release.py @@ -0,0 +1,606 @@ +"""DualCondorRunQueue's catch-and-release policy for memory holds. + +`auto_release_on_oom` bumps `request_memory` and releases a job held +because it ran out of memory, up to `oom_max_retries` times. + +Three things in that sentence are site facts, not HTCondor facts, and +this module is mostly about not pretending otherwise: + + * **which hold codes mean "out of memory".** 34 is unambiguous. 26 is + SystemPolicy -- it means whatever the site's SYSTEM_PERIODIC_HOLD + expressions say, which on the LIGO clusters this policy came from is + usually memory, and on an OSG access point may be an anti-thrash + limiter that fires on a high NumJobStarts. Same code, opposite + meaning. + * **which sub-codes to carve out**, because every SYSTEM_PERIODIC_HOLD + at a site produces the same hold code and only the sub-code + separates them. + * **what rations the retries.** NumJobStarts counts execution + attempts; NumHolds counts holds of every kind, including transfer + failures that increment it while NumJobStarts stays at 0. Neither is + "the memory retry count" at every site. + +So they are arguments with defaults, and the defaults are exactly what +this class emitted before they existed. Sites that differ pass their own +and keep the knowledge of which-site-is-which in whatever inventory they +already maintain, not here. + +Expressions are EVALUATED against synthetic job ads rather than +string-matched, so the tests describe scheduler behaviour rather than the +text encoding it. Evaluation needs the HTCondor python bindings; the +shape tests do not and stay live without them. + +Run with the RIFT-importable interpreter, e.g.: + + PYTHONPATH=<...>/MonteCarloMarginalizeCode/Code \ + python -m pytest -q .../tests/test_condor_oom_release.py +""" + +from __future__ import annotations + +import shutil +import subprocess + +import pytest + +from RIFT.simulation_manager.database import ( + Archive, DualCondorRunQueue, Manifest, +) + +try: # pragma: no cover + import classad2 as classad +except ImportError: # pragma: no cover + try: + import classad + except ImportError: + classad = None + +needs_classad = pytest.mark.skipif( + classad is None, reason="HTCondor python bindings not importable") + +#: What this class emitted before any of these knobs existed. The +#: default configuration must still produce it, or every existing +#: deployment silently changes policy on upgrade. +PRE_EXISTING_RELEASE = ( + "((HoldReasonCode =?= 34) || (HoldReasonCode =?= 26)) " + "&& (NumJobStarts < 5)") + + +def _generator_src(): + return ( + "import json, os\n" + "def run(params, sim_dir, level, prev_levels):\n" + " p = os.path.join(sim_dir, 'level_%d.json' % level)\n" + " with open(p, 'w') as f:\n" + " json.dump({'level': level}, f)\n" + " return p\n" + ) + + +@pytest.fixture +def archive(tmp_path): + code = tmp_path / "src" + code.mkdir() + (code / "generator.py").write_text(_generator_src()) + manifest = Manifest.new(name="oom_release", + request_queue_kind="condor", + run_queue_kind="condor") + return Archive( + base_location=tmp_path / "arch", manifest=manifest, + generator_spec={"module_path": str(code / "generator.py"), + "entrypoint": "generator:run"}, + ) + + +def _build(archive, queue, level=1): + name = archive.register({"x": 1}, target_level=level) + return open(queue.build_worker(archive, name, level)).read() + + +def _command(sub_text, key): + hits = [l for l in sub_text.splitlines() + if l.split("=")[0].strip().lower() == key] + assert len(hits) == 1, hits + return hits[0].split("=", 1)[1].strip() + + +def _eval(expr, **job_ad): + """Evaluate a submit expression against a synthetic job ad. + + `MY.` is stripped first: it is submit-language scope syntax that + condor resolves against the job ad at evaluation time, but the + python bindings evaluate a lone ad and return Undefined for it. + Stripping keeps the rest of the real emitted text under test. Note + this proves the expression PARSES and evaluates -- the dry-run tests + are what show condor accepts the `MY.` form itself. + """ + got = classad.ExprTree(expr.replace("MY.", "")).eval( + classad.ClassAd(dict(job_ad))) + # classad.Value is an IntEnum, so Undefined and Error are truthy + # ints: `assert _eval(...)` would pass on either and every + # behavioural test here would be vacuous. Refuse them at the door. + if isinstance(got, classad.Value): + raise AssertionError( + "expression evaluated to {!r}, not a value: {}".format(got, expr)) + return got + + +# -------------------------------------------------------------------- +# the defaults are the old behaviour, exactly +# -------------------------------------------------------------------- + +def test_the_default_release_expression_is_unchanged(archive): + """Byte-for-byte what the class emitted before these knobs existed. + + Anything less and every deployment that never heard of this change + gets a different policy on upgrade.""" + sub = _build(archive, DualCondorRunQueue(auto_release_on_oom=True, + oom_max_retries=5)) + assert _command(sub, "periodic_release") == PRE_EXISTING_RELEASE + + +@needs_classad +def test_the_default_memory_bump_matches_the_old_one(archive): + """request_memory is restructured (see the MemoryUsage guard below), + so it is not text-identical. It must still agree with the old + expression everywhere the old one produced a value.""" + old = ("ifthenelse((LastHoldReasonCode =!= 34 && LastHoldReasonCode =!= 26)" + ", InitialRequestMemory, int(1.5 * NumJobStarts * MemoryUsage))") + new = _command(_build(archive, DualCondorRunQueue(request_memory=4096)), + "request_memory") + for code in (34, 26, 13, 1, 47): + for starts in (1, 3, 9): + ad = dict(LastHoldReasonCode=code, NumJobStarts=starts, + MemoryUsage=1000, InitialRequestMemory=4096) + assert _eval(new, **ad) == _eval(old, **ad), (code, starts) + + +@needs_classad +def test_the_default_counts_starts_not_holds(archive): + """Not an accident and not a leftover: on an OSG access point + NumHolds is incremented by transfer failures that never ran the job + at all, so it is not a better default -- only a different one.""" + release = _command(_build(archive, DualCondorRunQueue(oom_max_retries=5)), + "periodic_release") + assert _eval(release, HoldReasonCode=34, NumJobStarts=1, NumHolds=99) + assert _eval(release, HoldReasonCode=34, NumJobStarts=9, + NumHolds=1) is False + + +# -------------------------------------------------------------------- +# the site supplies the policy +# -------------------------------------------------------------------- + +@needs_classad +def test_a_site_can_narrow_which_codes_mean_memory(archive): + """The OSG case: code 26 there is SystemPolicy, and the site policy + it reports is an anti-thrash limiter, not memory. Releasing it with + a bigger memory request fights the pool's own protection.""" + q = DualCondorRunQueue(auto_release_on_oom=True, oom_hold_codes=(34,)) + release = _command(_build(archive, q), "periodic_release") + assert _eval(release, HoldReasonCode=34, NumJobStarts=1) + assert _eval(release, HoldReasonCode=26, NumJobStarts=1) is False + + +@needs_classad +def test_a_site_can_carve_out_one_subcode_of_a_shared_code(archive): + """The finer case, and why codes alone are not enough: a site whose + memory holds DO arrive as 26 still needs to exclude the limiter, + which arrives as 26 too and is told apart only by its sub-code.""" + q = DualCondorRunQueue(auto_release_on_oom=True, + oom_hold_subcode_exclusions={26: (100, 101)}) + release = _command(_build(archive, q), "periodic_release") + # the anti-thrash limiter: same code, excluded sub-code + assert _eval(release, HoldReasonCode=26, HoldReasonSubCode=100, + NumJobStarts=1) is False + # a real memory hold reported by the same site policy + assert _eval(release, HoldReasonCode=26, HoldReasonSubCode=7, + NumJobStarts=1) + # 34 is untouched by an exclusion keyed on 26 + assert _eval(release, HoldReasonCode=34, HoldReasonSubCode=100, + NumJobStarts=1) + + +@needs_classad +def test_a_site_can_choose_what_rations_the_retries(archive): + q = DualCondorRunQueue(auto_release_on_oom=True, oom_max_retries=5, + oom_retry_counter="NumHolds") + release = _command(_build(archive, q), "periodic_release") + assert "NumJobStarts" not in release + assert _eval(release, HoldReasonCode=34, NumHolds=1, NumJobStarts=99) + assert _eval(release, HoldReasonCode=34, NumHolds=9, + NumJobStarts=1) is False + + +@needs_classad +def test_the_counter_also_scales_the_memory_bump(archive): + q = DualCondorRunQueue(auto_release_on_oom=True, oom_memory_factor=1.5, + oom_retry_counter="NumHolds") + mem = _command(_build(archive, q), "request_memory") + assert _eval(mem, LastHoldReasonCode=34, NumHolds=2, NumJobStarts=11, + MemoryUsage=1000, InitialRequestMemory=4096) == 3000 + + +@needs_classad +def test_owning_no_codes_disables_the_policy_without_breaking_the_file( + archive): + """An empty set must emit a well-formed expression, not an empty one + that condor rejects at submit time.""" + q = DualCondorRunQueue(auto_release_on_oom=True, oom_hold_codes=()) + release = _command(_build(archive, q), "periodic_release") + assert _eval(release, HoldReasonCode=34, NumJobStarts=1) is False + mem = _command(_build(archive, q), "request_memory") + assert _eval(mem, LastHoldReasonCode=34, NumJobStarts=3, + MemoryUsage=1000, InitialRequestMemory=4096) == 4096 + + +# A test asserting "no site names appear in database.py" used to live +# here. It was theatre: none of its needles occurred in the module even +# before this change, so it passed unconditionally and on the parent +# commit too, while the module does say "LIGO clusters" and "OSG access +# point" in prose the needle list happened not to cover. A grep cannot +# express "no site-to-policy table" -- the constraint is a review one, +# and it is stated in DEFAULT_OOM_HOLD_CODES and DESIGN.md instead. + + +# -------------------------------------------------------------------- +# the guard belongs on the attribute that can actually be undefined +# -------------------------------------------------------------------- + +@needs_classad +def test_an_undefined_memory_usage_does_not_wedge_the_job(archive): + """MemoryUsage is itself an expression over ResidentSetSize, which a + job held before it ever executed does not have. int(1.5 * n * + undefined) is undefined, an undefined request_memory matches no + slot, and the job sits Idle with nothing in its log -- worse than + releasing it unchanged, which the retry cap at least bounds.""" + mem = _command(_build(archive, DualCondorRunQueue(request_memory=4096)), + "request_memory") + got = _eval(mem, LastHoldReasonCode=34, NumJobStarts=3, + InitialRequestMemory=4096) # no MemoryUsage + assert got == 4096 + + +@needs_classad +def test_a_non_memory_hold_leaves_the_request_alone(archive): + mem = _command(_build(archive, DualCondorRunQueue(request_memory=4096)), + "request_memory") + assert _eval(mem, LastHoldReasonCode=13, NumJobStarts=3, + MemoryUsage=1000, InitialRequestMemory=4096) == 4096 + + +# -------------------------------------------------------------------- +# extra_periodic_release: additive, and scoped to the configured codes +# -------------------------------------------------------------------- + +SITE_TERM = "(HoldReasonCode =!= 1) && (NumJobStarts < 50)" + + +@needs_classad +def test_a_site_term_does_not_cost_the_memory_policy(archive): + """The point of the hook. Routing this through extra_condor_cmds + instead replaced periodic_release outright and the OOM arm was gone + -- silently, and only on the sites that needed the site term.""" + q = DualCondorRunQueue(auto_release_on_oom=True, oom_max_retries=5, + extra_periodic_release=SITE_TERM) + release = _command(_build(archive, q), "periodic_release") + assert _eval(release, HoldReasonCode=34, NumJobStarts=1) + assert _eval(release, HoldReasonCode=7, NumJobStarts=1) + + +@needs_classad +def test_each_term_keeps_its_own_budget(archive): + q = DualCondorRunQueue(auto_release_on_oom=True, oom_max_retries=5, + extra_periodic_release=SITE_TERM) + release = _command(_build(archive, q), "periodic_release") + # THE case, and the one an earlier version of this test dodged by + # asserting it with HoldReasonCode=1 -- the single code the site + # term excludes by construction, so it could not fail however the + # terms composed. Unscoped, the site term matches 34 happily and + # oom_max_retries caps nothing while request_memory climbs past + # every slot in the pool. + assert _eval(release, HoldReasonCode=34, NumJobStarts=9) is False + assert _eval(release, HoldReasonCode=26, NumJobStarts=9) is False + # site budget spent, memory arm still live + assert _eval(release, HoldReasonCode=34, NumJobStarts=1) + # a user hold is nobody's business + assert _eval(release, HoldReasonCode=1, NumJobStarts=1) is False + + +@needs_classad +def test_the_scoping_follows_the_configured_codes(archive): + """Not a second hardcoded copy of the default set: a site that has + told the policy it does not own code 26 gets to release 26 from its + own term.""" + q = DualCondorRunQueue(auto_release_on_oom=True, oom_hold_codes=(34,), + oom_max_retries=5, + extra_periodic_release=SITE_TERM) + release = _command(_build(archive, q), "periodic_release") + assert _eval(release, HoldReasonCode=26, NumJobStarts=1) + assert _eval(release, HoldReasonCode=34, NumJobStarts=9) is False + + +@needs_classad +def test_a_site_term_can_stand_alone(archive): + q = DualCondorRunQueue(auto_release_on_oom=False, + extra_periodic_release=SITE_TERM) + release = _command(_build(archive, q), "periodic_release") + assert _eval(release, HoldReasonCode=7, NumJobStarts=1) + assert _eval(release, HoldReasonCode=1, NumJobStarts=1) is False + + +def test_no_site_term_changes_nothing(archive): + for empty in (None, "", " "): + got = _build(archive, DualCondorRunQueue(auto_release_on_oom=True, + oom_max_retries=5, + extra_periodic_release=empty)) + assert _command(got, "periodic_release") == PRE_EXISTING_RELEASE + + +# -------------------------------------------------------------------- +# rejections +# -------------------------------------------------------------------- + +@pytest.mark.parametrize("bad", [ + "(HoldReasonCode =!= 1)\ngetenv = True", + "(HoldReasonCode =!= 1)\r\ntransfer_output_files = nothing", +]) +def test_a_newline_cannot_smuggle_in_another_submit_command(archive, bad): + """A newline ends the submit command; the remainder would be read as + a fresh one. This value can arrive from a manifest written by + another tool, so it is not only the author's own typing.""" + with pytest.raises(ValueError): + DualCondorRunQueue(extra_periodic_release=bad) + with pytest.raises(ValueError): + DualCondorRunQueue(oom_retry_counter=bad) + + +@pytest.mark.parametrize("bad", [17, ["a", "b"], {"x": 1}, object()]) +def test_a_non_string_expression_is_refused(bad): + with pytest.raises(TypeError): + DualCondorRunQueue(extra_periodic_release=bad) + + +@pytest.mark.parametrize("bad", ["34", 34, {"a": 1}, [34, "35"], [34, True]]) +def test_hold_codes_must_be_integers(bad): + """A bare string is iterable and would become one code per + character; True is an int subclass and would silently become 1.""" + with pytest.raises(TypeError): + DualCondorRunQueue(oom_hold_codes=bad) + + +@pytest.mark.parametrize("bad", [[26], "26", {26: 100}, {"x": [100]}]) +def test_subcode_exclusions_must_be_a_code_to_subcodes_mapping(bad): + with pytest.raises(TypeError): + DualCondorRunQueue(oom_hold_subcode_exclusions=bad) + + +def test_assignment_after_construction_is_validated(archive): + """Constructor-only checks are bypassed by plain assignment -- the + failure mode the transfer-file guards had to be fixed for.""" + q = DualCondorRunQueue(auto_release_on_oom=True) + with pytest.raises(ValueError): + q.extra_periodic_release = "(HoldReasonCode =!= 1)\ngetenv = True" + with pytest.raises(TypeError): + q.oom_hold_codes = "34" + with pytest.raises(TypeError): + q.oom_hold_subcode_exclusions = [26] + assert "getenv = True" not in _build(archive, q) + + +def test_periodic_release_cannot_be_replaced_through_extra_condor_cmds(archive): + """The bug the additive hook exists to remove, closed rather than + routed around. extra_condor_cmds is emitted last, so a + periodic_release key there replaced the queue's line and took the + whole OOM policy with it -- silently, condor_submit reporting + success. Leaving that path open beside the additive one means the + next backend author still finds it first.""" + q = DualCondorRunQueue( + auto_release_on_oom=True, + extra_condor_cmds={"periodic_release": "(HoldReasonCode =?= 13)"}) + with pytest.raises(ValueError, match="periodic_release"): + _build(archive, q) + + +def test_the_refusal_is_case_insensitive(archive): + """HTCondor command names are case-insensitive, so an exact-lowercase + check would let Periodic_Release straight through.""" + q = DualCondorRunQueue( + auto_release_on_oom=True, + extra_condor_cmds={"Periodic_Release": "(HoldReasonCode =?= 13)"}) + with pytest.raises(ValueError): + _build(archive, q) + + +def test_disabling_the_policy_leaves_a_plain_memory_request(archive): + sub = _build(archive, DualCondorRunQueue(auto_release_on_oom=False, + request_memory=4096)) + assert _command(sub, "request_memory") == "4096M" + assert "periodic_release" not in sub + + +# -------------------------------------------------------------------- +# the manifest carries the policy +# -------------------------------------------------------------------- + +def test_the_policy_survives_the_manifest(tmp_path): + """A relocated archive must submit under the policy it was built + with. Note the sub-code map: JSON has no integer keys, so it comes + back as {"26": [100]} and an implementation that does not coerce + turns a configured exclusion into a silently ignored one.""" + from RIFT.simulation_manager.database import make_queues_from_manifest + + code = tmp_path / "src" + code.mkdir() + (code / "generator.py").write_text(_generator_src()) + manifest = Manifest.new( + name="oom_manifest", request_queue_kind="condor", + run_queue_kind="condor", + run_queue_extra={"oom_hold_codes": [34, 26], + "oom_hold_subcode_exclusions": {"26": [100]}, + "oom_retry_counter": "NumHolds", + "extra_periodic_release": SITE_TERM}) + Archive(base_location=tmp_path / "arch", manifest=manifest, + generator_spec={"module_path": str(code / "generator.py"), + "entrypoint": "generator:run"}) + reopened = Archive(base_location=tmp_path / "arch") + _, run_queue = make_queues_from_manifest(reopened) + assert run_queue.oom_hold_codes == (34, 26) + assert dict(run_queue.oom_hold_subcode_exclusions) == {26: (100,)} + assert run_queue.oom_retry_counter == "NumHolds" + assert run_queue.extra_periodic_release == SITE_TERM + + +# -------------------------------------------------------------------- +# the scheduler's own opinion +# -------------------------------------------------------------------- + +@pytest.mark.parametrize("kwargs", [ + {}, + {"oom_hold_codes": (34,)}, + {"oom_hold_subcode_exclusions": {26: (100, 101)}}, + {"oom_retry_counter": "NumHolds"}, + {"oom_hold_codes": ()}, + {"extra_periodic_release": SITE_TERM}, + {"oom_hold_codes": (34,), "extra_periodic_release": SITE_TERM, + "oom_retry_counter": "NumHolds"}, +]) +def test_condor_accepts_every_shape_of_policy(archive, tmp_path, kwargs): + """No expression evaluator substitutes for condor parsing it, and + each knob changes the emitted text in a different place. -dry-run + contacts no schedd and queues nothing.""" + condor_submit = shutil.which("condor_submit") + if condor_submit is None: + pytest.skip("condor_submit not on PATH") + sub = _build(archive, DualCondorRunQueue(auto_release_on_oom=True, + **kwargs)) + path = tmp_path / "oom.sub" + path.write_text(sub) + out = tmp_path / "oom.dry" + proc = subprocess.run([condor_submit, "-dry-run", str(out), str(path)], + capture_output=True, text=True) + assert proc.returncode == 0, proc.stderr + materialised = [l for l in out.read_text().splitlines() + if l.split("=")[0].strip().lower() + in ("requestmemory", "periodicrelease")] + assert len(materialised) == 2, materialised + + +# -------------------------------------------------------------------- +# the counter is spliced into arithmetic, not only into a comparison +# -------------------------------------------------------------------- + +@needs_classad +def test_a_compound_counter_is_not_mangled_by_precedence(archive): + """`oom_retry_counter` is validated as an EXPRESSION, so a compound + one is advertised input. Unparenthesised in the bump it reassociates: + `int(1.5 * NumHolds - NumJobStarts * MemoryUsage)` is + (1.5*NumHolds) - (NumJobStarts*MemoryUsage), which for a job at + NumHolds=6, NumJobStarts=2, MemoryUsage=1000 asks for -1991 MB. + condor_submit accepts that, and a negative request matches no slot -- + the wedged-Idle failure this policy's MemoryUsage guard exists to + prevent, reintroduced through a different door.""" + q = DualCondorRunQueue(auto_release_on_oom=True, oom_memory_factor=1.5, + oom_retry_counter="NumHolds - NumJobStarts") + mem = _command(_build(archive, q), "request_memory") + assert _eval(mem, LastHoldReasonCode=34, NumHolds=6, NumJobStarts=2, + MemoryUsage=1000, InitialRequestMemory=4096) == 6000 + + +@needs_classad +def test_the_comparison_form_is_unaffected(archive): + """`<` has lower precedence than any arithmetic, so the release arm + was already safe -- which is why the fix is confined to the bump and + the default release text stays byte-identical.""" + q = DualCondorRunQueue(auto_release_on_oom=True, oom_max_retries=5, + oom_retry_counter="NumHolds - NumJobStarts") + release = _command(_build(archive, q), "periodic_release") + assert _eval(release, HoldReasonCode=34, NumHolds=6, NumJobStarts=2) + assert _eval(release, HoldReasonCode=34, NumHolds=9, + NumJobStarts=2) is False + + +# -------------------------------------------------------------------- +# configuration that would quietly do nothing +# -------------------------------------------------------------------- + +def test_an_exclusion_on_an_unowned_code_is_refused(archive): + """Silently ignoring it means a typo reads as configured: the site + believes it has carved out its anti-thrash sub-code and has not.""" + with pytest.raises(ValueError, match="99"): + DualCondorRunQueue(oom_hold_codes=(34,), + oom_hold_subcode_exclusions={99: (1,)}) + q = DualCondorRunQueue(oom_hold_codes=(34, 26), + oom_hold_subcode_exclusions={26: (100,)}) + with pytest.raises(ValueError): + q.oom_hold_codes = (34,) # orphans the exclusion after the fact + + +def test_a_refused_assignment_leaves_the_previous_policy_in_place(archive): + """A setter that stores first and validates after leaves the queue + configured with the value it just rejected: the caller sees the + ValueError, reads it as "nothing changed", and submits under (34,) + anyway. Both directions of the pair have to hold.""" + q = DualCondorRunQueue(auto_release_on_oom=True, oom_hold_codes=(34, 26), + oom_hold_subcode_exclusions={26: (100,)}) + with pytest.raises(ValueError): + q.oom_hold_codes = (34,) + assert q.oom_hold_codes == (34, 26) + with pytest.raises(ValueError): + q.oom_hold_subcode_exclusions = {99: (1,)} + assert dict(q.oom_hold_subcode_exclusions) == {26: (100,)} + # ...and what it submits is the surviving policy, not the rejected + # one: the attribute reading right is no use if the emitted text + # disagrees with it. + intact = DualCondorRunQueue(auto_release_on_oom=True, + oom_hold_codes=(34, 26), + oom_hold_subcode_exclusions={26: (100,)}) + assert _command(_build(archive, q), "periodic_release") == \ + _command(_build(archive, intact), "periodic_release") + + +def test_none_means_the_default_not_the_empty_set(archive): + """As it does in the constructor and for oom_retry_counter. Reading + it as "own no codes" would let an assignment disable the memory + policy outright; pass () to ask for that.""" + q = DualCondorRunQueue(auto_release_on_oom=True, oom_max_retries=5) + q.oom_hold_codes = None + assert _command(_build(archive, q), "periodic_release") == \ + PRE_EXISTING_RELEASE + + +def test_the_exclusion_view_cannot_be_mutated_in_place(archive): + """A copy would make this a silent no-op, the same trap the transfer + properties avoid by handing back tuples.""" + q = DualCondorRunQueue(oom_hold_codes=(34, 26)) + with pytest.raises(TypeError): + q.oom_hold_subcode_exclusions[26] = (100,) + + +# -------------------------------------------------------------------- +# the other half of the memory policy +# -------------------------------------------------------------------- + +def test_request_memory_cannot_be_replaced_through_extra_condor_cmds(archive): + """Protecting periodic_release alone did not close the path. + Replacing request_memory leaves the release arm intact, so the job is + released the full oom_max_retries times at a fixed size and OOMs + every time -- it spends the whole budget achieving nothing.""" + q = DualCondorRunQueue(auto_release_on_oom=True, + extra_condor_cmds={"request_memory": "8G"}) + with pytest.raises(ValueError, match="request_memory"): + _build(archive, q) + + +@pytest.mark.parametrize("key,expected", [ + ("periodic_release", "extra_periodic_release"), + ("request_memory", "set_resources"), + ("transfer_input_files", "extra_transfer_input_files"), +]) +def test_the_refusal_names_the_thing_to_use_instead(archive, key, expected): + """A guard that refuses without a remedy just moves the dead end. + The periodic_release message used to point at the transfer options.""" + q = DualCondorRunQueue(auto_release_on_oom=True, + extra_condor_cmds={key: "whatever"}) + with pytest.raises(ValueError, match=expected): + _build(archive, q) diff --git a/MonteCarloMarginalizeCode/Code/RIFT/simulation_manager/tests/test_condor_transfer_inputs.py b/MonteCarloMarginalizeCode/Code/RIFT/simulation_manager/tests/test_condor_transfer_inputs.py new file mode 100644 index 000000000..8db0da8d6 --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/RIFT/simulation_manager/tests/test_condor_transfer_inputs.py @@ -0,0 +1,458 @@ +"""DualCondorRunQueue.extra_transfer_input_files. + +A backend often needs every job to stage a bulk input the archive knows +nothing about — an opacity table, a reference catalogue — and on OSG +those belong in `transfer_input_files` as `osdf://` URLs so Condor +fetches them through a cache instead of the submit host's spool. + +Before this hook the only way in was `extra_condor_cmds`, which is +appended verbatim and so *replaces* the `transfer_input_files` line the +queue already wrote. That silently strips the frozen `code/` directory +and the sim's params, leaving the worker with nothing to run. Hence an +append-only knob. + +Run with the RIFT-importable interpreter, e.g.: + + PYTHONPATH=<...>/MonteCarloMarginalizeCode/Code \ + python -m pytest -q .../tests/test_condor_transfer_inputs.py +""" + +from __future__ import annotations + +import pytest + +from RIFT.simulation_manager.database import ( + Archive, DualCondorRunQueue, Manifest, +) + +BULK = [ + "osdf:///ospool/ap41/data/u/r3/opacities-v2.h5", + "osdf:///ospool/ap41/data/u/r3/compositions-v1.tar.gz", +] + + +def _generator_src(): + return ( + "import json, os\n" + "def run(params, sim_dir, level, prev_levels):\n" + " p = os.path.join(sim_dir, 'level_%d.json' % level)\n" + " with open(p, 'w') as f:\n" + " json.dump({'level': level}, f)\n" + " return p\n" + ) + + +@pytest.fixture +def archive(tmp_path): + code = tmp_path / "src" + code.mkdir() + (code / "generator.py").write_text(_generator_src()) + manifest = Manifest.new(name="transfer_inputs", + request_queue_kind="condor", + run_queue_kind="condor") + return Archive( + base_location=tmp_path / "arch", manifest=manifest, + generator_spec={"module_path": str(code / "generator.py"), + "entrypoint": "generator:run"}, + ) + + +def _transfer_line(sub_text): + lines = [l for l in sub_text.splitlines() + if l.strip().startswith("transfer_input_files")] + assert len(lines) == 1, lines + return lines[0] + + +def _build(archive, queue, level=1): + name = archive.register({"x": 1}, target_level=level) + return name, open(queue.build_worker(archive, name, level)).read() + + +def test_extras_are_appended(archive): + q = DualCondorRunQueue(extra_transfer_input_files=BULK) + _, sub = _build(archive, q) + line = _transfer_line(sub) + for url in BULK: + assert url in line + + +def test_archive_entries_are_preserved(archive, tmp_path): + """The whole point: extras must be present AND must not displace the + frozen code. + + Asserting only the archive entries made this pass unmodified against + the base revision — the old constructor swallowed the unknown kwarg + into **submit_kwargs rather than raising, so the test could not + detect the failure mode it names.""" + q = DualCondorRunQueue(extra_transfer_input_files=BULK) + name, sub = _build(archive, q) + line = _transfer_line(sub) + assert str(tmp_path / "arch" / "code") in line + assert "params.json" in line + for url in BULK: + assert url in line + + +def test_default_is_unchanged(archive, tmp_path): + """No extras configured means the submit description is exactly what + it was before this knob existed.""" + q = DualCondorRunQueue() + _, sub = _build(archive, q) + line = _transfer_line(sub) + assert "osdf://" not in line + assert str(tmp_path / "arch" / "code") in line + + +def test_extras_appear_once_per_job(archive): + q = DualCondorRunQueue(extra_transfer_input_files=BULK) + _, sub = _build(archive, q) + line = _transfer_line(sub) + for url in BULK: + assert line.count(url) == 1 + + +def test_extras_survive_repeated_builds(archive): + """build_worker is documented idempotent; the extras list must not + accumulate across calls.""" + q = DualCondorRunQueue(extra_transfer_input_files=BULK) + name = archive.register({"x": 1}, target_level=1) + q.build_worker(archive, name, 1) + sub = open(q.build_worker(archive, name, 1)).read() + assert _transfer_line(sub).count(BULK[0]) == 1 + + +def test_chained_levels_still_declare_prior_outputs(archive): + """Extras must not disturb the prior-level entries, which are + declared regardless of disk presence because the DAG guarantees they + exist by the time level N runs.""" + q = DualCondorRunQueue(extra_transfer_input_files=BULK) + name = archive.register({"x": 1}, target_level=3) + sub = open(q.build_worker(archive, name, 3)).read() + line = _transfer_line(sub) + assert "level_1.json" in line + assert "level_2.json" in line + assert BULK[0] in line + + +def test_accepts_path_like_entries(archive, tmp_path): + local = tmp_path / "aux.dat" + local.write_text("x") + q = DualCondorRunQueue(extra_transfer_input_files=[local]) + _, sub = _build(archive, q) + assert str(local) in _transfer_line(sub) + + +def test_reaches_the_queue_through_the_manifest(tmp_path): + """make_queues_from_manifest passes run_queue.extra as kwargs, so a + reopened archive must keep its bulk inputs.""" + from RIFT.simulation_manager.database import make_queues_from_manifest + + code = tmp_path / "src" + code.mkdir() + (code / "generator.py").write_text(_generator_src()) + manifest = Manifest.new( + name="transfer_inputs", request_queue_kind="condor", + run_queue_kind="condor", + run_queue_extra={"extra_transfer_input_files": BULK}, + ) + a = Archive(base_location=tmp_path / "arch", manifest=manifest, + generator_spec={"module_path": str(code / "generator.py"), + "entrypoint": "generator:run"}) + reopened = Archive(base_location=tmp_path / "arch") + _, run_queue = make_queues_from_manifest(reopened) + assert list(run_queue.extra_transfer_input_files) == BULK + + +# --------------------------------------------------------------------------- +# Rejections: each of these is something condor_submit accepts with exit 0 +# and then gets wrong on a remote worker. +# --------------------------------------------------------------------------- + +def test_bare_string_is_rejected(): + """A str is a Sequence[str], so it would iterate as one transfer + request per character.""" + with pytest.raises(TypeError, match="not a bare string"): + DualCondorRunQueue(extra_transfer_input_files="osdf:///a/b.h5") + + +@pytest.mark.parametrize("bad", [ + "/data/tab,v2.h5", # comma separates entries + "/data/a.h5\nrequest_memory = 999999", # newline injects a submit command + " ", # empty +]) +def test_corrupting_entries_are_rejected(bad): + with pytest.raises(ValueError): + DualCondorRunQueue(extra_transfer_input_files=[bad]) + + +@pytest.mark.parametrize("colliding", [ + "osdf:///bulk/params.json", "osdf:///bulk/code", "osdf:///bulk/level_1.json", +]) +def test_basename_collisions_are_rejected(colliding): + """Condor flattens basenames into the sandbox, so these would + overwrite the archive's own staged files on the worker.""" + with pytest.raises(ValueError, match="collides"): + DualCondorRunQueue(extra_transfer_input_files=[colliding]) + + +def test_extras_with_subdag_factory_are_rejected(): + """submit() dispatches to the sub-DAG and never calls build_worker, + so the extras would be stored, persisted to the manifest, and reach + nothing at all.""" + with pytest.raises(ValueError, match="subdag_factory"): + DualCondorRunQueue(extra_transfer_input_files=BULK, + subdag_factory=lambda a, s, l: "x.dag") + + +@pytest.mark.parametrize("key", [ + "transfer_input_files", "transfer_output_files", "transfer_output_remaps", +]) +def test_extra_condor_cmds_cannot_replace_the_transfer_lines(archive, key): + """extra_condor_cmds is emitted last, so setting these would replace + the archive's own line and strip what the worker needs.""" + q = DualCondorRunQueue(extra_condor_cmds={key: "/other/thing"}) + name = archive.register({"x": 1}, target_level=1) + with pytest.raises(ValueError, match=key): + q.build_worker(archive, name, 1) + + +# --------------------------------------------------------------------------- +# Output side +# --------------------------------------------------------------------------- + +def test_extra_outputs_are_returned_and_remapped(archive, tmp_path): + """transfer_output_files is explicit, so anything not named here is + destroyed with the sandbox — a backend whose science IS output files + completes having discarded its own results.""" + q = DualCondorRunQueue(extra_transfer_output_files=["level_{level}"]) + name = archive.register({"x": 1}, target_level=1) + sub = open(q.build_worker(archive, name, 1)).read() + out = next(l for l in sub.splitlines() + if l.strip().startswith("transfer_output_files")) + remap = next(l for l in sub.splitlines() + if l.strip().startswith("transfer_output_remaps")) + assert "level_1.json" in out and "level_1" in out + assert str(tmp_path / "arch" / "sims" / name / "level_1") in remap + assert remap.count(";") == 1 # marker remap plus ours + + +def test_output_placeholders_track_the_level(archive): + """Asserting `"level_2" in out` was vacuous — the marker is already + named level_2.json, so it passed with the feature unimplemented. + Check the actual entry list instead.""" + q = DualCondorRunQueue(extra_transfer_output_files=["work_{level}"]) + name = archive.register({"x": 1}, target_level=2) + sub = open(q.build_worker(archive, name, 2)).read() + out = next(l for l in sub.splitlines() + if l.strip().startswith("transfer_output_files")) + entries = [e.strip() for e in out.split("=", 1)[1].split(",")] + assert entries == ["level_2.json", "work_2"] + + +def test_output_default_is_unchanged(archive): + q = DualCondorRunQueue() + name = archive.register({"x": 1}, target_level=1) + sub = open(q.build_worker(archive, name, 1)).read() + out = next(l for l in sub.splitlines() + if l.strip().startswith("transfer_output_files")) + remap = next(l for l in sub.splitlines() + if l.strip().startswith("transfer_output_remaps")) + assert out.split("=", 1)[1].strip() == "level_1.json" + assert ";" not in remap + + +# --------------------------------------------------------------------------- +# Guards must survive attribute assignment, not just __init__ +# +# All of these were reachable after the first round of "fixes": the +# attributes are public, and configuring a queue by assigning to them is +# the natural thing to do, which walked past every constructor check. +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("bad", [ + ["/data/tab,v2.h5"], + ["/data/a.h5\nrequest_memory = 999999"], + "osdf:///a/b.h5", + ["osdf:///bulk/params.json"], +]) +def test_input_assignment_after_construction_is_validated(bad): + q = DualCondorRunQueue() + with pytest.raises((ValueError, TypeError)): + q.extra_transfer_input_files = bad + + +@pytest.mark.parametrize("bad", [ + ["evil;name=/etc/hosts"], + ["a=b"], + ["params.json"], + ["out,put"], +]) +def test_output_assignment_after_construction_is_validated(bad): + q = DualCondorRunQueue() + with pytest.raises((ValueError, TypeError)): + q.extra_transfer_output_files = bad + + +def test_subdag_factory_assigned_late_still_refuses_extras(archive): + """The P0: setting subdag_factory after construction reached the + sub-DAG path with the extras stored and silently ignored.""" + q = DualCondorRunQueue(extra_transfer_input_files=BULK, + submit_mode="embed") + q.subdag_factory = lambda a, s, l: "/some/external.dag" + name = archive.register({"x": 1}, target_level=1) + with pytest.raises(ValueError, match="sub-DAG"): + q.submit(archive, [name]) + + +def test_extras_assigned_late_still_refuse_a_subdag(archive): + """...and the same in the other order.""" + q = DualCondorRunQueue(submit_mode="embed", + subdag_factory=lambda a, s, l: "/some/external.dag") + q.extra_transfer_input_files = BULK + name = archive.register({"x": 1}, target_level=1) + with pytest.raises(ValueError, match="sub-DAG"): + q.submit(archive, [name]) + + +# --------------------------------------------------------------------------- +# Output-side hazards the shared validator did not originally cover +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("bad", ["evil;x", "a=b"]) +def test_remap_delimiters_are_rejected(bad): + """transfer_output_remaps is a ';'-separated list of name=path pairs, + so either character makes the remap unparseable on the execute side.""" + with pytest.raises(ValueError): + DualCondorRunQueue(extra_transfer_output_files=[bad]) + + +@pytest.mark.parametrize("bad", ["params.json", "code", "level_{level}.json"]) +def test_output_basename_collisions_are_rejected(bad): + """An output entry is remapped back under sims//, so a returned + params.json overwrites the sim's recorded inputs in the archive — + corrupting state every later level reads.""" + with pytest.raises(ValueError, match="collides"): + q = DualCondorRunQueue(extra_transfer_output_files=[bad]) + q.build_worker.__self__ # constructed: force the check + + +def test_expanded_names_are_revalidated(archive): + """Validation at assignment sees the template; expansion can still + introduce a space or a path separator.""" + name = archive.register({"x": 1}, target_level=1) + for template in ("my file_{level}", "sub/dir_{level}"): + q = DualCondorRunQueue(extra_transfer_output_files=[template]) + with pytest.raises(ValueError): + q.build_worker(archive, name, 1) + + +def test_unknown_placeholder_names_the_contract(archive): + q = DualCondorRunQueue(extra_transfer_output_files=["stuff_{foo}"]) + name = archive.register({"x": 1}, target_level=1) + with pytest.raises(ValueError, match="placeholder"): + q.build_worker(archive, name, 1) + + +# --------------------------------------------------------------------------- +# The read path, not just the write path +# +# Validating on assignment protected the setter and nothing else: the +# getter handed back the live list, so `.append()` never went through it, +# and the private backing attribute was a plain assignment away. +# --------------------------------------------------------------------------- + +def test_getter_does_not_expose_the_live_list(archive): + """`q.extra_transfer_input_files.append(bad)` must not quietly work.""" + q = DualCondorRunQueue(extra_transfer_input_files=["osdf:///good/a.h5"]) + with pytest.raises(AttributeError): + q.extra_transfer_input_files.append("/data/tab,v2.h5") + assert list(q.extra_transfer_input_files) == ["osdf:///good/a.h5"] + + +def test_output_getter_does_not_expose_the_live_list(): + q = DualCondorRunQueue(extra_transfer_output_files=["work_{level}"]) + with pytest.raises(AttributeError): + q.extra_transfer_output_files.append("evil;name=/etc/hosts") + + +@pytest.mark.parametrize("bad", [ + "/data/tab,v2.h5", + "osdf:///a\nrequest_memory = 999999", +]) +def test_private_backing_attribute_is_caught_at_submit(archive, bad): + """Writing straight to the private attribute skips the setter, so the + check has to also happen where the value is used.""" + q = DualCondorRunQueue() + q._extra_transfer_input_files = [bad] + name = archive.register({"x": 1}, target_level=1) + with pytest.raises(ValueError): + q.build_worker(archive, name, 1) + + +def test_extras_colliding_with_each_other_are_rejected(archive): + """Two different objects that flatten to the same sandbox filename. + Neither collides with anything the archive stages — only with each + other — so the reserved-name check could not see it.""" + q = DualCondorRunQueue(extra_transfer_input_files=[ + "osdf:///siteA/data.h5", "osdf:///siteB/data.h5"]) + name = archive.register({"x": 1}, target_level=1) + with pytest.raises(ValueError, match="resolve to"): + q.build_worker(archive, name, 1) + + +def test_output_extras_colliding_after_expansion_are_rejected(archive): + """`a_{level}` and `a_1` are distinct templates that expand to the + same name at level 1.""" + q = DualCondorRunQueue(extra_transfer_output_files=["a_{level}", "a_1"]) + name = archive.register({"x": 1}, target_level=1) + with pytest.raises(ValueError, match="resolve to"): + q.build_worker(archive, name, 1) + + +@pytest.mark.parametrize("raw", ["wxyz", b"abc"]) +def test_output_raw_collection_type_is_checked_at_submit(archive, raw): + """The input side validates the whole collection before per-entry + work; the output side only looped. A bare str/bytes tuple()s into one + entry per character, and each single character passes the per-entry + checks, so a garbage transfer_output_files line was emitted with no + error at all.""" + q = DualCondorRunQueue() + q._extra_transfer_output_files = raw + name = archive.register({"x": 1}, target_level=1) + with pytest.raises(TypeError, match="not a bare string"): + q.build_worker(archive, name, 1) + + +def test_attribute_style_placeholder_names_the_contract(archive): + """`{sim_name.bogus}` raised a bare AttributeError past the handler.""" + q = DualCondorRunQueue(extra_transfer_output_files=["{sim_name.bogus}"]) + name = archive.register({"x": 1}, target_level=1) + with pytest.raises(ValueError, match="placeholder"): + q.build_worker(archive, name, 1) + + +@pytest.mark.parametrize("key", [ + "Transfer_Input_Files", "TRANSFER_INPUT_FILES", "transfer_Input_files", + "Transfer_Output_Files", "TRANSFER_OUTPUT_REMAPS", " transfer_input_files ", +]) +def test_protected_commands_are_matched_case_insensitively(archive, key): + """HTCondor command names are case-insensitive, so an exact lowercase + guard let `Transfer_Input_Files` through — reinstating the exact + substitution the guard exists to prevent, with the frozen code/ and + params.json silently dropped from the job.""" + q = DualCondorRunQueue(extra_condor_cmds={key: "/tmp/evil.dat"}) + name = archive.register({"x": 1}, target_level=1) + with pytest.raises(ValueError, match="case-insensitive"): + q.build_worker(archive, name, 1) + + +def test_per_sim_override_is_also_matched_case_insensitively(archive): + """Archive.set_resources merges into the same dict, so it must be + covered by the same pass.""" + name = archive.register({"x": 1}, target_level=1) + archive.set_resources(name, extra_condor_cmds={ + "Transfer_Input_Files": "/tmp/evil.dat"}) + q = DualCondorRunQueue() + with pytest.raises(ValueError, match="case-insensitive"): + q.build_worker(archive, name, 1) diff --git a/MonteCarloMarginalizeCode/Code/RIFT/simulation_manager/tests/test_dedup_roundtrip.py b/MonteCarloMarginalizeCode/Code/RIFT/simulation_manager/tests/test_dedup_roundtrip.py new file mode 100644 index 000000000..b28cf240a --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/RIFT/simulation_manager/tests/test_dedup_roundtrip.py @@ -0,0 +1,441 @@ +"""Dedup must survive reopening an archive. + +`Archive` keeps its dedup buckets in memory, keyed by +``_safe_hashable(lookup_key(params))``, and rebuilds them from +``index.jsonl`` every time the archive is constructed. That makes the +bucket key a *persisted* value, so it has to be stable across a JSON +round-trip as well as hashable. + +A tuple is not. JSON has no tuple type, so a backend whose +``lookup_key`` returns a tuple — including this tree's own +``backends/gw_pe_synthetic/lookup_key.py`` — gets a list back on +reopen. Hashing the list fails, `_safe_hashable` falls back to the +repr sentinel, and that never equals the freshly-computed tuple. The +bucket misses, `find_existing` returns None, and `register` mints a +duplicate sim for physics the archive already has. Nothing errors; the +campaign just quietly pays twice. + +Run with the RIFT-importable interpreter, e.g.: + + PYTHONPATH=<...>/MonteCarloMarginalizeCode/Code \ + python -m pytest -q .../simulation_manager/tests/test_dedup_roundtrip.py +""" + +from __future__ import annotations + +import json + +import pytest + +from RIFT.simulation_manager.database import ( + Archive, Manifest, _safe_hashable, +) + + +# --------------------------------------------------------------------------- +# _safe_hashable, directly +# --------------------------------------------------------------------------- + +def test_list_and_tuple_canonicalize_together(): + """The core invariant: a JSON-round-tripped tuple must land in the + same bucket as the tuple it came from.""" + key = (0.05, 0.2, "1d_spherical") + restored = json.loads(json.dumps(list(key))) + assert _safe_hashable(restored) == _safe_hashable(key) + + +def test_canonical_form_is_hashable(): + assert hash(_safe_hashable([1, 2, [3, 4]])) is not None + + +def test_nested_lists_canonicalize(): + assert _safe_hashable([1, [2, 3]]) == _safe_hashable((1, (2, 3))) + + +def test_dicts_canonicalize_regardless_of_insertion_order(): + a = {"x": 1, "y": [2, 3]} + b = {"y": [2, 3], "x": 1} + assert _safe_hashable(a) == _safe_hashable(b) + assert hash(_safe_hashable(a)) is not None + + +@pytest.mark.parametrize("key", [ + 1, 1.0, 1.5, -0.0, "a", + True, False, None, # str() gives True/False/None, + float("inf"), float("-inf"), # JSON gives true/false/null/Infinity +]) +def test_dict_keys_survive_jsons_own_coercion(key): + """JSON coerces dict keys to strings, but *not* via str(): + True -> "true", None -> "null", inf -> "Infinity". Canonicalizing + with str() would put the fresh and rehydrated forms in different + buckets for exactly those keys.""" + d = {key: "v"} + restored = json.loads(json.dumps(d)) + assert _safe_hashable(restored) == _safe_hashable(d) + + +def test_colliding_coerced_keys_agree_with_json(): + """{True: 'a', "true": 'b'} both coerce to "true"; JSON collapses + them last-wins. The canonical form has to collapse the same way, or + fresh and rehydrated disagree on how many entries there are.""" + d = {True: "a", "true": "b"} + restored = json.loads(json.dumps(d)) + assert _safe_hashable(restored) == _safe_hashable(d) + assert len(_safe_hashable(d)) == 1 + + +def test_dict_ordering_is_total_across_mixed_key_types(): + """Mixed key types must not raise on sort.""" + key = _safe_hashable({1: "a", "b": 2, 3.5: "c", None: "d", True: "e"}) + assert hash(key) is not None + + +def test_unserializable_key_falls_back_without_raising(): + """A tuple dict-key is not JSON-representable, so such a lookup_key + could never have been persisted; canonicalization must degrade + rather than explode.""" + key = _safe_hashable({(1, 2): "x"}) + assert hash(key) is not None + + +def test_scalars_pass_through(): + for v in ("a", 1, 1.5, None, True): + assert _safe_hashable(v) == v + + +def test_genuinely_unhashable_still_falls_back(): + class Weird: + __hash__ = None + + got = _safe_hashable(Weird()) + assert isinstance(got, tuple) and got[0] == "__unhashable__" + + +# --------------------------------------------------------------------------- +# Through a real Archive +# --------------------------------------------------------------------------- + +def _generator_src(): + return ( + "import json, os\n" + "def run(params, sim_dir, level, prev_levels):\n" + " p = os.path.join(sim_dir, 'level_%d.json' % level)\n" + " with open(p, 'w') as f:\n" + " json.dump({'level': level}, f)\n" + " return p\n" + ) + + +def _tuple_lookup_key_src(): + """A tuple-returning lookup_key, exactly the shape gw_pe_synthetic + (and any natural backend) uses.""" + return ( + "def lookup_key(params):\n" + " return (round(float(params.get('mc', 0.0)), 3),\n" + " round(float(params.get('eta', 0.0)), 4))\n" + ) + + +def _dict_lookup_key_src(): + """A dict-returning lookup_key keyed on bools, which JSON coerces to + "true"/"false" — not the "True"/"False" that str() produces. + + Mixed key types are fine now: `register` normalizes the key through + JSON before storing it, so `Index._write_all`'s sort_keys=True sees + strings. That was not always true — see the mixed-key tests at the + bottom of this file for the regression. + """ + return ( + "def lookup_key(params):\n" + " return {True: round(float(params.get('mc', 0.0)), 3),\n" + " False: round(float(params.get('eta', 0.0)), 4)}\n" + ) + + +def _same_q_src(): + return ( + "def same_q(a, b):\n" + " return (abs(float(a.get('mc', 0)) - float(b.get('mc', 0))) < 1e-6\n" + " and abs(float(a.get('eta', 0)) - float(b.get('eta', 0))) < 1e-8)\n" + ) + + +@pytest.fixture +def archive_factory(tmp_path): + def _make(subdir, lookup_key_src=None): + code = tmp_path / (subdir + "_src") + code.mkdir(parents=True, exist_ok=True) + (code / "generator.py").write_text(_generator_src()) + (code / "lookup_key.py").write_text( + lookup_key_src or _tuple_lookup_key_src()) + (code / "same_q.py").write_text(_same_q_src()) + + manifest = Manifest.new( + name="dedup_roundtrip", + request_queue_kind="local", + run_queue_kind="local", + same_q_entrypoint="same_q:same_q", + lookup_key_entrypoint="lookup_key:lookup_key", + ) + return Archive( + base_location=tmp_path / subdir, + manifest=manifest, + generator_spec={"module_path": str(code / "generator.py"), + "entrypoint": "generator:run"}, + same_q_spec={"module_path": str(code / "same_q.py"), + "entrypoint": "same_q:same_q"}, + lookup_key_spec={"module_path": str(code / "lookup_key.py"), + "entrypoint": "lookup_key:lookup_key"}, + ) + return _make + + +PARAMS = {"mc": 1.2, "eta": 0.24} + + +def test_dedup_within_one_session(archive_factory): + a = archive_factory("arch") + first = a.register(dict(PARAMS), target_level=1) + assert a.register(dict(PARAMS), target_level=1) == first + assert len(list(a.index.all())) == 1 + + +def test_dedup_survives_reopen(archive_factory, tmp_path): + """The regression. Before the _safe_hashable fix this registered a + second sim for identical physics.""" + a = archive_factory("arch") + first = a.register(dict(PARAMS), target_level=1) + + reopened = Archive(base_location=tmp_path / "arch") + assert reopened.register(dict(PARAMS), target_level=1) == first + assert len(list(reopened.index.all())) == 1 + + +def test_find_existing_matches_after_reopen(archive_factory, tmp_path): + a = archive_factory("arch") + name = a.register(dict(PARAMS), target_level=1) + + reopened = Archive(base_location=tmp_path / "arch") + assert reopened.find_existing(dict(PARAMS)) == name + + +def test_distinct_physics_still_separates_after_reopen(archive_factory, tmp_path): + a = archive_factory("arch") + first = a.register(dict(PARAMS), target_level=1) + + reopened = Archive(base_location=tmp_path / "arch") + other = reopened.register({"mc": 9.9, "eta": 0.1}, target_level=1) + assert other != first + assert len(list(reopened.index.all())) == 2 + + +def test_dict_lookup_key_dedups_across_reopen(archive_factory, tmp_path): + """The reopen regression for dict-valued lookup keys whose keys JSON + coerces differently from str() — True -> "true", None -> "null". + Under str()-based canonicalization this registered a duplicate.""" + a = archive_factory("dictarch", lookup_key_src=_dict_lookup_key_src()) + first = a.register(dict(PARAMS), target_level=1) + + reopened = Archive(base_location=tmp_path / "dictarch") + assert reopened.find_existing(dict(PARAMS)) == first + assert reopened.register(dict(PARAMS), target_level=1) == first + assert len(list(reopened.index.all())) == 1 + + +def test_dict_lookup_key_still_separates_distinct_physics(archive_factory, + tmp_path): + a = archive_factory("dictarch", lookup_key_src=_dict_lookup_key_src()) + first = a.register(dict(PARAMS), target_level=1) + + reopened = Archive(base_location=tmp_path / "dictarch") + other = reopened.register({"mc": 9.9, "eta": 0.1}, target_level=1) + assert other != first + assert len(list(reopened.index.all())) == 2 + + +def test_stored_key_is_what_we_think_it_is(archive_factory, tmp_path): + """Guard the premise: the key really is persisted as a JSON list.""" + a = archive_factory("arch") + a.register(dict(PARAMS), target_level=1) + row = list(a.index.all())[0] + assert isinstance(row["lookup_key"], list) + + +# --------------------------------------------------------------------------- +# Dict-valued lookup_key, through a real archive +# +# _safe_hashable alone is not enough evidence. `register` stores the key in +# the index row and `Index._write_all` serializes rows with sort_keys=True, +# so a key set JSON would coerce to strings still reaches sorted() raw. A +# key like {True: 'a', 'true': 'b'} passed the unit test above while +# register() raised +# TypeError: '<' not supported between instances of 'str' and 'bool' +# These drive register -> reopen instead. +# --------------------------------------------------------------------------- + +def _dict_key_archive(tmp_path, subdir, lookup_body): + code = tmp_path / (subdir + "_src") + code.mkdir(parents=True, exist_ok=True) + (code / "generator.py").write_text(_generator_src()) + (code / "lookup_key.py").write_text(lookup_body) + (code / "same_q.py").write_text( + "def same_q(a, b):\n" + " return a.get('tag') == b.get('tag')\n") + manifest = Manifest.new( + name="dict_key", request_queue_kind="local", run_queue_kind="local", + same_q_entrypoint="same_q:same_q", + lookup_key_entrypoint="lookup_key:lookup_key", + ) + return Archive( + base_location=tmp_path / subdir, manifest=manifest, + generator_spec={"module_path": str(code / "generator.py"), + "entrypoint": "generator:run"}, + same_q_spec={"module_path": str(code / "same_q.py"), + "entrypoint": "same_q:same_q"}, + lookup_key_spec={"module_path": str(code / "lookup_key.py"), + "entrypoint": "lookup_key:lookup_key"}, + ) + + +_MIXED_KEY_LOOKUP = ( + "def lookup_key(params):\n" + " return {True: 'a', 'true': 'b', 'tag': params.get('tag')}\n" +) + +_NESTED_COMPOSITION_LOOKUP = ( + # SuperNu-shaped: a composition dict mixing atomic numbers and symbols. + "def lookup_key(params):\n" + " return {'tag': params.get('tag'),\n" + " 'comp': {26: 0.5, 'Fe': 0.5, None: 0.0}}\n" +) + + +def test_mixed_type_dict_keys_can_be_registered(tmp_path): + """The regression: register() raised on sorted() before this fix.""" + a = _dict_key_archive(tmp_path, "arch", _MIXED_KEY_LOOKUP) + name = a.register({"tag": "x"}, target_level=1) + assert name + + +def test_mixed_type_dict_keys_dedup_across_reopen(tmp_path): + a = _dict_key_archive(tmp_path, "arch", _MIXED_KEY_LOOKUP) + first = a.register({"tag": "x"}, target_level=1) + + reopened = Archive(base_location=tmp_path / "arch") + assert reopened.register({"tag": "x"}, target_level=1) == first + assert len(list(reopened.index.all())) == 1 + + +def test_nested_composition_keys_dedup_across_reopen(tmp_path): + """SuperNu-shaped: atomic numbers, element symbols and None together.""" + a = _dict_key_archive(tmp_path, "arch", _NESTED_COMPOSITION_LOOKUP) + first = a.register({"tag": "x"}, target_level=1) + + reopened = Archive(base_location=tmp_path / "arch") + assert reopened.register({"tag": "x"}, target_level=1) == first + assert len(list(reopened.index.all())) == 1 + + +def test_stored_dict_key_is_json_normalized(tmp_path): + """What lands in index.jsonl must already be the coerced form, or + the next write hits the same sorted() failure.""" + a = _dict_key_archive(tmp_path, "arch", _MIXED_KEY_LOOKUP) + a.register({"tag": "x"}, target_level=1) + stored = list(a.index.all())[0]["lookup_key"] + assert all(isinstance(k, str) for k in stored) + assert stored["true"] == "b" # collision collapsed last-wins + + +def test_distinct_physics_still_separates_with_dict_keys(tmp_path): + a = _dict_key_archive(tmp_path, "arch", _MIXED_KEY_LOOKUP) + first = a.register({"tag": "x"}, target_level=1) + + reopened = Archive(base_location=tmp_path / "arch") + other = reopened.register({"tag": "y"}, target_level=1) + assert other != first + assert len(list(reopened.index.all())) == 2 + + +def test_index_survives_a_second_write_after_reopen(tmp_path): + """_write_all runs again on the next upsert; the stored key must + still be sortable then.""" + a = _dict_key_archive(tmp_path, "arch", _MIXED_KEY_LOOKUP) + a.register({"tag": "x"}, target_level=1) + + reopened = Archive(base_location=tmp_path / "arch") + reopened.register({"tag": "y"}, target_level=1) # triggers a rewrite + again = Archive(base_location=tmp_path / "arch") + assert len(list(again.index.all())) == 2 + + +def test_unpersistable_lookup_key_names_the_contract(tmp_path): + """A set cannot live in index.jsonl. Say so, rather than surfacing a + json TypeError from the write path.""" + a = _dict_key_archive( + tmp_path, "arch", + "def lookup_key(params):\n return {frozenset(['a']): 1}\n") + with pytest.raises(TypeError, match="JSON-serializable"): + a.register({"tag": "x"}, target_level=1) + + +# --------------------------------------------------------------------------- +# rebuild_index and failed registration +# --------------------------------------------------------------------------- + +def test_rebuild_index_normalizes_the_key(tmp_path): + """register -> rebuild -> reopen. rebuild_index stored the raw key, + so a mixed-key archive that registered and reopened cleanly still + failed here with the original sorted() TypeError.""" + a = _dict_key_archive(tmp_path, "arch", _MIXED_KEY_LOOKUP) + first = a.register({"tag": "x"}, target_level=1) + + assert a.rebuild_index() == 1 + + reopened = Archive(base_location=tmp_path / "arch") + assert reopened.register({"tag": "x"}, target_level=1) == first + assert len(list(reopened.index.all())) == 1 + + +def test_rebuild_index_keeps_dedup_working_for_nested_keys(tmp_path): + a = _dict_key_archive(tmp_path, "arch", _NESTED_COMPOSITION_LOOKUP) + first = a.register({"tag": "x"}, target_level=1) + a.rebuild_index() + reopened = Archive(base_location=tmp_path / "arch") + assert reopened.find_existing({"tag": "x"}) == first + + +def test_failed_registration_leaves_no_partial_sim(tmp_path): + """An unpersistable key raised the intended error but left sims/1 + behind, with params.json and status.json, unknown to the index.""" + a = _dict_key_archive( + tmp_path, "arch", + "def lookup_key(params):\n return {frozenset(['a']): 1}\n") + + with pytest.raises(TypeError, match="JSON-serializable"): + a.register({"tag": "x"}, target_level=1) + + sims = tmp_path / "arch" / "sims" + assert list(sims.iterdir()) == [], "left a half-registered simulation" + assert list(a.index.all()) == [] + + +#: Fails only for tag == "bad", so one archive can see both outcomes. +_SOMETIMES_BAD_LOOKUP = ( + "def lookup_key(params):\n" + " if params.get('tag') == 'bad':\n" + " return {frozenset(['a']): 1}\n" + " return 'ok|' + str(params.get('tag'))\n" +) + + +def test_a_failed_registration_does_not_consume_a_name(tmp_path): + """Names are allocated by counting entries in sims/, so an orphan + directory shifts every later name. Using two archives here would not + test that — the orphan has to be in the SAME archive.""" + a = _dict_key_archive(tmp_path, "arch", _SOMETIMES_BAD_LOOKUP) + + with pytest.raises(TypeError, match="JSON-serializable"): + a.register({"tag": "bad"}, target_level=1) + + assert a.register({"tag": "good"}, target_level=1) == "1" + assert len(list(a.index.all())) == 1 diff --git a/MonteCarloMarginalizeCode/Code/bin/helper_LDG_Events.py b/MonteCarloMarginalizeCode/Code/bin/helper_LDG_Events.py index 6b781fef7..c1597947d 100755 --- a/MonteCarloMarginalizeCode/Code/bin/helper_LDG_Events.py +++ b/MonteCarloMarginalizeCode/Code/bin/helper_LDG_Events.py @@ -28,6 +28,9 @@ # Backward compatibility from RIFT.misc.dag_utils_generic import which +# leaf module: numpy only, so this does not drag numba/cupy into the helper +from RIFT.likelihood.time_interp_choice import ( + BARE_FLAG_SENTINEL, CROSSOVER_GUIDANCE, resolve_interpolate_time_request) lalapps_path2cache = which('lal_path2cache') ligolw_add = 'igwn_ligolw_add' if not(which(ligolw_add)): @@ -218,7 +221,7 @@ def get_observing_run(t): parser.add_argument("--internal-ile-auto-logarithm-offset",action='store_true',help="Passthrough to ILE") parser.add_argument("--internal-ile-use-lnL",action='store_true',help="Passthrough to ILE. Will DISABLE auto-logarithm-offset and manual-logarithm-offset") parser.add_argument("--internal-ile-n-chunk",default=None,type=int,help="Override the extrinsic chunk size (--n-chunk) passed to ILE. Default: 40000, scaled linearly with SNR above 40 and capped at 160000. Rationale: at high SNR the posterior is a vanishing fraction of the prior volume, so a small chunk gives few informative samples per adaptation step; measured collapse on a truth-known SNR ladder falls 88%%->50%% (SNR160) and 69%%->25%% (SNR80) going 1e4->1.6e5, and the gain survives at fixed budget. Larger chunks cost GPU memory, so raise the ILE memory request if you raise this a lot.") -parser.add_argument("--internal-ile-interpolate-time",action='store_true',help="Evaluate Q_lm at FRACTIONAL detector times by cubic interpolation instead of snapping to the nearest sample bin (passes --interpolate-time True). Requires the maintained NoLoop likelihood, i.e. the --vectorized --gpu --force-xpy combination. Nearest-bin evaluation injects a time-quantization non-smoothness into the extrinsic likelihood surface that is a discretization artifact, not physics; removing it makes convergence more robust. Default off for backward compatibility.") +parser.add_argument("--internal-ile-interpolate-time",nargs='?',const=BARE_FLAG_SENTINEL,default=None,type=str,help="Evaluate Q_lm at FRACTIONAL detector times instead of snapping to the nearest sample bin, in the maintained NoLoop likelihood (needs --time-marginalization --vectorized and one of --gpu/--rotation-slow/--freqresponse; the driver REFUSES rather than ignores otherwise). REQUIRES AN EXPLICIT STENCIL: nearest|cubic|sinc -- automatic selection was removed as measurably unreliable, and a bare flag is rejected rather than silently doing nothing. MEASURED GUIDANCE (SEOBNRv4, an IMR model): %s. 'nearest' is never competitive and is already unusable at O4 SNRs. Error grows as SNR^2, so this matters more at 3G. Cost: sinc is ~4.2-4.5x cubic on CPU, ~1.6-3.0x on GPU. Full tables, limitations and provenance: RIFT/likelihood/DESIGN_q_window_stencil.md. Default off." % CROSSOVER_GUIDANCE) parser.add_argument("--internal-cip-use-lnL",action='store_true') parser.add_argument("--ile-n-eff",default=50,type=int,help="Target n_eff passed to ILE. Try to keep above 2") parser.add_argument("--test-convergence",action='store_true',help="If present, the code will terminate if the convergence test passes. WARNING: if you are using a low-dimensional model the code may terminate during the low-dimensional model!") @@ -260,6 +263,11 @@ def get_observing_run(t): parser.add_argument("--verbose",action='store_true') opts= parser.parse_args() +# Resolve the sub-sample stencil request IMMEDIATELY, so a bare flag / retired 'True' / typo +# fails here rather than after a whole workflow has been built and submitted. Returns None when +# the feature is off; a canonical stencil name otherwise. +time_interp_choice = resolve_interpolate_time_request(opts.internal_ile_interpolate_time) + if opts.assume_matter_but_primary_bh: opts.assume_matter=True @@ -1132,9 +1140,20 @@ def crit_m2(delta): n_chunk_ile = int(40000 * np.max([1.0, event_dict["SNR"] / 40.0])) n_chunk_ile = int(np.min([n_chunk_ile, 160000])) helper_ile_args += " --n-chunk " + str(n_chunk_ile) + " " -if opts.internal_ile_interpolate_time: - # cubic Q_lm time interpolation; needs the NoLoop path (--vectorized --gpu --force-xpy) - helper_ile_args += " --interpolate-time True " +if time_interp_choice is not None: + # Sub-sample Q_lm time interpolation; needs the maintained NoLoop path. The stencil was + # already validated at parse time (see resolve_interpolate_time_request above), so by here it + # is one of nearest|cubic|sinc. The name goes on the ILE command line verbatim, so a + # completed run's stencil is readable off the .sub file. + print(" ==> Q_lm time interpolation: stencil '{}' (explicit; automatic selection was " + "removed as unreliable -- see RIFT.likelihood.time_interp_choice for the measured " + "guidance)".format(time_interp_choice)) + # + # VERSION SKEW, one-directional: an ILE predating stencil names maps any unrecognised + # --interpolate-time value to 'nearest' through a truthiness test, with no error and no log + # line -- so an OLD ILE driven by THIS helper silently runs 'nearest'. A new ILE raises, so + # the reverse pairing is safe. Pair this pipeline with an ILE from the same checkout. + helper_ile_args += " --interpolate-time " + time_interp_choice + " " if opts.internal_ile_auto_logarithm_offset and not opts.internal_ile_use_lnL: helper_ile_args += " --auto-logarithm-offset " diff --git a/MonteCarloMarginalizeCode/Code/bin/hyperpipe_conf.yaml b/MonteCarloMarginalizeCode/Code/bin/hyperpipe_conf.yaml index d43162407..4dab5a30a 100644 --- a/MonteCarloMarginalizeCode/Code/bin/hyperpipe_conf.yaml +++ b/MonteCarloMarginalizeCode/Code/bin/hyperpipe_conf.yaml @@ -67,7 +67,11 @@ puff: # Leave keys null to take the updater's built-in defaults. settings: update-method: null # smc-mala-bd | smc-mala | birth-death | ucb | puffball - tracer-fit-method: null # rf | rbf | polynomial | quadratic + tracer-fit-method: null # rf | rbf | polynomial | quadratic | gp_linmean + # gp_linmean: linear-mean GP; extrapolates past the + # training hull (rf goes flat) and gives ucb a real sigma + tracer-lnl-floor-delta: null # clamp training lnL at max-DELTA instead of cutting + # catastrophic-fit outliers; null = off (legacy) ucb-kappa: null # UCB exploration weight (default 2.0) ucb-n-candidates: null # UCB candidate pool size (default 20000) n-mala-steps: null # int diff --git a/MonteCarloMarginalizeCode/Code/bin/ile_postproc_add_time b/MonteCarloMarginalizeCode/Code/bin/ile_postproc_add_time index a3da2733b..462e7a4c0 100755 --- a/MonteCarloMarginalizeCode/Code/bin/ile_postproc_add_time +++ b/MonteCarloMarginalizeCode/Code/bin/ile_postproc_add_time @@ -174,10 +174,18 @@ else: # # Initialize the RNG, if needed # -# TODO: Do we seed a given instance of the integrator, or set it for all -# or both? +# Seed EVERY backend a sampler can draw from, not just numpy: the samplers draw +# through self.xpy / xpy_default, which is cupy on GPU, and cupy has its own +# global generator. See RIFT/integrators/seeding.py. +# +# NOTE: this script is currently dead -- it reads several opts (seed, +# manual_logarithm_offset, ...) that no add_option ever defines, so it dies with +# AttributeError here on any invocation. Kept in step with the live ILE drivers +# so that reviving it does not reintroduce the GPU seeding bug; resurrecting it +# is out of scope for this change. if opts.seed is not None: - numpy.random.seed(opts.seed) + from RIFT.integrators.seeding import seed_everything + seed_everything(opts.seed) diff --git a/MonteCarloMarginalizeCode/Code/bin/integrate_likelihood_extrinsic b/MonteCarloMarginalizeCode/Code/bin/integrate_likelihood_extrinsic index 997dde333..afa3dfe86 100755 --- a/MonteCarloMarginalizeCode/Code/bin/integrate_likelihood_extrinsic +++ b/MonteCarloMarginalizeCode/Code/bin/integrate_likelihood_extrinsic @@ -199,7 +199,7 @@ integration_params.add_option("--n-eff", type=int, default=100, help="Total numb integration_params.add_option("--fairdraw-extrinsic-output", action='store_true' , help="Output is fair draw, rather than being comprehensive") integration_params.add_option("--n-chunk", type=int, help="Chunk'.",default=10000) integration_params.add_option("--convergence-tests-on",default=False,action='store_true') -integration_params.add_option("--seed", type=int, help="Random seed to use. Default is to not seed the RNG.") +integration_params.add_option("--seed", type=int, help="Random seed to use. Default is to not seed the RNG. Seeds every backend the samplers draw through (numpy, cupy, torch), so a seeded run is reproducible on GPU as well as CPU.") integration_params.add_option("--no-adapt", action="store_true", help="Turn off adaptive sampling. Adaptive sampling is on by default.") integration_params.add_option("--no-adapt-distance", action="store_true", help="Turn off adaptive sampling, just for distance. Adaptive sampling is on by default.") integration_params.add_option("--adapt-weight-exponent", type=float, default=1.0, help="Exponent to use with weights (likelihood integrand) when doing adaptive sampling. Used in tandem with --adapt-floor-level to prevent overconvergence. Default is 1.0.") @@ -365,10 +365,12 @@ n_eff = opts.n_eff # Effective number of points evaluated # # Initialize the RNG, if needed # -# TODO: Do we seed a given instance of the integrator, or set it for all -# or both? +# Seed EVERY backend a sampler can draw from, not just numpy: the samplers draw +# through self.xpy / xpy_default, which is cupy on GPU, and cupy has its own +# global generator. See RIFT/integrators/seeding.py. if opts.seed is not None: - numpy.random.seed(opts.seed) + from RIFT.integrators.seeding import seed_everything + seed_everything(opts.seed) # # Gather information about a injection put in the data diff --git a/MonteCarloMarginalizeCode/Code/bin/integrate_likelihood_extrinsic_batchmode b/MonteCarloMarginalizeCode/Code/bin/integrate_likelihood_extrinsic_batchmode index a8b40987c..84b9c9205 100755 --- a/MonteCarloMarginalizeCode/Code/bin/integrate_likelihood_extrinsic_batchmode +++ b/MonteCarloMarginalizeCode/Code/bin/integrate_likelihood_extrinsic_batchmode @@ -49,8 +49,11 @@ from igwn_ligolw import utils, ligolw import glue.lal import RIFT.lalsimutils as lalsimutils +from RIFT.likelihood.time_interp_choice import CROSSOVER_GUIDANCE as _CROSSOVER_GUIDANCE from RIFT.precision import RiftFloat import RIFT.integrators.mcsampler as mcsampler +from RIFT.integrators.rvs_record import (RvsRecord as _RvsRecord, # see DESIGN_rvs_naming.md + SamplerOutputMixin) # NOTE: the name 'mcsampler' above is REBOUND below to mcsamplerGPU for some --sampler-method # choices, so the zoom-box helpers are imported under their own names. They are backend-agnostic: # each closure infers its array module from the argument it is handed (numpy on the CPU/AV paths, @@ -306,7 +309,7 @@ integration_params.add_option("--fairdraw-extrinsic-output", action='store_true' integration_params.add_option("--fairdraw-extrinsic-output-n-max", default=5, type=int, help="Maximum number of fair draws per ILE evaluation.") integration_params.add_option("--n-chunk", type=int, help="Chunk'.",default=10000) integration_params.add_option("--convergence-tests-on",default=False,action='store_true') -integration_params.add_option("--seed", type=int, help="Random seed to use. Default is to not seed the RNG.") +integration_params.add_option("--seed", type=int, help="Random seed to use. Default is to not seed the RNG. Seeds every backend the samplers draw through (numpy, cupy, torch), so a seeded run is reproducible on GPU as well as CPU.") integration_params.add_option("--no-adapt", action="store_true", help="Turn off adaptive sampling. Adaptive sampling is on by default.") integration_params.add_option("--force-adapt-all", action="store_true", help="Force adaptive sampling for all parameters.") integration_params.add_option("--force-reset-all", action="store_true", help="Force reset of sampling every iteration. (Recommended if AC and not using no-adapt-after-first)") @@ -323,7 +326,7 @@ integration_params.add_option("--internal-gmm-adaptive-components",action='store integration_params.add_option("--internal-gmm-max-components",type=int,default=8,help="Cap on the per-group component count for --internal-gmm-adaptive-components (default 8).") integration_params.add_option("--internal-gmm-defensive-frac",type=float,default=0.0,help="Weight of the broad box-covering 'defensive' mixture component added to each adaptive GMM group (default 0 = OFF). Intended to bound the importance weights (Hesterberg defensive IS), but on a wide extrinsic prior the broad component draws physically-extreme points where the likelihood is NaN and it did not improve n_eff on the SNR~82 benchmark -- opt-in only.") integration_params.add_option("--internal-gmm-inflate",type=float,default=1.0,help="Covariance inflation factor (std multiplier) applied to each adaptive GMM component (default 1.0 = none). A value >1 widens the proposal relative to the elite cloud it was fit to; complements --internal-gmm-defensive-frac.") -integration_params.add_option("--interpolate-time", default=False,help="If using the maintained NoLoop likelihood, evaluate Q_lm at fractional detector times using cubic interpolation instead of nearest sample bins. Accepts truthy values such as True/1/yes. (Default=false)") +integration_params.add_option("--interpolate-time", default=False,help="Sub-sample stencil for evaluating Q_lm at fractional detector times, instead of snapping to the nearest sample bin. Accepts 'nearest', 'cubic', 'sinc', or a legacy truthy value (True/1/yes) meaning 'cubic'. WHICH TO USE is set by the bandwidth of Q(t), which is NOT fmax -- Q is band-limited by whichever is lower, fmax or the template's own cutoff, so it depends on the MASSES and on FMIN. MEASURED with SEOBNRv4 (an IMR model): %s. fmin matters as much as mass -- cubic degrades from fmin 20 to 150 at fixed mass (endpoint ratios 6.5x at M=9 and 9.6x at M=20, and NOT monotone in between) while sinc stays flat, which is why the crossover rises. NEAREST is never competitive (200-443 nats) and reaches 1 nat of error by SNR 2-6. Do not trust inspiral-only (TaylorT4) numbers for this: no merger-ringdown, understates the band by 2-3.7x. Error grows as SNR^2. COST of sinc vs cubic: ~4.2-4.5x on CPU, ~1.6-3.0x on GPU. All three stencils have CPU and GPU implementations. Requires the maintained NoLoop likelihood: this is REFUSED, not ignored, if the configuration cannot honour it. Measured tables and limitations: RIFT/likelihood/DESIGN_q_window_stencil.md. (Default=false, i.e. nearest)" % _CROSSOVER_GUIDANCE) integration_params.add_option("--d-prior",default='Euclidean' ,type=str,help="Distance prior for dL. Options are dL^2 (Euclidean), 'pseudo_cosmo', and 'cosmo' and 'cosmo_sourceframe' .") integration_params.add_option("--d-prior-redshift", action='store_true', help="If true, distance prior is computed in redshift. This option MAY be enforced for 'cosmo' sampling") integration_params.add_option("--d-max", default=10000,type=float,help="Maximum distance in volume integral. Used to SET THE PRIOR; changing this value changes the numerical answer.") @@ -457,7 +460,35 @@ def _truthy_option(value): return False return str(value).strip().lower() in ("1", "true", "t", "yes", "y", "on") -opts._noloop_time_interp = "cubic" if _truthy_option(opts.interpolate_time) else "nearest" +_TI_LEGACY_BOOLEAN = ("1", "true", "t", "yes", "y", "on", + "0", "false", "f", "no", "n", "off", "none") +_ti_raw = str(opts.interpolate_time).strip().lower() +if _ti_raw in ("nearest", "cubic", "sinc"): + # explicit stencil name + opts._noloop_time_interp = _ti_raw +elif _ti_raw in _TI_LEGACY_BOOLEAN: + # legacy boolean: truthy meant cubic + opts._noloop_time_interp = "cubic" if _truthy_option(opts.interpolate_time) else "nearest" +else: + # Anything else is a typo, and it must NOT be absorbed. Before this check a misspelled + # stencil ('sinK', 'lanczos') was simply non-truthy and so ran 'nearest' -- a silent change + # of the likelihood's time discretization, invisible in the log and indistinguishable from a + # run that never asked for interpolation at all. Now that the helper writes a resolved + # stencil NAME onto every --interpolate-time command line, a typo there has to be loud. + raise ValueError( + "--interpolate-time: unrecognised value %r. Use a stencil name (nearest|cubic|sinc) or " + "a legacy boolean (%s)." % (opts.interpolate_time, "|".join(_TI_LEGACY_BOOLEAN))) +# The LEGACY scalar path (FactoredLogLikelihoodTimeMarginalized) takes a plain boolean and has +# nothing to do with the NoLoop stencils. It used to be handed opts.interpolate_time raw, which +# was fine while that was only ever truthy/falsy -- but 'nearest' is a non-empty string, so once +# stencil NAMES became legal spellings, "--interpolate-time nearest" would have switched the +# legacy path's interpolation ON while meaning the exact opposite in NoLoop. Derive an honest +# boolean instead: only the two genuinely-interpolating stencils count as "interpolate". +opts._legacy_interpolate_time = opts._noloop_time_interp in ("cubic", "sinc") +# NOTE: deliberately NOT announcing the stencil here. opts.gpu is not resolved yet at this +# point, so we cannot yet tell whether the stencil will actually be used -- and a banner that +# names a stencil the run then ignores is worse than no banner, because it reads as proof. +# The announcement happens after the honoured-path check below. if opts.rotation_slow: # Path A/B slow-rotation: wired into BOTH the CPU-vectorized and GPU (xpy) branches; the @@ -598,6 +629,48 @@ if opts.gpu and xpy_default is numpy: if opts.force_xpy: opts.gpu=True +# --interpolate-time IS SILENTLY IGNORED ON SEVERAL PATHS. Now that opts.gpu is final, refuse to +# proceed if a sub-sample stencil was asked for and this configuration cannot honour it. +# +# THE PREREQUISITES ARE CONJUNCTIVE, and an earlier version of this guard got that wrong by +# checking only the last of them: +# +# * --time-marginalization. Without it the code takes the `if not opts.time_marginalization` +# branch and calls FactoredLogLikelihood, which has no stencil argument at all. +# * --vectorized. Without it the time-marginalized branch calls the SCALAR +# FactoredLogLikelihoodTimeMarginalized, which takes only the legacy boolean `interpolate` +# and therefore runs legacy cubic regardless of which stencil was named. +# * and then one of: --gpu (the maintained NoLoop path), --rotation-slow, or --freqresponse. +# Plain `--vectorized` without any of those calls DiscreteFactoredLogLikelihoodViaArrayVector, +# which also has no time_interp argument. +# +# Measured on the last of these before it was guarded: '--vectorized --force-xpy' without '--gpu' +# returned BIT-IDENTICAL lnL (74.32974090285529) for sinc and cubic at n_max 2e5, while the +# startup banner still announced the stencil. A whole comparison campaign ran against it. +_stencil_prereqs = ( + ('--time-marginalization', bool(opts.time_marginalization)), + ('--vectorized', bool(opts.vectorized)), + ('one of --gpu / --rotation-slow / --freqresponse', + bool(opts.gpu) or bool(opts.rotation_slow) or bool(opts.freqresponse)), +) +_stencil_missing = [name for name, ok in _stencil_prereqs if not ok] +_stencil_is_honoured = not _stencil_missing +if opts._noloop_time_interp != 'nearest' and not _stencil_is_honoured: + raise ValueError( + "--interpolate-time %r was requested, but this configuration cannot honour it: missing " + "%s. The likelihood that would actually run takes no sub-sample stencil and evaluates " + "Q_lm at the nearest sample bin (or, without --vectorized, applies the unrelated legacy " + "cubic switch). Add the missing option(s) -- --gpu accepts --force-xpy if no device is " + "present, which keeps the identical NoLoop code path on numpy -- or drop " + "--interpolate-time. Refusing rather than running a different likelihood than the one " + "you asked for." % (opts._noloop_time_interp, ", ".join(_stencil_missing))) +print(" Q_lm sub-sample time stencil: {} (from --interpolate-time {!r}); honoured by this " + "configuration: {} [time_marginalization={} vectorized={} gpu={} rotation_slow={} " + "freqresponse={}]; legacy scalar path interpolate={}".format( + opts._noloop_time_interp, opts.interpolate_time, _stencil_is_honoured, + bool(opts.time_marginalization), bool(opts.vectorized), bool(opts.gpu), + bool(opts.rotation_slow), bool(opts.freqresponse), opts._legacy_interpolate_time)) + manual_avoid_overflow_logarithm=opts.manual_logarithm_offset manual_avoid_overflow_logarithm_default = manual_avoid_overflow_logarithm @@ -684,10 +757,12 @@ n_eff = opts.n_eff # Effective number of points evaluated # # Initialize the RNG, if needed # -# TODO: Do we seed a given instance of the integrator, or set it for all -# or both? +# Seed EVERY backend a sampler can draw from, not just numpy: the samplers draw +# through self.xpy / xpy_default, which is cupy on GPU, and cupy has its own +# global generator. See RIFT/integrators/seeding.py. if opts.seed is not None: - numpy.random.seed(opts.seed) + from RIFT.integrators.seeding import seed_everything + seed_everything(opts.seed) if opts.event_time is not None: @@ -989,6 +1064,22 @@ _calpilot_logresp_list = [] # per-intrinsic-point per-realization log-respo calibration_nodes = None # (n_cal, 2*n_nodes_amp*len(dets)) per-det [amp_0..,phase_0..] blocks calibration_node_dets = None # detector order matching the node blocks calibration_n_nodes_amp = None # spline nodes per detector per (amp|phase) +def _cal_rng(stream): + """Generator for a calibration-side auxiliary draw. + + The base cal realizations are drawn from default_rng(opts.seed), but the + probe/growth paths used a bare default_rng(), which takes fresh entropy from + the OS and so is NOT covered by --seed: two identical seeded invocations could probe + a different cal error, grow to a different n_cal, and marginalize over different + realizations. Derive those streams from the seed instead, with a per-stream counter + so repeated calls stay independent of each other -- and of the base draw set -- while + remaining reproducible. Unseeded runs keep fresh entropy. + + The counter bookkeeping lives in RIFT.integrators.seeding.next_derived_rng, which + every other counter-advancing site in RIFT already goes through; keeping a second + registry here would be one more thing that has to not drift.""" + from RIFT.integrators.seeding import next_derived_rng + return next_derived_rng(stream) def _cal_setup_prior_with_nodes(psd_dict): """Populate calibration_realization_dict from broad-PRIOR cal draws. When --calibration-export-posterior is set, RETAIN the node vectors too (via @@ -1096,19 +1187,25 @@ def _draw_more_calibration_draws(n_more, psd_dict): log-weights, node vectors), and return JUST the new realizations dict so the caller can precompute only the new rholm blocks and append them. - Fresh, unseeded randomness ON PURPOSE: cal draws must remain independent across - points/workers -- the variance is disclosed (cal MC error budget) and reduced by - growing the draw set, never by sharing draws.""" + The added draws come from a stream INDEPENDENT of the original set, and of every + earlier growth round: independence is what the cal MC error budget assumes -- the + variance is disclosed and reduced by growing the draw set, never by sharing draws. + That stream is DERIVED from --seed when one was given (so the enlarged set, and + hence the likelihood, is reproducible) and taken from fresh OS entropy when it was + not. See _cal_rng.""" global calibration_realization_dict, calibration_log_weights, calibration_nodes import RIFT.calmarg.generate_realizations as _genr new = {} + # used by the two node-drawing branches; create_realizations (below) draws through + # numpy's global RNG, which seed_everything already covers. + _rng = _cal_rng('calmarg.extra_draws') if opts.calibration_proposal_breadcrumb: import RIFT.calmarg.breadcrumbs _bc = RIFT.calmarg.breadcrumbs.load(opts.calibration_proposal_breadcrumb) new, _lw, _nodes = _genr.seed_realizations_from_breadcrumb( _bc, 1./P.deltaF, P.deltaT, opts.fmin_template, fmax, opts.calibration_spline_count, n_more, fmin_ifo=cal_fmin_ifo, - rng=np.random.default_rng()) + rng=_rng) calibration_log_weights = np.concatenate([np.asarray(calibration_log_weights), np.asarray(_lw)]) if calibration_nodes is not None: calibration_nodes = np.vstack([calibration_nodes, _nodes]) @@ -1116,7 +1213,7 @@ def _draw_more_calibration_draws(n_more, psd_dict): _ret = _genr.draw_prior_realizations_with_nodes( opts.calibration_envelope_directory, list(psd_dict.keys()), 1./P.deltaF, P.deltaT, opts.fmin_template, fmax, opts.calibration_spline_count, n_more, - fmin_ifo=cal_fmin_ifo, rng=np.random.default_rng()) + fmin_ifo=cal_fmin_ifo, rng=_rng) new = _ret['realizations'] calibration_nodes = np.vstack([calibration_nodes, _ret['nodes']]) if calibration_nodes is not None else _ret['nodes'] else: @@ -1407,17 +1504,22 @@ redshift_to_distance = lambda x: x if (opts.d_prior == 'cosmo' or opts.d_prior == 'cosmo_sourceframe') and not opts.distance_marginalization: from astropy.cosmology import z_at_value from astropy import units as u - from astropy.cosmology import FlatLambdaCDM from astropy.units import Hz # ported form https://github.com/lscsoft/lalsuite/blob/master/lalinference/python/lalinference/bayespputils.py - # need way to query lalsuite parameters! See - # https://git.ligo.org/cbc/action_items/-/issues/37#note_1158065 - try: - from lal import H0_SI, OMEGA_M - except: - # IN FUTURE: based on https://git.ligo.org/rapidpe-rift/rapidpe_rift_review_o4/-/wikis/Cosmo_sourceframe-Code-Review, updating previous version (from lalsuite 7.6.1) to match new constant in 7.25.1 - H0_SI, OMEGA_M = 2.200489137532724e-18, 0.3065 - my_cosmo = FlatLambdaCDM(H0=H0_SI*Hz, Om0=OMEGA_M) + # ONE named cosmology, from the framework helper, so every code that needs one gets the + # same object and a change is made in one place. Previously this preferred + # lal.H0_SI/lal.OMEGA_M with a hardcoded fallback, which is a cosmology nobody can cite + # by name in a paper: the installed lal gives H0=67.900, Om0=0.3065, while Planck15 is + # H0=67.740, Om0=0.3075. The difference is tiny (dL(z=5) 47756 vs 47732 Mpc, 0.05%) and + # of no physical consequence -- but "which cosmology is this?" is exactly the kind of + # question a referee asks, and "whatever the linked lalsuite constant happened to be" + # is a worse answer than "Planck15". + # + # History, kept so it is not rediscovered: the lal-constant route came from + # https://git.ligo.org/cbc/action_items/-/issues/37#note_1158065 and + # https://git.ligo.org/rapidpe-rift/rapidpe_rift_review_o4/-/wikis/Cosmo_sourceframe-Code-Review + # (updating lalsuite 7.6.1 -> 7.25.1). Superseded deliberately, not by accident. + my_cosmo = priors_utils.get_astropy_cosmology("Planck15") # omega = lal.CreateDefaultCosmologicalParameters() # matching the lal options. Only needed if we have it zmin = z_at_value(my_cosmo.luminosity_distance, dmin*u.Mpc).value zmax = z_at_value(my_cosmo.luminosity_distance, dmax*u.Mpc).value # use astropy estimate for zmax @@ -1984,7 +2086,7 @@ def resample_samples(my_samples, # if we are using GPU-based generation this is ok; if itis mcsampler, we will have a lot of 'object' casts to fix, arg - tvals = xpy_default.linspace(-t_ref_wind,t_ref_wind,int((t_ref_wind)*2/P.deltaT)) # choose an array at the target sampling rate. P is inherited globally + tvals = factored_likelihood.marginalization_time_grid(t_ref_wind, P.deltaT, xpy=xpy_default) # THE one window-grid constructor; see issue #146 P.phi = identity_convert_togpu(my_samples['right_ascension']) # cast to float P.theta = identity_convert_togpu(my_samples['declination']) P.tref = float(fiducial_epoch) @@ -2019,13 +2121,18 @@ def resample_samples(my_samples, if opts.srate_resample_time_marginalization and opts.srate_resample_time_marginalization > fSample: # Resample the marginalization-time grid to EXACTLY the requested rate, so # the exported geocenter time is quantized at 1/srate_resample seconds. We - # step by exactly 1/srate_resample rather than by an integer subdivision of - # the internal grid: that internal grid is a closed-interval linspace whose - # spacing is ~1/fSample but NOT exactly (here ~4086.7 Hz vs 4096), so an - # integer-factor upsample would land at ~n/deltaT_orig, tens of percent off - # the request. For the usual power-of-two rates 1/srate is exactly - # representable in float64, so consecutive output times differ by exactly - # that step. + # step by exactly 1/srate_resample; for the usual power-of-two rates that is + # exactly representable in float64, so consecutive output times differ by + # exactly that step. + # + # HISTORICAL NOTE (issue #146): this comment used to justify the choice by + # the internal grid being "a closed-interval linspace whose spacing is + # ~1/fSample but NOT exactly (here ~4086.7 Hz vs 4096)". That is no longer + # true -- marginalization_time_grid() is spaced EXACTLY deltaT, so an + # integer-factor upsample would now be exact too. More importantly, the + # tvals read as time LABELS below (t_out -> the exported 't_ref') are now + # the times the likelihood actually evaluated; under the old linspace they + # were off by up to 1.4 samples at the window edge. dt_target = 1.0/opts.srate_resample_time_marginalization # floor(): stay within [tvals[0], tvals[-1]] so the spline never # extrapolates. At most one step (<1/srate s, tens of us) is dropped at the @@ -2132,12 +2239,77 @@ def ln_weights_from_rvs(rvs, convert=None, use_lnL=False): def _rvs_len(rvs): - for v in rvs.values(): - try: - return len(numpy.atleast_1d(numpy.asarray(v)).ravel()) - except Exception: - continue - return 0 + """Rows in a raw `_rvs` column dict -> int. + + ONE row-count rule, and it lives with the record (`rvs_record.n_rows`). Flattening + whichever column came first was wrong for the ORDINARY case, not a corner: `_rvs` is + seeded parameters-first, and a combined parameter is stored (ndim, N) under a TUPLE key, + so any run registering one reported ndim*N. That number is the length of the uniform + vector `ln_weights_for_posterior` hands back for a fair draw -- an output ndim times too + long rather than a mislabelled count -- and the `block_sizes` recorded for a pooled + record. The rule that gets it right reads a canonical per-row column first and otherwise + takes the row axis from the key's own layout. + + Imported INSIDE the function deliberately: the test harnesses exec these helpers out of + the driver into a bare namespace, so a module-level name here would have to be threaded + through every one of them -- and this staying a one-line delegation is the point. + """ + from RIFT.integrators.rvs_record import n_rows as _n_rows_of_columns + return _n_rows_of_columns(rvs) + + +def _rvs_record_for(sampler, rvs): + """The record describing THESE columns, or None. See DESIGN_rvs_naming.md. + + THE IDENTITY CHECK IS THE POINT. A record holds a reference to a column dict that other + code replaces in place, so "the sampler has a record" and "the record describes the rows I + am holding" are different questions -- the same shape as everything else in this file's + history. A record that has fallen out of step is not consulted; the caller falls back to + the provenance flags, which are maintained separately and are still correct. + + One lookup rather than the check repeated per consumer, for the reason the reserve lookup + was centralised in #87: two copies of a guard drift. + """ + # `samples()` is the public accessor; the getattr guard is for an object that predates the + # mixin (an old pickle, a test double), not for the six samplers, all of which have it. + _get = getattr(sampler, 'samples', None) + rec = _get() if callable(_get) else None + if rec is None or getattr(rec, 'columns', None) is not rvs: + return None + return rec + + +def _internal_record_of(sampler): + """This pass's record, marked INTERNAL for threading -> RvsRecord or None. + + Replica pooling needs each block's record to derive that block's weights with the right + convention. Marking them internal is the difference between "we had to hand the structure + back" and "this is now something consumers may use": set_samples() refuses an internal + record, so nothing on this list can reappear from samples(). + """ + _get = getattr(sampler, 'samples', None) + rec = _get() if callable(_get) else None + return rec.as_internal() if rec is not None else None + + +def _sampler_keeps_records(sampler): + """Does this sampler populate `_rvs_record` at all? See DESIGN_rvs_naming.md. + + A PRODUCER's question, not a consumer's, and deliberately a different function from + `_rvs_record_for`. The pooling step is about to REPLACE `sampler._rvs`, so asking "does a + record describe the rows I hold" is the wrong question there -- it would be answered `None` + and the pooled record would silently not be built. What it needs to know is whether this + sampler participates in the record scheme at all. + + Two questions, two names. That is the entire lesson of this file's last four review rounds. + """ + # PARTICIPATION, not "is one present right now". Every sampler clears _rvs_record at the + # top of integrate(), so a replica that raised leaves None behind while the sampler is still + # a full participant -- and keying on presence would silently skip building the pooled + # record for it. Ask whether the sampler implements the scheme at all. + return isinstance(sampler, SamplerOutputMixin) or ( + callable(getattr(sampler, 'samples', None)) + and callable(getattr(sampler, 'set_samples', None))) def _rvs_is_export_resample(sampler): @@ -2195,13 +2367,37 @@ def ln_weights_for_posterior(rvs, sampler, convert=None, use_lnL=None): So: uniform (zero log-weight) for a fair-drawn record, the derived importance weight otherwise. Returns a float array the length of the record. """ + # MIGRATION (DESIGN_rvs_naming.md), the first consumer to move. This is the exact + # site where the one-flag-two-questions defect lived, so it is the one worth converting + # first: `is_equal_weight()` is a named question rather than two booleans a caller has to + # combine, and it cannot be answered with the wrong one. + # + # The flags stay as the fallback while the other six samplers are unconverted -- and while + # both exist they MUST agree, which is asserted directly in test_rvs_record.py rather than + # left as a comment, because "two sources of truth" is the risk this migration runs. + _rec = _rvs_record_for(sampler, rvs) + if _rec is not None: + if _rec.is_equal_weight(): + return numpy.zeros(_rvs_len(rvs), dtype=float) + # THE WEIGHT ITSELF now comes from the record, not from ln_weights_from_rvs -- which is + # the point of the record: it knows its own convention, so there is no `use_lnL` to + # thread through and no way for a caller to pass the wrong one. + # + # Verified equivalent before switching, not after: the two implementations were fuzzed + # against each other over 1200 randomized records spanning all three column families + # with NaN / -inf / 0 sprinkled through every column. That found a REAL divergence + # first -- log_weights() had been computing lnL + ln(pi) - ln(q) term by term, which + # yields NaN where the canonical form's conjunctive keep-mask yields -inf -- and it is + # fixed there rather than papered over here. + return numpy.asarray(_rec.log_weights(convert=convert), dtype=float) if _rvs_is_equal_weight(sampler): return numpy.zeros(_rvs_len(rvs), dtype=float) return numpy.asarray(ln_weights_from_rvs(rvs, convert=convert, use_lnL=use_lnL), dtype=float) -def _pool_replica_rvs(rep_rvs, sampler, rep_lnZ=None, already_resampled=False, use_lnL=None): +def _pool_replica_rvs(rep_rvs, sampler, rep_lnZ=None, already_resampled=False, use_lnL=None, + records=None): """Concatenate the replicas' samples into one correctly-weighted set. Each replica k is an independent importance-sampling estimate with weights w_ki and its own @@ -2227,6 +2423,12 @@ def _pool_replica_rvs(rep_rvs, sampler, rep_lnZ=None, already_resampled=False, u _ar_list = (list(already_resampled) if isinstance(already_resampled, (list, tuple, numpy.ndarray)) else None) + # PER-REPLICA RECORDS, threaded in so each block's lnZ is derived with ITS OWN convention + # instead of one `use_lnL` asserted over the whole set. These are INTERNAL: they are + # plumbing for this function, marked as such, and refused by set_samples() so they cannot + # escape through the public samples() accessor. Having had to pass the structure around is + # not a reason for anyone else to reach for it. + _rec_list = list(records) if records is not None else None # Drop empty records in LOCKSTEP with their metadata. The filter used to run on rep_rvs # alone, so a single empty replica shifted every later block against its own lnZ -- and # would now shift it against its own resampled flag too. @@ -2236,12 +2438,45 @@ def _pool_replica_rvs(rep_rvs, sampler, rep_lnZ=None, already_resampled=False, u rep_lnZ = [rep_lnZ[i] for i in _keep if i < len(rep_lnZ)] if _ar_list is not None: _ar_list = [_ar_list[i] for i in _keep if i < len(_ar_list)] + if _rec_list is not None: + _rec_list = [_rec_list[i] for i in _keep if i < len(_rec_list)] + + def _block_record(i, r): + """The record for block i, but only if it describes THAT block's columns.""" + if _rec_list is None or i >= len(_rec_list): + return None + rec = _rec_list[i] + return rec if getattr(rec, 'columns', None) is r else None def _block_resampled(i): if _ar_list is not None: return bool(_ar_list[i]) if i < len(_ar_list) else False return bool(already_resampled) + def _block_column(k, v): + """One block's column for key `k`, in the layout the KEY implies. + THE KEY SAYS WHERE THE ROW AXIS IS -- the same rule `_rvs_len` delegates to + (rvs_record._column_n_rows), applied to the rows themselves. A combined parameter is + stored (ndim, N) under a TUPLE key, so ravelling it and concatenating on axis 0 turns + it into ONE 1-D column of length ndim*sum(N) while the scalar columns have sum(N) rows. + Consumers still require (ndim, N) -- the sample exporter unpacks the combined sky column + as `samples["latitude"], samples["longitude"] = samples[("declination", + "right_ascension")]` -- so --mc-error-replicas produced a malformed record and could + abort the export. Per-row columns keep the flatten they always had. + """ + v = numpy.asarray(v) + return numpy.atleast_2d(v) if isinstance(k, tuple) else numpy.atleast_1d(v).ravel() + + def _empty_column(k): + """No block contributed any rows -- an empty column that still has the key's LAYOUT. + Handing back a bare `array([])` for a tuple key would fail to unpack in the exporter + for the shape reason above rather than for the real one (there are no samples). + """ + if not isinstance(k, tuple): + return numpy.array([]) + ndim = _block_column(k, sampler.identity_convert(rep_rvs[0][k])).shape[0] + return numpy.empty((ndim, 0)) + if len(rep_rvs) <= 1: return rep_rvs[0] if rep_rvs else {} # `already_resampled` -- the records are FAIRDRAW output. Those samples were already drawn in @@ -2284,7 +2519,8 @@ def _pool_replica_rvs(rep_rvs, sampler, rep_lnZ=None, already_resampled=False, u scale = 0.0 elif rep_lnZ is not None and _i < len(rep_lnZ) and numpy.isfinite(rep_lnZ[_i]): # target: this block's weights sum to Z_k/K - _cur = _lnZ_of_rvs(r, already_pooled=True, use_lnL=_lnL_here) + _cur = _lnZ_of_rvs(r, already_pooled=True, use_lnL=_lnL_here, + record=_block_record(_i, r)) if _cur is None or not numpy.isfinite(_cur): scale = numpy.log(float(K) * float(n_k)) else: @@ -2299,7 +2535,7 @@ def _pool_replica_rvs(rep_rvs, sampler, rep_lnZ=None, already_resampled=False, u sampler.identity_convert(r['log_joint_prior']), dtype=float)).ravel() _forced = _li + _lp - _target_lw for k in keys: - v = numpy.atleast_1d(numpy.asarray(sampler.identity_convert(r[k]))).ravel() + v = _block_column(k, sampler.identity_convert(r[k])) if _flat_block and log_key is not None and k == log_key: v = _forced elif _flat_block and lin_key is not None and k == lin_key: @@ -2333,7 +2569,10 @@ def _pool_replica_rvs(rep_rvs, sampler, rep_lnZ=None, already_resampled=False, u v = v * numpy.exp(scale) cols[k].append(v) for k in keys: - out[k] = numpy.concatenate(cols[k]) if cols[k] else numpy.array([]) + # ...and concatenate along THAT row axis: axis 1 for a combined (ndim, N) parameter, + # axis 0 for everything else. One rule, stated in _block_column, applied twice. + out[k] = (numpy.concatenate(cols[k], axis=1 if isinstance(k, tuple) else 0) + if cols[k] else _empty_column(k)) # CACHED WEIGHTS MUST FOLLOW THE COMPONENTS. _rvs may carry a precomputed 'log_weights' # (mcsamplerPortfolio writes one), and the .dgrid and calibration-posterior exporters # PREFER it -- they only fall back to log_integrand + log_joint_prior - log_joint_s_prior @@ -2367,7 +2606,23 @@ def _pool_replica_rvs(rep_rvs, sampler, rep_lnZ=None, already_resampled=False, u return out -def _lnZ_of_rvs(rvs, already_pooled=True, use_lnL=None): +def _lw_of(rvs, record, use_lnL): + """Importance log-weights for `rvs`, preferring a record that describes it. + + ONE resolver, so the two estimators below cannot drift in which source they trust. A + record is used only when its `.columns` IS this dict: `_rvs` is copied and replaced all + over this file, and a record describing different columns must not be believed. Otherwise + fall back to the canonical derivation with the stored convention -- the two are verified + equivalent by a randomized comparison in test_rvs_record.py, so this is a source choice, + not a semantics choice. + """ + if record is not None and getattr(record, 'columns', None) is rvs: + return numpy.asarray(record.log_weights(), dtype=float) + return numpy.asarray(ln_weights_from_rvs(rvs, use_lnL=_rvs_lnL_convention(use_lnL)), + dtype=float) + + +def _lnZ_of_rvs(rvs, already_pooled=True, use_lnL=None, record=None): """log of the evidence implied by an _rvs record. For a POOLED record the weights already carry their 1/(K n_k) factor, so the estimate is the @@ -2375,7 +2630,7 @@ def _lnZ_of_rvs(rvs, already_pooled=True, use_lnL=None): """ try: try: - lw = ln_weights_from_rvs(rvs, use_lnL=_rvs_lnL_convention(use_lnL)) + lw = _lw_of(rvs, record, use_lnL) except Exception: return None lw = lw[numpy.isfinite(lw)] @@ -2388,11 +2643,11 @@ def _lnZ_of_rvs(rvs, already_pooled=True, use_lnL=None): return None -def _kish_neff_of_rvs(rvs, use_lnL=None): +def _kish_neff_of_rvs(rvs, use_lnL=None, record=None): """Kish effective sample size of an _rvs record, or None if the weights are not reconstructible.""" try: try: - lw = ln_weights_from_rvs(rvs, use_lnL=_rvs_lnL_convention(use_lnL)) + lw = _lw_of(rvs, record, use_lnL) except Exception: return None lw = lw[numpy.isfinite(lw)] @@ -2446,7 +2701,23 @@ def _lnZ_of_reserve_or_rvs(sampler, rvs, reserve=None): return _v, 'retained' except Exception: pass - return _lnZ_of_rvs(rvs, already_pooled=False), 'fairdraw' + return _lnZ_of_rvs(rvs, already_pooled=False, + record=_rvs_record_for(sampler, rvs)), 'fairdraw' + + +def _rebound_record(sampler, columns): + """A copy of the sampler's record whose `.columns` is `columns` -> RvsRecord or None. + + Snapshot/restore installs a COPY of the column dict, so a record still pointing at the + original would fail every identity check and silently do nothing. + """ + _get = getattr(sampler, 'samples', None) + rec = _get() if callable(_get) else None + if rec is None: + return None + out = rec.snapshot() + out.columns = columns + return out def _snapshot_pass_state(sampler, res, var, neff, dict_return, rvs=None): @@ -2471,6 +2742,14 @@ def _snapshot_pass_state(sampler, res, var, neff, dict_return, rvs=None): warm_seed_reserve=getattr(sampler, '_warm_seed_reserve', None), rvs_is_fairdraw=bool(getattr(sampler, '_rvs_is_fairdraw', False)), rvs_is_pooled=bool(getattr(sampler, '_rvs_is_pooled', False)), + # The record too. A stale one is already declined by _rvs_record_for's identity check, + # so this is belt-and-braces -- but "everything describing the pass moves together" is + # the invariant, and carving an exception into it is how round 1 happened. + # REBOUND to the snapshot's columns. The record held a reference to the LIVE dict, and + # the restore installs a COPY -- so storing it as-is produced a record whose identity + # check could never match, i.e. inert rather than belt-and-braces. Rebinding makes it + # describe what is actually put back. + rvs_record=_rebound_record(sampler, dict(sampler._rvs) if rvs is None else rvs), member_reserves=[getattr(_m, '_warm_seed_reserve', None) for _m in list(getattr(sampler, 'portfolio_realizations', []) or [])], ) @@ -2486,6 +2765,8 @@ def _restore_pass_state(sampler, state): sampler._warm_seed_reserve = state['warm_seed_reserve'] sampler._rvs_is_fairdraw = state['rvs_is_fairdraw'] sampler._rvs_is_pooled = state['rvs_is_pooled'] + if callable(getattr(sampler, 'set_samples', None)): + sampler.set_samples(state.get('rvs_record')) _members = list(getattr(sampler, 'portfolio_realizations', []) or []) for _m, _r in zip(_members, state.get('member_reserves', [])): _m._warm_seed_reserve = _r @@ -2720,7 +3001,15 @@ def analyze_event(P_list, indx_event, data_dict, psd_dict, fmax, opts,inv_spec_t rotation_slow_data = None if opts.rotation_slow: _pmax = int(opts.rotation_p_max) - _nh = max(int(opts.rotation_n_harmonics), 2 + _pmax) # wide enough to cover all C_{(p,ntilde)} + # --rotation-n-harmonics is a FLOOR, not the literal width: the response + # coefficients C_{(p,ntilde)} reach |ntilde| <= 2 + p_max (issue #142), and the + # option's default of 2 is only the p_max=0 answer. The precompute now enforces + # this itself, so this line is belt-and-braces -- kept (a) so the printout below + # and any future use of _harm describe the bank that was actually built, and + # (b) so the ILE never trips the precompute's widening warning. The rule itself + # lives in ONE place: required_harmonic_width. + _nh = max(int(opts.rotation_n_harmonics), + factored_likelihood_with_rotation.required_harmonic_width(_pmax)) _harm = tuple(range(-_nh, _nh + 1)) _rint_r, _ct_r, _ctV_r, _rho_r, _meta_r = factored_likelihood_with_rotation.PrecomputeLikelihoodTermsWithRotation( fiducial_epoch, t_window, P, data_dict, psd_dict, opts.l_max, fmax, @@ -2741,7 +3030,8 @@ def analyze_event(P_list, indx_event, data_dict, psd_dict, fmax, opts,inv_spec_t _vNN[_det][_pair] = cupy.asarray(_vNN[_det][_pair]) rotation_slow_data = dict(meta=_meta_r, lookupNKDict=_lkR, rho_by_n=_rhoN, U_by_nn=_uNN, V_by_nn=_vNN, epochDict=_epR) - print(" [rotation-slow] precompute complete; p_max", _pmax, "sidereal harmonics", _harm, + print(" [rotation-slow] precompute complete; p_max", _pmax, "sidereal harmonics", + _meta_r['harmonics'], # the bank's own record, not our request "(GPU)" if (opts.gpu and not xpy_default is np) else "(CPU)") # [Path D] finite-size (frequency-dependent) response precompute: fold each W_p(f) @@ -2793,7 +3083,10 @@ def analyze_event(P_list, indx_event, data_dict, psd_dict, fmax, opts,inv_spec_t Responsibilities are ~extrinsic-independent so modest batches converge.""" import RIFT.calmarg.adaptive as _adapt from scipy.special import logsumexp as _lse - _rng = np.random.default_rng() # fresh randomness: this is a diagnostic + # A fresh probe stream per call (so successive probes do not reuse each + # other's extrinsic batch), derived from --seed when the run was seeded: + # this probe also DECIDES n_cal below, so it must not float run to run. + _rng = _cal_rng('calmarg.error_probe') if n_cap is None: n_cap = max(int(opts.calibration_mc_error_extrinsic or 0), n_start) _warned = [] @@ -2824,7 +3117,7 @@ def analyze_event(P_list, indx_event, data_dict, psd_dict, fmax, opts,inv_spec_t corr = np.log(np.clip(pw, 1e-300, None)) d = np.asarray(redshift_to_distance(x), dtype=float) return d, corr, 'sampler prior ({})'.format(opts.d_prior) - _tv = xpy_default.linspace(-t_ref_wind, t_ref_wind, int((t_ref_wind)*2/P.deltaT)) + _tv = factored_likelihood.marginalization_time_grid(t_ref_wind, P.deltaT, xpy=xpy_default) # THE one window-grid constructor; see issue #146 _calw_np = np.zeros(n_cal_now) if calibration_log_weights is None else np.asarray(identity_convert(calibration_log_weights), dtype=float)[:n_cal_now] comp_list = []; corr_list = [] sigma = None; neff_cal = None; sigma_prev = None @@ -2925,7 +3218,7 @@ def analyze_event(P_list, indx_event, data_dict, psd_dict, fmax, opts,inv_spec_t P.phiref = xpy_default.asarray(_rng_ext.uniform(0, 2*np.pi, _Next), dtype=np.float64) P.tref = float(fiducial_epoch) P.dist = xpy_default.asarray(np.full(_Next, factored_likelihood.distMpcRef)*1.e6*lalsimutils.lsu_PC, dtype=np.float64) - _tvals = xpy_default.linspace(-t_ref_wind, t_ref_wind, int((t_ref_wind)*2/P.deltaT)) + _tvals = factored_likelihood.marginalization_time_grid(t_ref_wind, P.deltaT, xpy=xpy_default) # THE one window-grid constructor; see issue #146 _comp = factored_likelihood.DiscreteFactoredLogLikelihoodViaArrayVectorNoLoop( _tvals, P, lookupNKDict, rholmArrayDict, ctUArrayDict, ctVArrayDict, epochDict, Lmax=opts.l_max, xpy=xpy_default, n_cal=n_cal_for_likelihood, cal_method='loop', @@ -2993,7 +3286,7 @@ def analyze_event(P_list, indx_event, data_dict, psd_dict, fmax, opts,inv_spec_t # use EXTREMELY many bits lnL = numpy.zeros(right_ascension.shape,dtype=RiftFloat) i = 0 - tvals = numpy.linspace(-t_ref_wind,t_ref_wind,int((t_ref_wind)*2/P.deltaT)) # choose an array at the target sampling rate. P is inherited globally + tvals = factored_likelihood.marginalization_time_grid(t_ref_wind, P.deltaT, xpy=numpy) # THE one window-grid constructor; see issue #146 for ph, th, phr, ic, ps, di in zip(right_ascension, dec, phi_orb, incl, psi, distance): # 'incl', NOT the raw sampled 'inclination': under --inclination-cosine-sampler the sampled variable is cos(iota) @@ -3008,7 +3301,7 @@ def analyze_event(P_list, indx_event, data_dict, psd_dict, fmax, opts,inv_spec_t lnL[i] = factored_likelihood.FactoredLogLikelihoodTimeMarginalized(tvals, P, rholms_intp, rholms, cross_terms, cross_terms_V, - opts.l_max,interpolate=opts.interpolate_time) + opts.l_max,interpolate=opts._legacy_interpolate_time) i+=1 if supplemental_ln_likelihood: lnL += supplemental_ln_likelihood(right_ascension, declination, phi_orb,inclination, psi, distance) @@ -3023,7 +3316,7 @@ def analyze_event(P_list, indx_event, data_dict, psd_dict, fmax, opts,inv_spec_t def likelihood_function(right_ascension, declination, phi_orb, inclination, psi, distance): # global nEvals - tvals = numpy.linspace(-t_ref_wind,t_ref_wind,int((t_ref_wind)*2/P.deltaT)) # choose an array at the target sampling rate. P is inherited globally + tvals = factored_likelihood.marginalization_time_grid(t_ref_wind, P.deltaT, xpy=numpy) # THE one window-grid constructor; see issue #146 dec = numpy.copy(declination).astype(numpy.float64) if opts.declination_cosine_sampler: dec = numpy.pi/2 - numpy.arccos(dec) @@ -3085,7 +3378,7 @@ def analyze_event(P_list, indx_event, data_dict, psd_dict, fmax, opts,inv_spec_t def likelihood_function(right_ascension, declination, phi_orb, inclination, psi, distance): # global nEvals - tvals = xpy_default.linspace(-t_ref_wind,t_ref_wind,int((t_ref_wind)*2/P.deltaT)) # choose an array at the target sampling rate. P is inherited globally + tvals = factored_likelihood.marginalization_time_grid(t_ref_wind, P.deltaT, xpy=xpy_default) # THE one window-grid constructor; see issue #146 # Use xpy_default.asarray (not the passthrough xpy_asarray_already): some # samplers (e.g. AV) hand back numpy arrays, so on GPU we must convert # them to cupy. asarray is a no-op for already-on-device arrays. This @@ -3241,7 +3534,7 @@ def analyze_event(P_list, indx_event, data_dict, psd_dict, fmax, opts,inv_spec_t def likelihood_function(right_ascension, declination, inclination, psi): # global nEvals - tvals = xpy_default.linspace(-t_ref_wind,t_ref_wind,int((t_ref_wind)*2/P.deltaT)) # choose an array at the target sampling rate. P is inherited globally + tvals = factored_likelihood.marginalization_time_grid(t_ref_wind, P.deltaT, xpy=xpy_default) # THE one window-grid constructor; see issue #146 P.phi = xpy_default.asarray(right_ascension, dtype=np.float64) # cast to float if opts.declination_cosine_sampler: P.theta = numpy.pi/2 - xpy_default.arccos(xpy_default.asarray(declination,dtype=np.float64)) @@ -3288,7 +3581,7 @@ def analyze_event(P_list, indx_event, data_dict, psd_dict, fmax, opts,inv_spec_t def likelihood_function(right_ascension, declination, phi_orb, inclination, psi): # global nEvals - tvals = xpy_default.linspace(-t_ref_wind,t_ref_wind,int((t_ref_wind)*2/P.deltaT)) # choose an array at the target sampling rate. P is inherited globally + tvals = factored_likelihood.marginalization_time_grid(t_ref_wind, P.deltaT, xpy=xpy_default) # THE one window-grid constructor; see issue #146 P.phi = xpy_default.asarray(right_ascension, dtype=np.float64) # cast to float if opts.declination_cosine_sampler: P.theta = numpy.pi/2 - xpy_default.arccos(xpy_default.asarray(declination,dtype=np.float64)) @@ -3344,7 +3637,7 @@ def analyze_event(P_list, indx_event, data_dict, psd_dict, fmax, opts,inv_spec_t lnL = numpy.zeros(len(right_ascension),dtype=RiftFloat) # i = 0 - tvals = numpy.linspace(-t_ref_wind,t_ref_wind,int((t_ref_wind)*2/P.deltaT)) # choose an array at the target sampling rate. P is inherited globally + tvals = factored_likelihood.marginalization_time_grid(t_ref_wind, P.deltaT, xpy=numpy) # THE one window-grid constructor; see issue #146 # t_start =lal.GPSTimeNow() @@ -3365,7 +3658,7 @@ def analyze_event(P_list, indx_event, data_dict, psd_dict, fmax, opts,inv_spec_t lnL[i] = factored_likelihood.FactoredLogLikelihoodTimeMarginalized(tvals, P, rholms_intp_A, rholms_A, cross_terms_A, cross_terms_V_A, - opts.l_max,interpolate=opts.interpolate_time) + opts.l_max,interpolate=opts._legacy_interpolate_time) if numpy.isnan(lnL[i]) or lnL[i]<-200: lnL[i] = -200 # regularize : a hack, for now, to deal with rare ROM problems. Only on the ROM logic fork i+=1 @@ -3625,7 +3918,8 @@ def analyze_event(P_list, indx_event, data_dict, psd_dict, fmax, opts,inv_spec_t " re-reading both from the fair-draw record so the comparison is" " like-for-like.".format(_cold_src, _warm_src)) _cold_lnZ = _lnZ_of_rvs(_cold_rvs, already_pooled=False) - _warm_lnZ = _lnZ_of_rvs(sampler._rvs, already_pooled=False) + _warm_lnZ = _lnZ_of_rvs(sampler._rvs, already_pooled=False, + record=_rvs_record_for(sampler, sampler._rvs)) _cold_src = _warm_src = 'fairdraw' _evidence_of_loss = ( (_cold_lnZ is not None) and (_warm_lnZ is not None) @@ -3779,6 +4073,10 @@ def analyze_event(P_list, indx_event, data_dict, psd_dict, fmax, opts,inv_spec_t # a resampled replica double-weighted. Near the n_extr boundary a run can produce a # MIXTURE of raw and resampled replicas, which one global boolean cannot describe. _rep_fairdraw = [bool(getattr(sampler, '_rvs_is_fairdraw', False))] + # ...and each replica's RECORD, so pooling can derive that block's weights with its own + # convention. Marked INTERNAL: this list is plumbing for _pool_replica_rvs, and + # set_samples() refuses an internal record so none of it can reach a consumer. + _rep_records = [_internal_record_of(sampler)] # Collapse status must be aggregated over EVERY replica that ends up in the pool. # The exported posterior is the pooled mixture, so one collapsed replica taints it # even if the first run was healthy -- and the status sidecar is written from @@ -3837,6 +4135,7 @@ def analyze_event(P_list, indx_event, data_dict, psd_dict, fmax, opts,inv_spec_t _rep_lnZ.append(float(_lr2)); _rep_sig.append(float(_sig2)); _rep_neff.append(float(_neff2)) _rep_rvs.append(sampler._rvs) _rep_fairdraw.append(bool(getattr(sampler, '_rvs_is_fairdraw', False))) + _rep_records.append(_internal_record_of(sampler)) _rep_collapsed.append(bool(_dd2.get('live_volume_collapsed', False)) if isinstance(_dd2, dict) else False) if isinstance(_dd2, dict) and _dd2.get('collapse_reason'): @@ -3851,7 +4150,8 @@ def analyze_event(P_list, indx_event, data_dict, psd_dict, fmax, opts,inv_spec_t # (which all form log_integrand + log_joint_prior - log_joint_s_prior) correct untouched. _pooled_rvs = _pool_replica_rvs(_rep_rvs, sampler, rep_lnZ=_rep_lnZ, already_resampled=_rep_fairdraw, - use_lnL=rvs_integrand_is_lnL) + use_lnL=rvs_integrand_is_lnL, + records=_rep_records) # A POOLED RECORD IS NOT A FAIR DRAW, even when every block that went into it was. # _pool_replica_rvs gives block k weights summing to Z_k/K: equal WITHIN a block (each # block really is an equal-weight draw from its own posterior) but differing BETWEEN @@ -3873,6 +4173,39 @@ def analyze_event(P_list, indx_event, data_dict, psd_dict, fmax, opts,inv_spec_t # ln_weights_for_posterior must read the reconstructed per-row weights. sampler._rvs_is_pooled = True sampler._rvs_is_fairdraw = any(_rep_fairdraw) + # (DESIGN_rvs_naming.md) The same statement, as a record. Note it carries + # _rep_fairdraw PER BLOCK -- the thing the two booleans above cannot express, and + # the reason a mixture of raw and resampled replicas needed a special case in + # _pool_replica_rvs. The reserve does NOT ride along: it describes one pass, and + # a pooled record is a mixture of several, so there is no single retained set. + if _sampler_keeps_records(sampler): + try: + # THE CONVENTION MUST COME ALONG. _pool_replica_rvs keeps only the + # INTERSECTION of the replica keys, so on a linear-only backend + # (adaptive_cartesian, or Ensemble without use_lnL) the pooled record has a + # bare `integrand` column. Without a recorded convention log_weights() + # raises rather than guessing -- correct in itself, but it would abort the + # unwrapped .dgrid export and the outer handler would DROP THE EVENT. The + # blocks all come from one sampler, so its pre-pool record knows; fall back + # to the run's stored convention. + _pre = sampler.samples() + _pool_is_log = (_pre.integrand_is_log if _pre is not None else None) + if _pool_is_log is None: + _pool_is_log = rvs_integrand_is_lnL + # LOCKSTEP with _pool_replica_rvs, which drops empty records together with + # their lnZ and their resampled flag. Filtering here too keeps the + # provenance describing the blocks the record actually contains. + _keep_rec = [_i for _i, _r in enumerate(_rep_rvs) if _r] + sampler.set_samples(_RvsRecord.pooled( + _pooled_rvs, + resampled_blocks=[_rep_fairdraw[_i] for _i in _keep_rec + if _i < len(_rep_fairdraw)], + block_sizes=[_rvs_len(_rep_rvs[_i]) for _i in _keep_rec], + integrand_is_log=_pool_is_log)) + except Exception as _e_rec: + sampler.set_samples(None) + print(" [rvs-record] pooled record not built ({}); falling back to the" + " provenance flags".format(_e_rec)) # Did pooling FLATTEN any block? That, not "is the record resampled", is what makes # the pooled Kish n_eff meaningless below -- a flattened block's rows carry its export # size rather than its integration quality. @@ -3934,7 +4267,11 @@ def analyze_event(P_list, indx_event, data_dict, psd_dict, fmax, opts,inv_spec_t # which has exactly the property the paragraph above asks for: it reduces to # sum_k neff_k when the replicas agree, and falls below it when they disagree -- # the disagreement these replicas exist to detect. - if _blocks_flattened: + # The record answers this directly. blocks_were_flattened() is a THIRD + # question, distinct from the other two -- keying it on either of them is what made + # this branch dead code in review round 2. + _rec_ne = _rvs_record_for(sampler, sampler._rvs) + if (_rec_ne.blocks_were_flattened() if _rec_ne is not None else _blocks_flattened): _l_rel = numpy.asarray(_rep_lnZ, dtype=float) - float(numpy.max(_rep_lnZ)) _Zk = numpy.exp(_l_rel) _nk = numpy.asarray(_rep_neff, dtype=float) @@ -3943,7 +4280,8 @@ def analyze_event(P_list, indx_event, data_dict, psd_dict, fmax, opts,inv_spec_t if numpy.any(_ok) else None) _neff_how = 'block Kish over replicas (the export is fair-drawn)' else: - _neff_pooled = _kish_neff_of_rvs(sampler._rvs) + _neff_pooled = _kish_neff_of_rvs( + sampler._rvs, record=_rvs_record_for(sampler, sampler._rvs)) _neff_how = 'Kish over the pooled samples' neff = float(_neff_pooled) if _neff_pooled is not None else float(numpy.sum(_rep_neff)) if _neff_pooled is not None: @@ -4258,7 +4596,13 @@ def analyze_event(P_list, indx_event, data_dict, psd_dict, fmax, opts,inv_spec_t # The fresh path is exact and already supported, so use it rather than reporting a # plausible wrong number -- every slice becomes an independent fixed-d integration. # It costs more likelihood evaluations; say so, rather than changing cost silently. - if not all_fresh and _rvs_is_export_resample(sampler): + # Ask the record when it describes these rows; the flag is the fallback. + # Note this is the ROWS-RESAMPLED question, not equal-weight: a pooled record still + # has resampled rows, and reweighting them still double-counts. + _rec_ds = _rvs_record_for(sampler, sampler._rvs) + _ds_resampled = (_rec_ds.rows_are_resampled() if _rec_ds is not None + else _rvs_is_export_resample(sampler)) + if not all_fresh and _ds_resampled: print(" [dslice] _rvs is the fair-draw export; forcing --distance-slice-all-fresh" " (the reweight core would double-count pi_Omega/q_Omega on resampled rows)." " K fresh fixed-d integrations instead of a reweighted core.") @@ -4487,7 +4831,7 @@ def analyze_event(P_list, indx_event, data_dict, psd_dict, fmax, opts,inv_spec_t if opts.calibration_export_posterior and calibration_marginalization and n_cal_for_likelihood and n_cal_for_likelihood > 1 and _cal_nodes is not None: try: from scipy.special import logsumexp as _logsumexp # 'scipy' is shadowed as a local later in analyze_event - _tv = xpy_default.linspace(-t_ref_wind, t_ref_wind, int((t_ref_wind)*2/P.deltaT)) + _tv = factored_likelihood.marginalization_time_grid(t_ref_wind, P.deltaT, xpy=xpy_default) # THE one window-grid constructor; see issue #146 # per-realization, time-integrated lnL at each fair-draw sample (P holds the sample # extrinsic arrays, just set by resample_samples). return_cal_components forces the # loop method and returns shape (n_samples, n_cal). diff --git a/MonteCarloMarginalizeCode/Code/bin/integrate_likelihood_extrinsic_batchmode_lisa b/MonteCarloMarginalizeCode/Code/bin/integrate_likelihood_extrinsic_batchmode_lisa index 3754e5452..bfe81fe8f 100755 --- a/MonteCarloMarginalizeCode/Code/bin/integrate_likelihood_extrinsic_batchmode_lisa +++ b/MonteCarloMarginalizeCode/Code/bin/integrate_likelihood_extrinsic_batchmode_lisa @@ -64,6 +64,8 @@ import RIFT.LISA.lalsimutils_compat as lisa_lalsimutils_compat import RIFT.likelihood.factored_likelihood as factored_likelihood import RIFT.likelihood.factored_likelihood_LISA as factored_likelihood_LISA import RIFT.integrators.mcsampler as mcsampler +from RIFT.integrators.rvs_record import (RvsRecord as _RvsRecord, # see DESIGN_rvs_naming.md + SamplerOutputMixin) import RIFT.misc.sky_rotations as sky_rotations try: import RIFT.integrators.mcsamplerEnsemble as mcsamplerEnsemble @@ -281,7 +283,7 @@ integration_params.add_option("--n-eff", type=int, default=100, help="Total numb integration_params.add_option("--fairdraw-extrinsic-output", action='store_true' , help="Output is fair draw, rather than being comprehensive") integration_params.add_option("--n-chunk", type=int, help="Chunk'.",default=10000) integration_params.add_option("--convergence-tests-on",default=False,action='store_true') -integration_params.add_option("--seed", type=int, help="Random seed to use. Default is to not seed the RNG.") +integration_params.add_option("--seed", type=int, help="Random seed to use. Default is to not seed the RNG. Seeds every backend the samplers draw through (numpy, cupy, torch), so a seeded run is reproducible on GPU as well as CPU.") integration_params.add_option("--no-adapt", action="store_true", help="Turn off adaptive sampling. Adaptive sampling is on by default.") integration_params.add_option("--force-adapt-all", action="store_true", help="Force adaptive sampling for all parameters.") integration_params.add_option("--force-reset-all", action="store_true", help="Force reset of sampling every iteration. (Recommended if AC and not using no-adapt-after-first)") @@ -306,7 +308,45 @@ integration_params.add_option("--internal-use-lnL",action='store_true',help="lik integration_params.add_option("--sampler-method",default="adaptive_cartesian_gpu",help="adaptive_cartesian|GMM|adaptive_cartesian_gpu") integration_params.add_option("--sampler-portfolio",default=None,action='append',type=str,help="comma-separated strings, matching sampler methods other than portfolio") integration_params.add_option("--sampler-portfolio-args",default=None, action='append', type=str, help='eval-able dictionaryo to be passed to that sampler') +# Portfolio freeze/allocation policy. Pure pass-through to the shared portfolio sampler. +# Definitions copied verbatim from bin/integrate_likelihood_extrinsic_batchmode; pinned by +# test_lisa_sampler_plumbing.py. +integration_params.add_option("--portfolio-adaptive-alloc",action='store_true',default=False,help="Portfolio: ENABLE (opt-in) adaptive-probe draw allocation -- concentrate draws on the best per-chunk-n_ess member. Good on strongly-correlated targets; NOT recommended for AV-favorable high-SNR events (it starves the slow-contracting AV workhorse). Off by default (legacy n_ess reweighting).") +integration_params.add_option("--portfolio-alloc-exponent",default=None,type=float,help="Portfolio: adaptive allocation ~ member_quality^exponent. Higher concentrates harder on the winner. Sampler default 1.0.") +integration_params.add_option("--portfolio-freeze-wt",default=None,type=float,help="Portfolio: a member whose balance weight is below this stops updating its proposal (subject to grace/revive/VARAHA-exemption). Sampler default 0.05.") +integration_params.add_option("--portfolio-grace-iters",default=None,type=int,help="Portfolio: never freeze ANY member during the first N integration chunks (let slow starters contract). Sampler default 25.") +integration_params.add_option("--portfolio-probe-period",default=None,type=int,help="Portfolio: round-robin probe one member at a raised draw share every N chunks (breaks the under-observation trap). 0 disables probing. Sampler default 4.") +integration_params.add_option("--portfolio-quality-signal",default=None,type=str,help="Portfolio adaptive allocation: which per-member quality signal to rank members by. 'global' (default) = marginal gain in POOLED n_eff per sample (credits weight mass, debits weight variance); 'credit' = q_mix-native MIS credit assignment, sum_i [frac_m q_m/q_mix]_i * w_i per drawn sample (credits a member for COVERING where the integrand is, even if it drew few samples there); 'ness' = legacy per-member Kish n_ess (scale-invariant, misranks a slow-contracting AV -- see DESIGN_portfolio_freeze_policy.md).") +integration_params.add_option("--portfolio-revive-period",default=None,type=int,help="Portfolio: every N chunks, update even a frozen member one step so it can recover. 0 disables. Sampler default 8.") +integration_params.add_option("--portfolio-varaha-can-freeze",action='store_true',default=False,help="Portfolio: DISABLE the VARAHA freeze-exemption, so VARAHA/AV members obey the grace/revive/weight freeze schedule like other members. Use only if a VARAHA member is a known-bad fit and you want to save its selfish-draw eval cycles.") +integration_params.add_option("--portfolio-varaha-max-frac",default=None,type=float,help="Portfolio: CAP the combined DRAW fraction of VARAHA/AV members (0/unset = no cap). Use WITH --portfolio-varaha-min-frac to constrain the VARAHA share to a BAND. Rationale: a floor alone stops the mixture degenerating to peaked-member-only (which strips q_mix of its broad backstop, so a missed mode goes uncovered and lnZ is silently low while n_eff looks GOOD), but the share can then run away the OTHER way to ~1 and the mixture degenerates to VARAHA-only instead. A band (e.g. 0.25/0.75) keeps q_mix genuinely mixed by construction. Unbiased either way (balance heuristic), so it costs at most draws, never correctness.") +integration_params.add_option("--portfolio-varaha-min-frac",default=None,type=float,help="Portfolio: reserve this combined DRAW fraction for VARAHA/AV members (0/unset = off). never-freeze keeps a VARAHA member UPDATING, but both allocation rules score by per-chunk n_ess, which sits at ~1 during VARAHA's slow cumulative contraction -- so a member that looks instantly good can take nearly the whole budget (measured on S250114ax post-#33: GMM took ~0.84 and the portfolio collapsed to n_eff ~2 vs ~100 for standalone AV). Unbiased for any allocation (q_mix); trades efficiency only.") +integration_params.add_option("--portfolio-varaha-never-freeze",action='store_true',default=False,help="Portfolio: VARAHA/AV members always update every chunk past their breakpoint (freeze-exempt). This is the sampler default; the flag is here for explicitness/pipe pass-through.") +integration_params.add_option("--portfolio-weight-clip",default=None,type=float,help="Portfolio: OPT-IN truncated importance sampling applied to the PROPOSAL-FIT INPUT ONLY. Caps the weights fed to member.update_sampling_prior (the GMM covariance fit) at tau = C*sqrt(n)*mean(w) (0/unset = off; C~1 is the standard Ionides choice), so one enormous weight cannot make that fit degenerate. The estimator (ln Z, n_eff), the n_ess report, and the allocation signal all use the TRUE unclipped weights, so they stay exactly unbiased and undistorted. Do NOT clip the estimator (measured on S250114ax: n_eff=100 2x faster than AV but ln Z biased -11.5 nats) or the n_ess report (clipping inflates the clipped member's n_ess and starves the AV workhorse). The withheld tail mass is tracked and reported as a diagnostic. NOTE: if huge weights come from q_mix UNDERFLOW (watch for the warning) they are a numerical artifact, not tail mass.") integration_params.add_option("--sampler-xpy",default=None,help="numpy|cupy if the adaptive_cartesian_gpu sampler is active, use that.") +# MC-error replicas. Copied verbatim from bin/integrate_likelihood_extrinsic_batchmode; +# pinned by test_lisa_mc_error_replicas.py. +optp.add_option("--mc-error-replicas",default=0,type=int, help="MC-error stabilization: when the reported lnL error is untrustworthy (see the trigger options below), re-run the extrinsic integration this many EXTRA times as cold replicas (adaptation reset, sample cache dropped, fresh RNG draws) and report lnL from the LINEAR mean of the replica integrals with sigma from the max of the propagated error and the between-replica scatter (t-distributed, K-1 dof). The naive per-run sigma is computed from the SAME weights as the integral, so it is small exactly when the run silently missed the peak; only independent replicas can see that. NEVER combine replicas by inverse-variance weighting -- that overweights the worst replica. The posterior/fairdraw export POOLS the replicas (weights renormalized so each contributes Z_k/K; fairdraw blocks contribute equal within-block weights, since those samples already carry their weights once), so the exported samples represent the same mixture as the reported evidence. Default 0 = off (production behavior unchanged).") +optp.add_option("--mc-error-sigma-trigger",default=0.4,type=float, help="Replicate (see --mc-error-replicas) when the reported sigma_lnZ exceeds this value.") +optp.add_option("--mc-error-ess-trigger",default=30.,type=float, help="Replicate when the Kish effective sample size (sum w)^2/sum w^2 of the run's weights falls below this value.") +optp.add_option("--mc-error-khat-trigger",default=0.7,type=float, help="Replicate when the Pareto k-hat weight-tail diagnostic exceeds this value (0.7 = the PSIS reliability threshold: above it the weight variance is effectively unresolved and the naive sigma is a lower bound).") +# AV live-volume state, per-axis bin allocation, and the collapse gate. Copied verbatim +# from bin/integrate_likelihood_extrinsic_batchmode; pinned by test_lisa_av_state.py. +integration_params.add_option("--sampler-save-state",default=None,help="AV only: after integration, write the adapted live-volume state (.npz) for reuse by later instances/iterations. Point --sampler-load-state at the same file across a grid to warm-start each point from the previous one.") +integration_params.add_option("--sampler-load-state",default=None,help="AV only: load a saved live-volume state (.npz from --sampler-save-state) to warm-start this integration. Overrides --sampler-warmstart-samples.") +integration_params.add_option("--sampler-anisotropic-bins",action="store_true",help="AV only: give each extrinsic axis a DIFFERENT number of bins during contraction -- fine where the live points cluster tightly (phase/polarization/sky), coarse where they are broad (distance/inclination) -- instead of the default equal split. Keeps the same total bin budget, so the estimator is unchanged; helps AV wrap a correlated/degenerate posterior more tightly.") +optp.add_option("--reject-collapsed-live-volume",action='store_true',default=False, help="DROP an event whose adaptive-volume live volume degenerated (see the [AV COLLAPSE] report) instead of exporting it: the integration is treated as a failure, so no likelihood row, XML or posterior samples are written for it. Such a run's lnZ and samples describe a single mode of the integrand and are NOT a fair posterior draw, and nothing downstream can distinguish them from a converged export. Default off, because dropping the event silently THINS the posterior in an SNR-dependent way -- that was the pre-fix behaviour, when this case crashed. Left off, the event is exported but announces itself loudly and (with --mc-error-replicas>0) triggers replication. Turn it on when a contaminated point is worse than a missing one.") +# L0 auto-rescue. Ported from bin/integrate_likelihood_extrinsic_batchmode; defaults and help +# text kept IDENTICAL there and here on purpose -- see test_lisa_l0_rescue.py, which pins them. +integration_params.add_option("--sampler-warmstart-retry-neff",type=float,default=None,help="AV or portfolio (L0 auto-rescue): if a pass finishes below this n_eff (i.e. it stalled on a very sharp / high-amplitude peak), automatically re-run a second pass warm-started from THIS point's own highest-likelihood samples. Same-problem reuse in the sense that the seed provably contains the peak the cold pass found -- but NOT that every mode is represented, so the warm pass can be biased low if the seed missed one. The rescue still runs as before; its result is rejected in favour of the cold pass only on positive evidence of lost mass (see --sampler-l0-rescue-reject-dlnZ). A portfolio is unaffected: its GMM member carries a defensive component. Directly targets the high-SNR n_eff LOTTERY (a large fraction of independent runs collapse to n_eff~1 by contracting onto the wrong spot); the rescue re-seeds a collapsed run from the peak it did find. Recommended for high-SNR events; e.g. 5.") +integration_params.add_option("--sampler-l0-rescue-reject-dlnZ", type=float, default=3.0, help="Evidence threshold (nats) for rejecting the L0 rescue's warm pass: reject when the full-support cold pass reports lnZ this much HIGHER, which would indicate the seed missed mass. Larger = more permissive. DEFAULT RAISED 0.5 -> 3.0 ON MEASUREMENT (see test/expensive_before_merging/integrators/L0_REJECT_DLNZ_MEASUREMENT.md): across 160 known-lnZ passes the gate caught 0 of 55 genuinely truncated warm passes at EVERY threshold, while at 0.5 it binned 25% of GOOD portfolio warm passes. 0.5 was therefore strictly dominated -- it bought no detection and cost one good pass in four. 3.0 keeps a safety net for a genuinely large discrepancy at ~0% false-positive rate. This gate is NOT a working truncation detector; do not rely on it as one.") +integration_params.add_option("--sampler-l0-rescue-accept-truncated", action='store_true', default=False, help="Report the L0 rescue's warm pass even when it lands well below the full-support cold pass (see --sampler-l0-rescue-reject-dlnZ). Default OFF: on that evidence the cold result is kept instead, since the warm pass is confined to the seeded peak and may be missing a mode. The rescue itself still runs either way.") +integration_params.add_option("--sampler-l0-rescue-puff-scale", type='choice', choices=['fixed','auto'], default='auto', help="How wide to puff the L0 rescue's seed when it is rank-deficient in the adaptive dimensions. 'auto' (default) measures the posterior scale AND correlations from every finite lnL the collapsed pass already drew; 'fixed' uses --sampler-l0-rescue-puff-width-frac of each parameter's prior range, which is the historical behaviour and knows nothing about the posterior (which narrows as 1/rho). 'auto' falls back to 'fixed' when there are too few finite points to estimate a covariance.") +integration_params.add_option("--sampler-l0-rescue-puff-width-frac", type=float, default=0.005, help="Isotropic puff width for the L0 rescue's rank-deficient seed, as a fraction of each parameter's prior range. Used by --sampler-l0-rescue-puff-scale fixed, and as the 'auto' fallback. Default 0.005 = the historical hardcoded 1/200.") +integration_params.add_option("--sampler-l0-rescue-puff-factor", type=float, default=2.0, help="Multiply the L0 rescue's puff width by this factor. Default 2 is the measured optimum on a known-lnZ 6-D target (mean lnZ error +0.08 nats, ESS 52); BOTH tails are wrong, so do not treat wide as free -- x0.5 truncates (-8.5 nats), x6 biases high (+3.0) and costs efficiency, x12 is a cold start in all but name and re-collapses (-30).") +# Also consumed by the rescue (it is the lnL window build_warm_seed keeps), which is why it +# lands in this pass rather than with the sequential warm start it is named for. +integration_params.add_option("--sampler-sequential-warmstart-deltalnL",type=float,default=15.0,help="Keep previous-point samples within this lnL of the max as the warm seed for the next point. Default 15.") integration_params.add_option("--supplementary-likelihood-factor-code", default=None,type=str,help="Import a module (in your pythonpath!) containing a supplementary factor for the likelihood. Used to impose supplementary external priors of arbitrary complexity and external dependence (e.g., EM observations). EXPERTS-ONLY") integration_params.add_option("--supplementary-likelihood-factor-function", default=None,type=str,help="With above option, specifies the specific function used as an external prior. EXPERTS ONLY") integration_params.add_option("--supplementary-likelihood-factor-ini", default=None,type=str,help="With above option, specifies an ini file that is parsed (here) and passed to the preparation code, called when the module is first loaded, to configure the module. EXPERTS ONLY") @@ -494,10 +534,12 @@ n_eff = opts.n_eff # Effective number of points evaluated # # Initialize the RNG, if needed # -# TODO: Do we seed a given instance of the integrator, or set it for all -# or both? +# Seed EVERY backend a sampler can draw from, not just numpy: the samplers draw +# through self.xpy / xpy_default, which is cupy on GPU, and cupy has its own +# global generator. See RIFT/integrators/seeding.py. if opts.seed is not None: - numpy.random.seed(opts.seed) + from RIFT.integrators.seeding import seed_everything + seed_everything(opts.seed) # LISA check, reference time instead of event time if not(opts.LISA): @@ -790,6 +832,7 @@ params = {} sampler = mcsampler.MCSampler() xpy_asarray_already = functools.partial(xpy_default.asarray,dtype=np.float64) +use_gmm_member=False # set when a portfolio carries a GMM member (see the portfolio setup loop) if opts.sampler_method == "adaptive_cartesian_gpu": print(" ILE: {}".format(opts.sampler_method)) sampler = mcsamplerGPU.MCSampler() @@ -844,13 +887,32 @@ elif opts.sampler_method == "portfolio": for name in sampler_types: if name =='AV': sampler = mcsamplerAdaptiveVolume.MCSampler(n_chunk=opts.n_chunk) # enforce now, so provided for setup phase - if name =='GMM': + elif name =='GMM': sampler = mcsamplerEnsemble.MCSampler() - # following override means sampler_method is CHANGED, so THIS MUST BE LAST, and can't condition on portfolio - opts.sampler_method = 'GMM' # this will force the creation/parsing of GMM-specific arguments below, so they are properly passed - if name == "adaptive_cartesian_gpu": + # A GMM member needs the GMM-specific argument blocks below to run so its config is + # forwarded. This used to CLOBBER opts.sampler_method='GMM', which silently broke + # every downstream `sampler_method == "portfolio"` test -- most importantly the L0 + # auto-rescue gate, which then NEVER FIRED for a portfolio carrying a GMM member -- + # and made a portfolio take GMM-only branches (e.g. return_lnI). Flag it + # non-destructively instead: sampler_method stays 'portfolio', and the GMM blocks + # below key off `use_gmm_args` = standalone GMM OR a portfolio with a GMM member. + # Ported from bin/integrate_likelihood_extrinsic_batchmode, which fixed this. + use_gmm_member = True + elif name == "adaptive_cartesian_gpu" or name == 'AC': sampler = mcsamplerGPU.MCSampler() mcsampler = mcsamplerGPU # force use of routines in that file, for properly configured GPU-accelerated code as needed + elif name in mcsamplerPortfolio.known_pipelines: # everything else, including nflow + sampler = mcsamplerPortfolio.known_pipelines[name]() + else: + # No else clause here meant an unrecognized name left `sampler` bound to its + # previous value -- the plain MCSampler built before this chain, or, on the second + # and later iterations, the PREVIOUS member -- and appended it silently. The + # portfolio then ran with a member the user never asked for, and a typo in + # --sampler-portfolio produced a duplicate rather than an error. Ported from + # bin/integrate_likelihood_extrinsic_batchmode. (The chain above is now elif for + # the same reason: with plain `if`, a name matching no branch fell through every + # test and reused whatever `sampler` still held.) + raise Exception(" --sampler-portfolio: unknown member '{}'. Known: AV, GMM, AC/adaptive_cartesian_gpu, {}".format(name, sorted(mcsamplerPortfolio.known_pipelines))) print('PORTFOLIO: adding {} '.format(name)) # enable xpy for low level sampler as needed if hasattr(sampler, 'xpy'): @@ -1197,10 +1259,209 @@ if opts.sampler_method=="GMM" and opts.internal_use_lnL: if opts.sampler_method =="adaptive_cartesian_gpu" and opts.internal_use_lnL: return_lnL=True pinned_params.update({"use_lnL":True}) +if opts.sampler_method =="AV" and opts.internal_use_lnL: + # AV integrates in log space natively (integrate() is a thin wrapper over integrate_log); + # without this, --internal-use-lnL --sampler-method AV passed the ok_lnL_methods check but + # silently did nothing, so exp(lnL) overflowed at high SNR when no logarithm offset was set. + # + # PORTED FROM THE MAIN DRIVER, where this branch already exists. It was missing here, and + # the drift audit could not see it: a missing `if` branch is not a FUNC/OPTION/CONST/ATTR, + # so it produces no gap item. High-SNR is the LISA MBHB regime, which is exactly the case + # the main driver's comment describes. + return_lnL=True + pinned_params.update({"use_lnL":True}) if opts.sampler_method =="portfolio": return_lnL=True pinned_params.update({"use_lnL":True}) -if opts.sampler_method == "GMM": + +# What the sampler will actually STORE in _rvs['integrand'], derived from the pinned params +# above rather than from the CLI. This is not the same predicate as opts.internal_use_lnL: +# that option is accepted for adaptive_cartesian_gpu and portfolio too (see the branches +# directly above), which set use_lnL WITHOUT return_lnI and therefore still store linear L. +# Keying the weight helpers off the option would compute L + ln p - ln p_s for those, which +# is the failure the main driver documents at ln_weights_from_rvs. +rvs_integrand_is_lnL = bool(pinned_params.get("return_lnI", False)) + + +# --------------------------------------------------------------------------------------- +# Fair-draw weighting helpers. Ported from bin/integrate_likelihood_extrinsic_batchmode +# (PR #87); see test/expensive_before_merging/integrators/RVS_FAIRDRAW_AUDIT.md. +# +# WHY THESE ARE HERE, given this driver has no .dgrid/.dslice/proposal-breadcrumb exports +# (the three consumers whose double-weighting PR #87 actually fixed): this driver DOES set +# igrand_fairdraw_samples from --fairdraw-extrinsic-output, so its _rvs can be a fair draw, +# and every shared sampler already sets the provenance marker at its rebind. The marker was +# arriving here and nothing was reading it. The helpers are the correct thing for the next +# person to reach for, which is the whole argument of the audit's Recommendation 1. +# +# KEEP IN STEP WITH THE MAIN DRIVER. These are deliberate copies, not an import, because the +# two drivers are a deliberate fork; audit_lisa_driver_drift.py is what makes the copy visible. +# --------------------------------------------------------------------------------------- +def _rvs_lnL_convention(use_lnL=None): + """Resolve the stored-'integrand' convention for a helper call. + + Returns the explicit argument when given, else the run's `rvs_integrand_is_lnL`. Falls + back to False (the historical linear reading) when that global is absent, which is what + happens when these helpers are lifted out of the driver by the unit tests. Read through + globals() rather than by name so a missing global cannot become a NameError swallowed by + a caller's bare `except Exception`. + """ + if use_lnL is not None: + return bool(use_lnL) + return bool(globals().get('rvs_integrand_is_lnL', False)) + + +def ln_weights_from_rvs(rvs, convert=None, use_lnL=False): + """THE importance log-weight of an _rvs record: lnL + ln(prior) - ln(sampling_prior). + + ONE definition, because the alternative has already cost us. A stored 'log_weights' + column does not mean the same thing in every sampler: mcsamplerPortfolio stores the true + importance weight, but mcsamplerGPU stores tempering_exp*lnL + ln p - ln p_s -- the + ADAPTATION weight, with --adapt-weight-exponent baked in. That exponent is not 1 in + production and --no-adapt drives it to 0, removing the likelihood from the column + entirely. A consumer preferring that cache silently reweights its output by L^(e-1). + + So the cache is never read here: the weight is DERIVED from the canonical components -- + log form first, then the linear (mcsamplerEnsemble) form, out-of-support rows -inf. + Raises when neither set is present: an explicit failure beats a plausible wrong number. + + `use_lnL` is REQUIRED to read the linear form correctly, because mcsamplerEnsemble reuses + 'integrand' for BOTH conventions (it stores lnL when given return_lnI). Taking log() of + lnL compresses tens of nats into log(tens), leaving an almost flat weight vector, and the + positivity cut is wrong in that mode too: non-positive means a low-likelihood point, not + a rejected one, so `ig > 0` would discard every sample with lnL <= 0. + + PASS THE STORED CONVENTION, NOT THE CLI OPTION -- `rvs_integrand_is_lnL`, not + `opts.internal_use_lnL`. In this driver the two genuinely differ: --internal-use-lnL is + also accepted for adaptive_cartesian_gpu and portfolio, which set use_lnL without + return_lnI and still store linear L. + """ + conv = convert if convert is not None else (lambda x: x) + if all(k in rvs for k in ('log_integrand', 'log_joint_prior', 'log_joint_s_prior')): + return (numpy.asarray(conv(rvs['log_integrand']), dtype=float) + + numpy.asarray(conv(rvs['log_joint_prior']), dtype=float) + - numpy.asarray(conv(rvs['log_joint_s_prior']), dtype=float)) + if all(k in rvs for k in ('integrand', 'joint_prior', 'joint_s_prior')): + ig = numpy.asarray(conv(rvs['integrand']), dtype=float) + jp = numpy.asarray(conv(rvs['joint_prior']), dtype=float) + js = numpy.asarray(conv(rvs['joint_s_prior']), dtype=float) + out = numpy.full(len(ig), -numpy.inf) + if use_lnL: + # 'integrand' already holds lnL: do not log it again, do not cut on its sign. + keep = numpy.isfinite(ig) & (jp > 0) & (js > 0) + out[keep] = ig[keep] + numpy.log(jp[keep]) - numpy.log(js[keep]) + else: + keep = (ig > 0) & (jp > 0) & (js > 0) + out[keep] = numpy.log(ig[keep]) + numpy.log(jp[keep]) - numpy.log(js[keep]) + return out + raise Exception("cannot build importance weights from sampler._rvs (keys={})".format( + sorted(rvs.keys()))) + + +def _rvs_len(rvs): + """Rows in a raw `_rvs` column dict -> int. + + ONE row-count rule, and it lives with the record (`rvs_record.n_rows`). Flattening + whichever column came first was wrong for the ORDINARY case, not a corner: `_rvs` is + seeded parameters-first, and a combined parameter is stored (ndim, N) under a TUPLE key, + so any run registering one reported ndim*N. Here that number is the length the pooled + export's weight vector is checked against, so the check failed and the pooled record went + out weight-mixed -- the exact degradation `_export_rvs_equal_weight` exists to prevent. + + Imported INSIDE the function deliberately: the test harnesses exec these helpers out of + the driver into a bare namespace, so a module-level name here would have to be threaded + through every one of them -- and this staying a one-line delegation is the point. + """ + from RIFT.integrators.rvs_record import n_rows as _n_rows_of_columns + return _n_rows_of_columns(rvs) + + +def _rvs_is_export_resample(sampler): + """True when the ROWS of _rvs were drawn in proportion to weight. + + Set by the samplers at the rebind itself, so it means "the draw FIRED", which is NOT the + same predicate as `opts.fairdraw_extrinsic_output`: the draw is skipped when it would not + shrink the record (n_extr >= len(_rvs)), and then the rows are still the retained set + carrying real importance weights. Keying off the CLI flag would flatten those -- the same + class of error in the other direction. + + SURVIVES POOLING by design, and this driver now does pool (--mc-error-replicas): a pooled + record built from fair-drawn replicas still has posterior-resampled rows, so anything that + must not re-weight them keeps seeing True here. Whether the record is GLOBALLY + equal-weight is a different question; see _rvs_is_equal_weight. + """ + return bool(getattr(sampler, '_rvs_is_fairdraw', False)) + + +def _rvs_is_equal_weight(sampler): + """True when EVERY row of _rvs carries the same posterior weight. + + Two properties, deliberately not one flag: + + rows resampled -- each row drawn proportional to w (per-BLOCK property) + equal weight -- the record as a whole is uniform (property of the WHOLE record) + + A single fair draw has both. A POOLED record has the first and not the second, because + pooling weights block k by the replica evidence Z_k/K. Conflating them broke two things + in opposite directions in the main driver (audit Finding 6), which is why the split is + carried over here even though this driver has no pooling yet. + """ + return (bool(getattr(sampler, '_rvs_is_fairdraw', False)) + and not bool(getattr(sampler, '_rvs_is_pooled', False))) + + +def ln_weights_for_posterior(rvs, sampler, convert=None, use_lnL=None): + """The weights to use when treating an _rvs record as a POSTERIOR SAMPLE SET. + + NOT the same question as `ln_weights_from_rvs`, which answers "what is the importance + weight of this record" and is always right about that. The question here is "how should + these rows be weighted to represent the posterior", and the answer depends on whether the + fair draw already did it. + + A fair-drawn record was resampled WITH REPLACEMENT proportional to w, so its rows are + already an equal-weight draw from the posterior. Weighting them by w again applies w^2 + and over-concentrates the result -- measured at a 13% shift in the posterior mean of a + weight-correlated coordinate (verify_skew.py). + + So: uniform (zero log-weight) for a fair-drawn record, the derived importance weight + otherwise. Returns a float array the length of the record. + + `use_lnL` is passed THROUGH UNRESOLVED, exactly as in the main driver: a caller that + omits it gets the linear reading, not the run's convention. That is a trap in both + drivers, and it is deliberately reproduced rather than fixed here -- a helper of the + same name behaving differently in the two forked drivers would be a worse defect than + the one it fixes. Callers must pass `use_lnL=rvs_integrand_is_lnL`, or route through + `_rvs_lnL_convention` first, the way the main driver's call sites do. + """ + # MIGRATION (DESIGN_rvs_naming.md), the first consumer to move. This is the exact + # site where the one-flag-two-questions defect lived, so it is the one worth converting + # first: `is_equal_weight()` is a named question rather than two booleans a caller has to + # combine, and it cannot be answered with the wrong one. + # + # The flags stay as the fallback while the other six samplers are unconverted -- and while + # both exist they MUST agree, which is asserted directly in test_rvs_record.py rather than + # left as a comment, because "two sources of truth" is the risk this migration runs. + _rec = _rvs_record_for(sampler, rvs) + if _rec is not None: + if _rec.is_equal_weight(): + return numpy.zeros(_rvs_len(rvs), dtype=float) + # THE WEIGHT ITSELF now comes from the record, not from ln_weights_from_rvs -- which is + # the point of the record: it knows its own convention, so there is no `use_lnL` to + # thread through and no way for a caller to pass the wrong one. + # + # Verified equivalent before switching, not after: the two implementations were fuzzed + # against each other over 1200 randomized records spanning all three column families + # with NaN / -inf / 0 sprinkled through every column. That found a REAL divergence + # first -- log_weights() had been computing lnL + ln(pi) - ln(q) term by term, which + # yields NaN where the canonical form's conjunctive keep-mask yields -inf -- and it is + # fixed there rather than papered over here. + return numpy.asarray(_rec.log_weights(convert=convert), dtype=float) + if _rvs_is_equal_weight(sampler): + return numpy.zeros(_rvs_len(rvs), dtype=float) + return numpy.asarray(ln_weights_from_rvs(rvs, convert=convert, use_lnL=use_lnL), + dtype=float) +use_gmm_args = (opts.sampler_method == "GMM") or use_gmm_member +if use_gmm_args: # standalone GMM, or a portfolio carrying a GMM member n_step =pinned_params["n"] n_max_blocks = ((1.0*int(opts.n_max))/n_step) # pairing coordinates for adaptive integration: see definition of order below @@ -1283,13 +1544,1193 @@ if use_portfolio: if not(isinstance(opts.sampler_portfolio_args[indx], dict)): print(indx,opts.sampler_portfolio_args[indx]) print(" ARGS ", opts.sampler_portfolio_args) - sampler.setup(portfolio_args=opts.sampler_portfolio_args, **pinned_params) # directly pass all parameters set above to low-level portfolios. In particular, GMM setup + # Assemble freeze-policy overrides from the CLI. Only include options the user actually + # set (None = unset) so the sampler keeps its built-in defaults otherwise. The two VARAHA + # flags are mutually exclusive; --portfolio-varaha-can-freeze wins if both are given. + _freeze_policy_kwargs = {} + if opts.portfolio_grace_iters is not None: + _freeze_policy_kwargs['portfolio_grace_iters'] = opts.portfolio_grace_iters + if opts.portfolio_revive_period is not None: + _freeze_policy_kwargs['portfolio_revive_period'] = opts.portfolio_revive_period + if opts.portfolio_freeze_wt is not None: + _freeze_policy_kwargs['portfolio_freeze_wt'] = opts.portfolio_freeze_wt + if opts.portfolio_varaha_can_freeze: + _freeze_policy_kwargs['portfolio_varaha_never_freeze'] = False + elif opts.portfolio_varaha_never_freeze: + _freeze_policy_kwargs['portfolio_varaha_never_freeze'] = True + # adaptive-probe draw allocation (OPT-IN; off by default in the sampler) + if opts.portfolio_adaptive_alloc: + _freeze_policy_kwargs['portfolio_adaptive_alloc'] = True + if opts.portfolio_varaha_min_frac is not None: + _freeze_policy_kwargs['portfolio_varaha_min_frac'] = opts.portfolio_varaha_min_frac + if opts.portfolio_varaha_max_frac is not None: + _freeze_policy_kwargs['portfolio_varaha_max_frac'] = opts.portfolio_varaha_max_frac + if opts.portfolio_weight_clip is not None: + _freeze_policy_kwargs['portfolio_weight_clip'] = opts.portfolio_weight_clip + if opts.portfolio_quality_signal is not None: + _freeze_policy_kwargs['portfolio_quality_signal'] = opts.portfolio_quality_signal + if opts.portfolio_alloc_exponent is not None: + _freeze_policy_kwargs['portfolio_alloc_exponent'] = opts.portfolio_alloc_exponent + if opts.portfolio_probe_period is not None: + _freeze_policy_kwargs['portfolio_probe_period'] = opts.portfolio_probe_period + print(" PORTFOLIO freeze-policy overrides: ", _freeze_policy_kwargs) + sampler.setup(portfolio_args=opts.sampler_portfolio_args, **_freeze_policy_kwargs, **pinned_params) # directly pass all parameters set above to low-level portfolios. In particular, GMM setup # initialize sampler, before we call integrate, so we can seed it if opts.sampler_method == 'adaptive_cartesian_gpu' and opts.skymap_file: sampler.setup() +# --------------------------------------------------------------------------------------- +# L0 auto-rescue. Ported from bin/integrate_likelihood_extrinsic_batchmode (PR #79/#84/#87); +# see test/expensive_before_merging/integrators/RVS_FAIRDRAW_AUDIT.md Findings 1 and 5. +# +# ONE DELIBERATE STRUCTURAL DIVERGENCE FROM THE MAIN DRIVER. There the rescue is inline in +# the single analyze_event. This driver has TWO -- analyze_event_LISA (used with --LISA) and +# analyze_event (the non-LISA fallback) -- each with its own integrate call and export block, +# already ~50% duplicated. Inlining the rescue twice would create a third copy to keep in +# step, which is the failure mode this whole exercise exists to prevent. So the block lives +# in _maybe_l0_rescue below and both call it. The helpers are byte-identical to main's and +# are pinned that way by test_lisa_l0_rescue.py. +# --------------------------------------------------------------------------------------- +def _rvs_record_for(sampler, rvs): + """The record describing THESE columns, or None. See DESIGN_rvs_naming.md. + + THE IDENTITY CHECK IS THE POINT. A record holds a reference to a column dict that other + code replaces in place, so "the sampler has a record" and "the record describes the rows I + am holding" are different questions -- the same shape as everything else in this file's + history. A record that has fallen out of step is not consulted; the caller falls back to + the provenance flags, which are maintained separately and are still correct. + + One lookup rather than the check repeated per consumer, for the reason the reserve lookup + was centralised in #87: two copies of a guard drift. + """ + # `samples()` is the public accessor; the getattr guard is for an object that predates the + # mixin (an old pickle, a test double), not for the six samplers, all of which have it. + _get = getattr(sampler, 'samples', None) + rec = _get() if callable(_get) else None + if rec is None or getattr(rec, 'columns', None) is not rvs: + return None + return rec + + +def _sampler_keeps_records(sampler): + """Does this sampler populate `_rvs_record` at all? See DESIGN_rvs_naming.md. + + A PRODUCER's question, not a consumer's, and deliberately a different function from + `_rvs_record_for`. The pooling step is about to REPLACE `sampler._rvs`, so asking "does a + record describe the rows I hold" is the wrong question there -- it would be answered `None` + and the pooled record would silently not be built. What it needs to know is whether this + sampler participates in the record scheme at all. + + Two questions, two names. That is the entire lesson of this file's last four review rounds. + """ + # PARTICIPATION, not "is one present right now". Every sampler clears _rvs_record at the + # top of integrate(), so a replica that raised leaves None behind while the sampler is still + # a full participant -- and keying on presence would silently skip building the pooled + # record for it. Ask whether the sampler implements the scheme at all. + return isinstance(sampler, SamplerOutputMixin) or ( + callable(getattr(sampler, 'samples', None)) + and callable(getattr(sampler, 'set_samples', None))) + + +def _internal_record_of(sampler): + """This pass's record, marked INTERNAL for threading -> RvsRecord or None. + + Replica pooling needs each block's record to derive that block's weights with the right + convention. Marking them internal is the difference between "we had to hand the structure + back" and "this is now something consumers may use": set_samples() refuses an internal + record, so nothing on this list can reappear from samples(). + """ + _get = getattr(sampler, 'samples', None) + rec = _get() if callable(_get) else None + return rec.as_internal() if rec is not None else None + + +def _rebound_record(sampler, columns): + """A copy of the sampler's record whose `.columns` is `columns` -> RvsRecord or None. + + Snapshot/restore installs a COPY of the column dict, so a record still pointing at the + original would fail every identity check and silently do nothing. + """ + _get = getattr(sampler, 'samples', None) + rec = _get() if callable(_get) else None + if rec is None: + return None + out = rec.snapshot() + out.columns = columns + return out + + +def _lw_of(rvs, record, use_lnL): + """Importance log-weights for `rvs`, preferring a record that describes it. + + ONE resolver, so the two estimators below cannot drift in which source they trust. A + record is used only when its `.columns` IS this dict: `_rvs` is copied and replaced all + over this file, and a record describing different columns must not be believed. Otherwise + fall back to the canonical derivation with the stored convention -- the two are verified + equivalent by a randomized comparison in test_rvs_record.py, so this is a source choice, + not a semantics choice. + """ + if record is not None and getattr(record, 'columns', None) is rvs: + return numpy.asarray(record.log_weights(), dtype=float) + return numpy.asarray(ln_weights_from_rvs(rvs, use_lnL=_rvs_lnL_convention(use_lnL)), + dtype=float) + + +def _lnZ_of_rvs(rvs, already_pooled=True, use_lnL=None, record=None): + """log of the evidence implied by an _rvs record. + + For a POOLED record the weights already carry their 1/(K n_k) factor, so the estimate is the + plain sum; for a single run it is the mean. Returns None when the weights cannot be rebuilt. + """ + try: + try: + lw = _lw_of(rvs, record, use_lnL) + except Exception: + return None + lw = lw[numpy.isfinite(lw)] + if lw.size == 0: + return None + m = numpy.max(lw) + tot = m + numpy.log(numpy.sum(numpy.exp(lw - m))) + return float(tot if already_pooled else tot - numpy.log(lw.size)) + except Exception: + return None + + +def _kish_neff_of_rvs(rvs, use_lnL=None, record=None): + """Kish effective sample size of an _rvs record, or None if the weights are not reconstructible.""" + try: + try: + lw = _lw_of(rvs, record, use_lnL) + except Exception: + return None + lw = lw[numpy.isfinite(lw)] + if lw.size == 0: + return None + lw = lw - numpy.max(lw) + w = numpy.exp(lw) + return float(numpy.sum(w) ** 2 / numpy.sum(w ** 2)) + except Exception: + return None + + +def _lnZ_of_reserve_or_rvs(sampler, rvs, reserve=None): + """lnZ of a completed pass, from the points it RETAINED where that is available. + + The rescue's reject gate compares the warm pass's lnZ against the cold pass's, and both + were read out of _rvs -- which the fair draw has already replaced with + min(n_extr, 1.5*eff_samp, 1.5*neff) rows resampled WITH REPLACEMENT, proportional to + weight. That is not a smaller unbiased sample of the same estimator, it is a DIFFERENT + and biased one: _lnZ_of_rvs forms logsumexp(w)/n, so drawing n rows proportional to w + returns something near max(w) rather than mean(w), high by roughly + + log(n_retained / eff_samp) + + and the two passes are drawn at wildly different n and eff_samp. The gate was reading a + multi-nat artifact of its own two subsample sizes as evidence that the warm seed had + missed mass. + + Falls back to the old _rvs reading when no reserve was kept, so the comparison degrades to + the previous behaviour rather than to no gate at all. + + Returns (lnZ, source) -- the caller MUST check that both sides came from the same source, + because the two readings are not interchangeable. + """ + _res = reserve if reserve is not None else getattr(sampler, '_warm_seed_reserve', None) + if isinstance(_res, dict) and 'log_joint_prior' in _res and 'log_joint_s_prior' in _res: + try: + # NOT _lnZ_of_rvs: it averages over the rows it is handed, and the reserve is + # neither the draw set nor a uniform sample of it -- non-finite rows were dropped + # and the remainder may have been capped. lnZ_from_reserve restores the original + # proposal-draw normalization from n_finite/n_retained. Without it a PORTFOLIO + # reading is high by ~log(n_retained/n_finite), ~11 nats on a collapsed pass, and + # the error does NOT cancel in the gate: the cold and warm passes have different + # finite fractions, so it is the difference of two different-sized errors. + _v = mcsamplerAdaptiveVolume.lnZ_from_reserve(_res) + if _v is not None and numpy.isfinite(_v): + return _v, 'retained' + except Exception: + pass + return _lnZ_of_rvs(rvs, already_pooled=False, + record=_rvs_record_for(sampler, rvs)), 'fairdraw' + + +def _snapshot_pass_state(sampler, res, var, neff, dict_return, rvs=None): + """Everything that must move TOGETHER when a completed pass is put back -> dict. + + THE POINT IS THE WORD "everything". A pass is described by more than its samples, and the + reject path used to restore only some of it: `_rvs`, the estimate and `dict_return` went + back to the cold pass while `_warm_seed_reserve` was left holding the REJECTED warm cloud. + In the main driver that stayed latent until --sampler-sequential-warmstart began seeding + the next intrinsic point from the reserve, at which point a rejected, truncated warm pass + became the seed for the next point -- the exact failure the reject gate exists to prevent, + reintroduced one attribute over. THAT OPTION DOES NOT EXIST IN THIS DRIVER YET, so the + reserve restore is pre-emptive here; it is also what makes porting the capture safe, which + is why it lands first. + + So the snapshot carries the reserve and the fair-draw marker as well, including the + per-member reserves: `_warm_seed_reserve_for` falls through to `portfolio_realizations`, + so restoring only the aggregate would leave that fallback pointing at the warm pass. + """ + return dict( + rvs=(dict(sampler._rvs) if rvs is None else rvs), + res=res, var=var, neff=neff, dict_return=dict_return, + warm_seed_reserve=getattr(sampler, '_warm_seed_reserve', None), + rvs_is_fairdraw=bool(getattr(sampler, '_rvs_is_fairdraw', False)), + rvs_is_pooled=bool(getattr(sampler, '_rvs_is_pooled', False)), + # The record too. A stale one is already declined by _rvs_record_for's identity check, + # so this is belt-and-braces -- but "everything describing the pass moves together" is + # the invariant, and carving an exception into it is how round 1 happened. + # REBOUND to the snapshot's columns. The record held a reference to the LIVE dict, and + # the restore installs a COPY -- so storing it as-is produced a record whose identity + # check could never match, i.e. inert rather than belt-and-braces. Rebinding makes it + # describe what is actually put back. + rvs_record=_rebound_record(sampler, dict(sampler._rvs) if rvs is None else rvs), + member_reserves=[getattr(_m, '_warm_seed_reserve', None) + for _m in list(getattr(sampler, 'portfolio_realizations', []) or [])], + ) + + +def _restore_pass_state(sampler, state): + """Undo of _snapshot_pass_state -> (res, var, neff, dict_return). + + Both callers (the reject path and the exception handler) go through here, so the set of + attributes that travels with a restored pass cannot drift between them. + """ + sampler._rvs = state['rvs'] + sampler._warm_seed_reserve = state['warm_seed_reserve'] + sampler._rvs_is_fairdraw = state['rvs_is_fairdraw'] + sampler._rvs_is_pooled = state['rvs_is_pooled'] + if callable(getattr(sampler, 'set_samples', None)): + sampler.set_samples(state.get('rvs_record')) + _members = list(getattr(sampler, 'portfolio_realizations', []) or []) + for _m, _r in zip(_members, state.get('member_reserves', [])): + _m._warm_seed_reserve = _r + return state['res'], state['var'], state['neff'], state['dict_return'] + + +def _warm_seed_reserve_for(sampler): + """The retained-sample reserve a completed pass left behind, or None. + + THE ONE LOOKUP FOR SEED CONSUMERS. In the main driver there are two -- the L0 auto-rescue + (which re-seeds a collapsed pass from its own peak) and the --sampler-sequential-warmstart + capture (which seeds the NEXT intrinsic point). ONLY THE RESCUE EXISTS IN THIS DRIVER; the + shared lookup is kept so the pair cannot drift once the capture is ported. Both otherwise + fall back to sampler._rvs, which by then has been rebound to a fair-draw subset + taken WITH REPLACEMENT -- and the whole point of the reserve is that on the collapsed pass + a warm start exists for, that subset is a handful of rows several of which are the same + point twice. + + A PORTFOLIO keeps the reserve on the aggregate, not on its members, but a bare AV member + can be the one that has it; check the sampler first, then its realizations. + + COLUMN ORDER MUST MATCH or the seed is scrambled: the reserve stores X in the column order + of the sampler that built it, and a seed handed to bootstrap_from_samples is read + positionally against params_ordered. A mismatch is silent and produces a seed in the + wrong coordinates, so decline the reserve rather than use it. + + NOT SHARED WITH `_lnZ_of_reserve_or_rvs` above, deliberately. That one reads only lnL and + the two prior columns, never X, so a column-order mismatch is harmless to it and declining + would throw away a good lnZ reading and silently downgrade the gate to its fallback. + """ + _res = getattr(sampler, '_warm_seed_reserve', None) + if _res is None: + for _m in list(getattr(sampler, 'portfolio_realizations', []) or []): + _res = getattr(_m, '_warm_seed_reserve', None) + if _res is not None: + break + if _res is not None and list(_res.get('params_ordered', [])) != list(sampler.params_ordered): + return None + return _res + + +def _warm_seed_geometry(sampler): + """Which columns a warm seed must span, and the box it must lie in -> (axes, lo, hi). + + The seed is judged on the ADAPTIVE axes, because those are the only ones the live-volume + grid resolves (the rest get a single bin), and that is the set the [AV COLLAPSE] report + counts against. Ask the sampler that will consume the seed rather than assuming all + dimensions: with --force-adapt-all they coincide, without it a rank test over every column + would demand a seed span directions the grid cannot resolve and puff for nothing. + + A PORTFOLIO has no adaptive axes of its own -- they live on its AV-style members -- so fall + through to the first member that can answer. If nobody can, every column it is. + """ + _lo = np.array([sampler.llim[p] for p in sampler.params_ordered], dtype=float) + _hi = np.array([sampler.rlim[p] for p in sampler.params_ordered], dtype=float) + for _s in [sampler] + list(getattr(sampler, 'portfolio_realizations', []) or []): + if hasattr(_s, 'warm_seed_axes'): + try: + return list(_s.warm_seed_axes()), _lo, _hi + except Exception: + pass + return list(range(len(sampler.params_ordered))), _lo, _hi + + +def _clear_warm_state(sampler): + """Clear a warm-start seed AND any grid it installed, reaching PORTFOLIO MEMBERS too. + + `sampler._warm = None` alone is not enough for mcsamplerPortfolio: `_warm` and the + contracted AV grid live on each MEMBER, and portfolio.integrate_log() does not rerun each + member's setup(), so the next point would silently draw from the PREVIOUS point's + contracted live volume. If the new point's support falls outside it, lnZ is biased low + with a healthy-looking n_eff and no error. Portfolio exposes clear_warm_state(); + everything else keeps the old behaviour. + """ + # Deliberately NOT wrapped in try/except. A reset that quietly did not happen leaves the + # next point drawing from the previous point's contracted grid -- the exact silent bias + # this guards against -- so a failure must abort the point rather than degrade to a log + # line nobody reads. + if hasattr(sampler, 'clear_warm_state'): + sampler.clear_warm_state() + else: + sampler._warm = None + sampler._warm_applied = False + + +def _maybe_load_av_state(sampler): + """Warm-start this integration from a saved AV live-volume state (--sampler-load-state).""" + try: + if opts.sampler_load_state and hasattr(sampler, 'load_state'): + print(" warm-start: loading saved sampler state from", opts.sampler_load_state) + sampler.load_state(opts.sampler_load_state) + except Exception as _e_ls: + print(" AV state load skipped (", _e_ls, ")") + + +def _maybe_save_av_state(sampler): + """Persist the adapted live-volume state for reuse by later instances/iterations.""" + if opts.sampler_method == 'AV' and opts.sampler_save_state and hasattr(sampler, 'save_state'): + if not getattr(sampler, '_av_state_reuse_safe', True): + print(" AV: not saving live-volume state from a rejected/failed rescue") + return + try: + sampler.save_state(opts.sampler_save_state) + print(" AV: saved live-volume state to", opts.sampler_save_state) + except Exception as _e_ss: + print(" AV: could not save state (", _e_ss, ")") + + +def _maybe_enable_anisotropic_bins(sampler): + """Opt-in per-axis bin allocation, on the AV sampler AND any AV portfolio members.""" + if getattr(opts, 'sampler_anisotropic_bins', False): + _aniso_targets = [sampler] + list(getattr(sampler, 'portfolio_realizations', [])) + for _t in _aniso_targets: + if hasattr(_t, 'anisotropic_bins'): + _t.anisotropic_bins = True + print(" AV: anisotropic per-axis bin allocation ENABLED") + + +def _extract_mc_diag(dd): + dd = dd if isinstance(dd, dict) else {} + return dd.get('pareto_khat', None), dd.get('sigma_lnZ_block', None), dd.get('n_ESS', None), dd.get('lnZ_ci90', None) + + +def _pool_replica_rvs(rep_rvs, sampler, rep_lnZ=None, already_resampled=False, use_lnL=None, + records=None): + """Concatenate the replicas' samples into one correctly-weighted set. + + Each replica k is an independent importance-sampling estimate with weights w_ki and its own + sample count n_k, and the reported evidence is the linear mean (1/K) sum_k Z_k. The posterior + that matches THAT estimator is the concatenation with weights w_ki/(K n_k) -- equivalently the + importance weight against the pooled proposal q'_ki = q_ki * K * n_k, which is the actual + density of "pick a replica uniformly, then one of its n_k draws". So the K*n_k factor goes + into the sampling prior, where every downstream weight computation already accounts for it. + + Falls back to the first replica if the record shape is unexpected: a degraded export is + recoverable, a silently mis-weighted one is not. + + `use_lnL` is the stored convention of the RAW ('integrand') columns -- see + `_rvs_lnL_convention`. It matters here because this function REWRITES joint_s_prior to force a + block's weights, and the equation to solve is convention-dependent (see below). + """ + _lnL_here = _rvs_lnL_convention(use_lnL) + # `already_resampled` may be a single bool or a PER-REPLICA sequence. It has to be the + # latter in general: each pass decides independently whether to fair-draw (the draw is + # skipped when it would not shrink that pass's record), so one global flag either flattens + # a replica whose weights are genuine, or leaves a resampled replica double-weighted. A + # mixture of raw and resampled replicas is the normal case near the n_extr boundary. + _ar_list = (list(already_resampled) + if isinstance(already_resampled, (list, tuple, numpy.ndarray)) + else None) + # PER-REPLICA RECORDS, threaded in so each block's lnZ is derived with ITS OWN convention + # instead of one `use_lnL` asserted over the whole set. These are INTERNAL: they are + # plumbing for this function, marked as such, and refused by set_samples() so they cannot + # escape through the public samples() accessor. Having had to pass the structure around is + # not a reason for anyone else to reach for it. + _rec_list = list(records) if records is not None else None + # Drop empty records in LOCKSTEP with their metadata. The filter used to run on rep_rvs + # alone, so a single empty replica shifted every later block against its own lnZ -- and + # would now shift it against its own resampled flag too. + _keep = [i for i, r in enumerate(rep_rvs) if r] + rep_rvs = [rep_rvs[i] for i in _keep] + if rep_lnZ is not None: + rep_lnZ = [rep_lnZ[i] for i in _keep if i < len(rep_lnZ)] + if _ar_list is not None: + _ar_list = [_ar_list[i] for i in _keep if i < len(_ar_list)] + if _rec_list is not None: + _rec_list = [_rec_list[i] for i in _keep if i < len(_rec_list)] + + def _block_record(i, r): + """The record for block i, but only if it describes THAT block's columns.""" + if _rec_list is None or i >= len(_rec_list): + return None + rec = _rec_list[i] + return rec if getattr(rec, 'columns', None) is r else None + + def _block_resampled(i): + if _ar_list is not None: + return bool(_ar_list[i]) if i < len(_ar_list) else False + return bool(already_resampled) + + def _block_column(k, v): + """One block's column for key `k`, in the layout the KEY implies. + THE KEY SAYS WHERE THE ROW AXIS IS -- the same rule `_rvs_len` delegates to + (rvs_record._column_n_rows), applied to the rows themselves. A combined parameter is + stored (ndim, N) under a TUPLE key, so ravelling it and concatenating on axis 0 turns + it into ONE 1-D column of length ndim*sum(N) while the scalar columns have sum(N) rows. + Consumers still require (ndim, N) -- the sample exporter unpacks the combined sky column + as `samples["latitude"], samples["longitude"] = samples[("declination", + "right_ascension")]` -- so --mc-error-replicas produced a malformed record and could + abort the export. Per-row columns keep the flatten they always had. + """ + v = numpy.asarray(v) + return numpy.atleast_2d(v) if isinstance(k, tuple) else numpy.atleast_1d(v).ravel() + + def _empty_column(k): + """No block contributed any rows -- an empty column that still has the key's LAYOUT. + Handing back a bare `array([])` for a tuple key would fail to unpack in the exporter + for the shape reason above rather than for the real one (there are no samples). + """ + if not isinstance(k, tuple): + return numpy.array([]) + ndim = _block_column(k, sampler.identity_convert(rep_rvs[0][k])).shape[0] + return numpy.empty((ndim, 0)) + + if len(rep_rvs) <= 1: + return rep_rvs[0] if rep_rvs else {} + # `already_resampled` -- the records are FAIRDRAW output. Those samples were already drawn in + # proportion to their own importance weights, so reusing those weights applies them a second + # time and the pooled block follows w^2 instead of w. Renormalizing to Z_k/K fixes the block's + # SCALE but not its SHAPE, so it does not help here. A fairdraw block is an equal-weight draw + # from its own posterior, so that is what it must contribute: constant weights within the + # block, summing to Z_k/K. + # + # DO NOT assume the records are raw importance samples. integrate() may have thresholded or + # fairdraw-resampled _rvs before we see it, in which case sum_i w_ki over the RETAINED rows is + # no longer Z_k * n_k and a 1/n_k rescale would mis-weight the replica (a fairdraw record is + # already posterior-resampled, so scaling it by its retained length weights it twice). When + # the reported per-replica lnZ is available, renormalize each block so it contributes exactly + # Z_k/K -- correct whether the rows are raw, pruned or resampled, since only their RELATIVE + # weights need be right. + keys = set(rep_rvs[0]) + for r in rep_rvs[1:]: + keys &= set(r) + log_key = 'log_joint_s_prior' if 'log_joint_s_prior' in keys else None + lin_key = 'joint_s_prior' if (log_key is None and 'joint_s_prior' in keys) else None + if log_key is None and lin_key is None: + print(" [mc error] pooling skipped: no sampling-prior column in the replica records; " + "exporting the FIRST replica (consistent weights, fewer samples)") + return rep_rvs[0] + K = len(rep_rvs) + out = {} + try: + cols = {k: [] for k in keys} + for _i, r in enumerate(rep_rvs): + n_k = _rvs_len(r) + if n_k <= 0: + continue + _flat_block = False + if _block_resampled(_i) and rep_lnZ is not None and _i < len(rep_lnZ) \ + and numpy.isfinite(rep_lnZ[_i]): + # equal weights within the block, summing to Z_k/K + _flat_block = True + _target_lw = float(rep_lnZ[_i]) - numpy.log(float(K)) - numpy.log(float(n_k)) + scale = 0.0 + elif rep_lnZ is not None and _i < len(rep_lnZ) and numpy.isfinite(rep_lnZ[_i]): + # target: this block's weights sum to Z_k/K + _cur = _lnZ_of_rvs(r, already_pooled=True, use_lnL=_lnL_here, + record=_block_record(_i, r)) + if _cur is None or not numpy.isfinite(_cur): + scale = numpy.log(float(K) * float(n_k)) + else: + scale = _cur - (float(rep_lnZ[_i]) - numpy.log(float(K))) + else: + scale = numpy.log(float(K) * float(n_k)) + if _flat_block and log_key is not None: + # force lw_i = log_integrand + log_joint_prior - log_joint_s_prior == _target_lw + _li = numpy.atleast_1d(numpy.asarray( + sampler.identity_convert(r['log_integrand']), dtype=float)).ravel() + _lp = numpy.atleast_1d(numpy.asarray( + sampler.identity_convert(r['log_joint_prior']), dtype=float)).ravel() + _forced = _li + _lp - _target_lw + for k in keys: + v = _block_column(k, sampler.identity_convert(r[k])) + if _flat_block and log_key is not None and k == log_key: + v = _forced + elif _flat_block and lin_key is not None and k == lin_key: + # Same forcing for a RAW-field record: choose joint_s_prior so the + # reconstructed weight is exactly _target_lw. WHICH equation that is depends + # on the convention 'integrand' is stored in -- the same ambiguity + # ln_weights_from_rvs handles: + # linear: lw = log(ig) + log(jp) - log(js) -> js = ig*jp/exp(target) + # log: lw = ig + log(jp) - log(js) -> js = exp(ig + log(jp) - target) + # Applying the linear form to an lnL record gives js < 0 for every row with + # lnL < 0 -- a NEGATIVE proposal density -- and the block weights it produces + # are not constant at all, which is the entire point of the flat block. Worse, + # it corrupts the canonical columns BEFORE ln_weights_from_rvs ever reads them, + # so fixing the helper alone does not rescue this path. + _ig = numpy.atleast_1d(numpy.asarray( + sampler.identity_convert(r['integrand']), dtype=float)).ravel() + _jp = numpy.atleast_1d(numpy.asarray( + sampler.identity_convert(r['joint_prior']), dtype=float)).ravel() + if _lnL_here: + v = numpy.exp(_ig + numpy.log(_jp) - _target_lw) + else: + v = _ig * _jp / numpy.exp(_target_lw) + elif k == log_key: + v = v + scale + elif k == lin_key: + # The linear counterpart of 'log_joint_s_prior += scale'. This used to be a + # hardcoded K*n_k, which is only the FALLBACK value of `scale` -- so whenever a + # reported per-replica lnZ was available the raw-field path silently skipped + # the renormalization the log path applied, and a pruned or thresholded replica + # was mis-weighted. exp(scale) reduces to K*n_k in the fallback case. + v = v * numpy.exp(scale) + cols[k].append(v) + for k in keys: + # ...and concatenate along THAT row axis: axis 1 for a combined (ndim, N) parameter, + # axis 0 for everything else. One rule, stated in _block_column, applied twice. + out[k] = (numpy.concatenate(cols[k], axis=1 if isinstance(k, tuple) else 0) + if cols[k] else _empty_column(k)) + # CACHED WEIGHTS MUST FOLLOW THE COMPONENTS. _rvs may carry a precomputed 'log_weights' + # (mcsamplerPortfolio writes one), and the .dgrid and calibration-posterior exporters + # PREFER it -- they only fall back to log_integrand + log_joint_prior - log_joint_s_prior + # when it is absent. Concatenating the per-replica caches unchanged would hand those + # scientific outputs the ORIGINAL weights while the estimate used the corrected ones: + # replica rebalancing ignored, and fairdraw blocks double-weighted again in exactly the + # products this pooling exists to make consistent. Recompute from the canonical columns. + # Rebuild through the ONE canonical definition rather than a second inline copy of it: + # the copy that used to live here carried the same linear-only assumption as the helper's + # old second branch, so under the log convention it re-logged lnL and cut on its sign -- + # writing exactly the flattened weights the exporters prefer. + try: + _lw_pooled = ln_weights_from_rvs(out, use_lnL=_lnL_here) + except Exception: + _lw_pooled = None + if _lw_pooled is not None: + if 'log_weights' in out: + out['log_weights'] = _lw_pooled + if 'weights' in out: + out['weights'] = numpy.exp(_lw_pooled - numpy.max(_lw_pooled[numpy.isfinite(_lw_pooled)])) + elif 'log_weights' in out or 'weights' in out: + # cannot rebuild them -> DROP, so consumers fall through to whatever components exist + # rather than silently trusting a stale cache. + out.pop('log_weights', None) + out.pop('weights', None) + print(" [mc error] pooled record: dropped stale cached weights (components unavailable" + " to rebuild them); consumers will reconstruct from what remains") + except Exception as e: + print(" [mc error] pooling failed ({}); exporting the FIRST replica".format(e)) + return rep_rvs[0] + return out + + +def _export_rvs_equal_weight(rvs, sampler, use_lnL=None): + """The version of a POOLED record that may be written to the SimInspiral XML. + + THE XML KEEPS NO WEIGHT THIS DRIVER WRITES. xmlutils maps 'joint_prior'/'joint_s_prior' + onto alpha2/alpha3 -- which the ILE export below overwrites with zeros -- and the + log_joint_* columns the pool actually carries have no mapping at all. So every exported + row is read downstream with the same weight, whatever the record says. + + For an ordinary run that is the long-standing convention (the export is a fair draw, or is + treated as one). For a POOLED record it is wrong in a NEW way: _pool_replica_rvs gives + block k weights summing to Z_k/K, deliberately unequal BETWEEN blocks, so equal-weight rows + mix the replicas by ROW COUNT instead of by evidence -- silently discarding exactly the + disagreement the replicas were run to measure, in the one output a human looks at. + + So convert here rather than hope: draw rows in proportion to the pool's reconstructed + posterior weights, turning weights the format drops into row multiplicities it keeps. + + Returns its argument UNCHANGED for any record that is not a pooled mixture (every + non-replica run is untouched), and on any failure to rebuild or apply the weights -- with a + message, because a weighted export is a real defect, not a silent degradation. + """ + if not bool(getattr(sampler, '_rvs_is_pooled', False)): + return rvs + _conv = getattr(sampler, 'identity_convert', None) + n = _rvs_len(rvs) + if n <= 1: + return rvs + + def _bail(why): + print(" [mc error] pooled export left AS-IS ({}); the XML preserves no weight column," + " so its consumers will mix the replicas by row count".format(why)) + return rvs + + try: + # ln_weights_for_posterior, not ln_weights_from_rvs: it is the "how should these rows be + # weighted as a posterior" question. The pooled marker is what makes it answer with the + # reconstructed per-row weights instead of zeros -- a flat block contributes constant + # weights summing to Z_k/K (already an equal-weight draw, correctly scaled), a raw block + # its genuine importance weights, likewise scaled. Resolve the stored convention here: + # the helper deliberately passes use_lnL through unresolved, as the main driver's does. + lw = numpy.asarray(ln_weights_for_posterior(rvs, sampler, convert=_conv, + use_lnL=_rvs_lnL_convention(use_lnL)), + dtype=float).ravel() + except Exception as e: + return _bail(e) + if lw.size != n: + return _bail("weights are {} long for {} rows".format(lw.size, n)) + _ok = numpy.isfinite(lw) + if not numpy.any(_ok): + return _bail("no finite weights") + w = numpy.zeros(n, dtype=float) + w[_ok] = numpy.exp(lw[_ok] - numpy.max(lw[_ok])) + _tot = float(numpy.sum(w)) + if not numpy.isfinite(_tot) or _tot <= 0: + return _bail("weights do not sum to anything usable") + w = w / _tot + # HOW MANY ROWS. The Kish n_eff of the pooled weights, capped at the rows available: the + # honest count, and the same quantity the samplers' own fair draw caps on. Drawing the full + # K*n_k rows instead would report K times the independent information whenever one replica + # dominates -- the case pooling exists to expose. + n_out = int(min(n, max(1, int(round(1.0 / float(numpy.sum(w ** 2))))))) + # SYSTEMATIC resampling, not multinomial: one uniform offset, then n_out equally spaced + # positions through the cumulative weight. Unbiased in the same way, but each block gets its + # evidence share of the rows deterministically rather than with O(sqrt(n)) draw noise on top + # of the replica scatter being measured -- and when the replicas agree (equal weights, + # n_out == n) it returns every row exactly once, where a bootstrap would duplicate ~37% of + # them for nothing. + cdf = numpy.cumsum(w) + cdf[-1] = 1.0 + pos = (numpy.random.uniform() + numpy.arange(n_out)) / float(n_out) + idx = numpy.clip(numpy.searchsorted(cdf, pos, side='left'), 0, n - 1) + out = {} + for k, v in rvs.items(): + try: + arr = numpy.asarray(_conv(v) if _conv is not None else v) + except Exception as e: + return _bail("column {!r}: {}".format(k, e)) + # Index the LAST axis: _rvs may hold tuple-keyed pairs stored as (2, n). + if arr.ndim < 1 or arr.shape[-1] != n: + return _bail("column {!r} is not row-shaped".format(k)) + out[k] = arr[..., idx] + print(" [mc error] pooled export re-drawn to equal weight: {} rows -> {} (weights the XML" + " cannot carry are now row multiplicities)".format(n, n_out)) + return out + + +def _reject_if_collapsed(dd, stage): + """Apply --reject-collapsed-live-volume to whatever the CURRENT verdict is. + + Called TWICE, in both drivers: once on the first run and again on the replica POOL, + because replication can turn a healthy first run into a collapsed pool and gating only + the first would bypass the flag for exactly the case pooling introduces. Both calls live + in _maybe_replicate_for_mc_error, which is why analyze_event must not gate directly. + """ + if not opts.reject_collapsed_live_volume: + return + if not (isinstance(dd, dict) and dd.get('live_volume_collapsed', False)): + return + # Route through the ordinary failure path, so the caller skips this binary and writes no + # result row -- the pre-fix outcome, but for a stated reason. + _exc = mcsamplerAdaptiveVolume.LiveVolumeCollapse if mcsampler_AV_ok else RuntimeError + raise _exc( + "extrinsic integration collapsed ({}): live volume degenerated ({}); " + "--reject-collapsed-live-volume is set, so this event is being dropped " + "rather than exported".format(stage, dd.get('collapse_reason', ''))) + + +def _report_and_gate_collapse(dict_return, stage="first run"): + """Announce a collapsed live volume, then apply the rejection gate.""" + _collapsed = bool(dict_return.get('live_volume_collapsed', False)) if isinstance(dict_return, dict) else False + _collapse_reason = (dict_return or {}).get('collapse_reason', '') if isinstance(dict_return, dict) else '' + if _collapsed: + print(" [mc error] *** LIVE VOLUME COLLAPSED *** {}".format(_collapse_reason)) + print(" [mc error] this event's lnZ and exported samples are NOT a fair draw from the posterior.") + _reject_if_collapsed(dict_return, stage) + + +def _maybe_replicate_for_mc_error(sampler, res, var, neff, dict_return, + log_res, sqrt_var_over_res, + like_to_integrate, unpinned_params, pinned_params, + lnL_offset=0.0): + """MC-error replication + replica pooling. + + Returns (res, var, neff, log_res, sqrt_var_over_res, dict_return); returns them unchanged + when nothing triggers, so the call site is one unconditional assignment. + + A module-level helper rather than inline (as in the main driver) because THIS DRIVER HAS + TWO analyze_event variants -- inlining ~200 lines twice would be a third copy to keep in + step. Same reason as _maybe_l0_rescue. + + IT OWNS BOTH COLLAPSE-GATE CALLS. The main driver gates once on the first run and again + on the POOLED verdict, because replication can turn a healthy first run into a collapsed + pool; gating only the first silently bypasses --reject-collapsed-live-volume for exactly + the case pooling introduces. Callers must therefore NOT call _report_and_gate_collapse + themselves -- this does it. + + `lnL_offset` is the event's lnL_offset, used only for printing + absolute lnZ. + """ + _khat, _sig_block, _n_ess, _ci90 = _extract_mc_diag(dict_return) + if _sig_block is not None and numpy.isfinite(_sig_block) and _sig_block > sqrt_var_over_res: + print(" [mc error] sigma_lnZ raised to the between-chunk scatter: {:.4f} -> {:.4f}".format(float(sqrt_var_over_res), float(_sig_block))) + sqrt_var_over_res = float(_sig_block) + if _khat is not None: + print(" [mc error] Pareto k-hat = {:.3f}{}".format(float(_khat), " (> {:.2f}: weight tail unresolved; the reported sigma is a LOWER BOUND)".format(opts.mc_error_khat_trigger) if _khat > opts.mc_error_khat_trigger else "")) + if _ci90 is not None: + print(" [mc error] bootstrap lnZ 5/50/95 quantiles: {}".format(numpy.array2string(numpy.asarray(_ci90) + lnL_offset, precision=4))) + + # First-run collapse report AND gate (see the docstring: the pooled gate is below). + _report_and_gate_collapse(dict_return, "first run") + + # AV live-volume state is persisted HERE, and the position is doubly constrained: + # * AFTER the first-run gate, so a collapsed grid that --reject-collapsed-live-volume + # rejects is never written. Otherwise the event is correctly dropped while the NEXT + # intrinsic point warm-starts from the degenerate volume via --sampler-load-state, + # biased toward the surviving mode with nothing flagged. + # * BEFORE the replica loop, because afterwards the sampler holds the LAST replica's + # adapted grid rather than the run being reported. + # The main driver satisfies only the second: it saves ~80 lines above its own first-run + # gate, so a collapsed grid CAN be persisted there. Deliberate divergence, and the main + # driver should take the same reordering -- flagged rather than changed here. + _maybe_save_av_state(sampler) + _collapsed = bool(dict_return.get('live_volume_collapsed', False)) if isinstance(dict_return, dict) else False + _collapse_reason = (dict_return or {}).get('collapse_reason', '') if isinstance(dict_return, dict) else '' + + _trigger_reasons = [] + if _collapsed and opts.mc_error_replicas > 0: + _trigger_reasons.append('live volume collapsed ({})'.format(_collapse_reason)) + if opts.mc_error_replicas > 0: + _neff_target = pinned_params.get('neff', None) + if sqrt_var_over_res > opts.mc_error_sigma_trigger: + _trigger_reasons.append('sigma={:.3f}>{:.2f}'.format(float(sqrt_var_over_res), opts.mc_error_sigma_trigger)) + if _khat is not None and _khat > opts.mc_error_khat_trigger: + _trigger_reasons.append('khat={:.2f}>{:.2f}'.format(float(_khat), opts.mc_error_khat_trigger)) + if _n_ess is not None and _n_ess < opts.mc_error_ess_trigger: + _trigger_reasons.append('ESS={:.1f}<{:g}'.format(float(_n_ess), opts.mc_error_ess_trigger)) + if _neff_target is not None and float(neff) < float(_neff_target): + _trigger_reasons.append('neff={:.1f} pooled weight w_ki / (K n_k) + # which is exactly the importance weight against the POOLED proposal density + # q'_ki = q_ki * K * n_k (pick a replica uniformly, then draw one of its n_k samples). + # Folding the factor into log_joint_s_prior is therefore a statement of the real pooled + # sampling density, not a fudge -- and it leaves every downstream weight computation + # (which all form log_integrand + log_joint_prior - log_joint_s_prior) correct untouched. + _pooled_rvs = _pool_replica_rvs(_rep_rvs, sampler, rep_lnZ=_rep_lnZ, + already_resampled=_rep_fairdraw, + use_lnL=rvs_integrand_is_lnL) + # A POOLED RECORD IS NOT A FAIR DRAW, even when every block that went into it was. + # _pool_replica_rvs gives block k weights summing to Z_k/K: equal WITHIN a block (each + # block really is an equal-weight draw from its own posterior) but differing BETWEEN + # blocks by exactly the replica evidences. Leaving the marker set would make + # ln_weights_for_posterior return zeros, and .dgrid and the proposal breadcrumb would + # then mix the replicas by exported ROW COUNT instead of by evidence -- silently + # discarding the disagreement the replicas were run to measure. The reconstructed + # per-row weights already encode it, so clear the marker and let them be read. + # + # Only when it actually pooled: every fallback path in _pool_replica_rvs returns one of + # its INPUT records unchanged (too few replicas, no sampling-prior column, an exception), + # and such a record is still the fair draw it arrived as. Identity, not length, is the + # reliable test for that. + _did_pool = not any(_pooled_rvs is _r for _r in _rep_rvs) + if _did_pool: + # POOLED, not equal-weight. The rows are still posterior-resampled wherever their + # block was (so _rvs_is_fairdraw stays, and the .dslice safeguard keeps firing), + # but the record as a whole is a mixture weighted by the replica evidences, so + # ln_weights_for_posterior must read the reconstructed per-row weights. + sampler._rvs_is_pooled = True + sampler._rvs_is_fairdraw = any(_rep_fairdraw) + # DELIBERATELY record-less, and this line is why it is deliberate. The main + # driver builds an _RvsRecord.pooled() here from per-replica records; the LISA + # replica path does not collect them, so there is nothing honest to publish and + # the weight route falls back to the flags above. That fallback is correct -- + # but WITHOUT this line it would be correct only by accident: the record left on + # the sampler describes the pre-pool columns, and it is declined solely because + # `sampler._rvs` is about to become a different dict and _rvs_record_for compares + # by IDENTITY. Anything that later made the pooled dict reuse an input dict, or + # added a samples() consumer here, would silently start reading a per-pass record + # as if it described the mixture. Clear it, so the absence is a statement. + if _sampler_keeps_records(sampler): + sampler.set_samples(None) + # Did pooling FLATTEN any block? That, not "is the record resampled", is what makes + # the pooled Kish n_eff meaningless below -- a flattened block's rows carry its export + # size rather than its integration quality. + _blocks_flattened = bool(_did_pool and any(_rep_fairdraw)) + sampler._rvs = _pooled_rvs + # The pooled export is a mixture over every replica in _rep_rvs, so its collapse + # status is the OR over them: one collapsed member taints the pool. Fold that back + # into dict_return, which is what the status sidecar and the downstream reporting + # read -- otherwise a healthy first run followed by a collapsed replica would export + # the pooled posterior while recording "collapsed": false. + if isinstance(dict_return, dict): + _any_collapsed = any(_rep_collapsed) + _why = [w for w in _rep_collapse_why if w] + dict_return['live_volume_collapsed'] = bool(_any_collapsed) + dict_return['n_replicas_pooled'] = int(len(_rep_lnZ)) + dict_return['n_replicas_collapsed'] = int(sum(1 for c in _rep_collapsed if c)) + if _any_collapsed: + dict_return['collapse_reason'] = "; ".join(_why) if _why else "a pooled replica collapsed" + print(" [mc error] *** LIVE VOLUME COLLAPSED in {} of {} pooled replicas ***".format( + dict_return['n_replicas_collapsed'], dict_return['n_replicas_pooled'])) + print(" [mc error] {}".format(dict_return['collapse_reason'])) + print(" [mc error] the POOLED posterior therefore contains degenerate samples.") + # Re-apply the rejection gate to the POOLED verdict. The early call above saw only + # the first run, so without this a healthy first run followed by a collapsed replica + # would export the pooled, collapsed result with --reject-collapsed-live-volume set. + _reject_if_collapsed(dict_return, "pooled over {} replicas".format(len(_rep_lnZ))) + if len(_rep_lnZ) > 1: + _K = len(_rep_lnZ) + _l = numpy.array(_rep_lnZ); _s = numpy.array(_rep_sig) + _lref = numpy.max(_l) + _Z = numpy.exp(_l - _lref) + _Zbar = numpy.mean(_Z) + _lnZ_comb = numpy.log(_Zbar) + _lref # linear mean over replicas: unbiased in Z + _sig_prop = float(numpy.sqrt(numpy.sum((_s*_Z)**2))/(_K*_Zbar)) + _sig_scatter = float(numpy.std(_l, ddof=1)/numpy.sqrt(_K)) # t_{K-1}: small-K quantiles are wider than Gaussian, hence the max() below + _sig_comb = max(_sig_prop, _sig_scatter) + print(" [mc error] combined {} replicas: lnZ {} -> {:.4f} (shift {:+.3f} vs first); sigma propagated {:.3f} / scatter {:.3f} -> {:.3f}; neff {} -> {:.1f}".format( + _K, numpy.array2string(_l + lnL_offset, precision=3), float(_lnZ_comb + lnL_offset), float(_lnZ_comb - _rep_lnZ[0]), + _sig_prop, _sig_scatter, _sig_comb, numpy.array2string(numpy.asarray(_rep_neff), precision=1), float(numpy.sum(_rep_neff)))) + log_res = float(_lnZ_comb) + sqrt_var_over_res = _sig_comb + # Report the POOLED n_eff, not the sum. The sum claims the posterior carries the + # combined effective sample size of K independent runs, which is only true if they + # agree; when they disagree -- the case these replicas exist to detect -- the pooled + # Kish n_eff is smaller, and that disagreement is exactly what should show up here. + # + # ...but NOT the Kish n_eff OF THE POOLED RECORD when that record is the fair-draw + # export. _pool_replica_rvs deliberately FLATTENS each block in that case (equal + # weights within a block, summing to Z_k/K), and the Kish n_eff of piecewise-constant + # weights is just the row count -- i.e. K*min(n_max, 1.5*eff_samp, 1.5*neff), the size + # of the EXPORT, which says nothing about how well the integral converged. NOTE this + # driver has no --fairdraw-extrinsic-output-n-max: it caps the export at opts.n_eff + # (igrand_fairdraw_samples_max), so the bogus figure would be K*n_eff, not 5K. + # + # Do the same computation one level up, where the quantities are still meaningful: + # Kish over the BLOCKS, each carrying its own Z_k and its own n_eff, + # + # neff_pooled = (sum_k Z_k)^2 / sum_k (Z_k^2 / neff_k) + # + # which has exactly the property the paragraph above asks for: it reduces to + # sum_k neff_k when the replicas agree, and falls below it when they disagree -- + # the disagreement these replicas exist to detect. + if _blocks_flattened: + _l_rel = numpy.asarray(_rep_lnZ, dtype=float) - float(numpy.max(_rep_lnZ)) + _Zk = numpy.exp(_l_rel) + _nk = numpy.asarray(_rep_neff, dtype=float) + _ok = numpy.isfinite(_Zk) & numpy.isfinite(_nk) & (_nk > 0) + _neff_pooled = (float(numpy.sum(_Zk[_ok]) ** 2 / numpy.sum(_Zk[_ok] ** 2 / _nk[_ok])) + if numpy.any(_ok) else None) + _neff_how = 'block Kish over replicas (the export is fair-drawn)' + else: + _neff_pooled = _kish_neff_of_rvs(sampler._rvs) + _neff_how = 'Kish over the pooled samples' + neff = float(_neff_pooled) if _neff_pooled is not None else float(numpy.sum(_rep_neff)) + if _neff_pooled is not None: + print(" [mc error] pooled posterior: {} samples, n_eff {:.1f} via {} (sum over replicas was {:.1f})".format( + len(numpy.atleast_1d(list(sampler._rvs.values())[0])) if sampler._rvs else 0, + float(_neff_pooled), _neff_how, float(numpy.sum(_rep_neff)))) + # keep the (res, var) pair consistent for any downstream reader + if not(opts.internal_use_lnL): + res = numpy.exp(log_res); var = (sqrt_var_over_res*res)**2 + else: + res = log_res; var = 2*numpy.log(sqrt_var_over_res) + 2*log_res + return res, var, neff, log_res, sqrt_var_over_res, dict_return + + +def _maybe_l0_rescue(sampler, res, var, neff, dict_return, + like_to_integrate, unpinned_params, pinned_params, + lnL_offset=0.0): + """Run the L0 auto-rescue if this pass stalled -> (res, var, neff, dict_return). + + Returns its arguments unchanged when the rescue does not apply, so the call site is a + single unconditional assignment. + + MUST BE CALLED BEFORE the `if not(res): raise` guard. A degenerate early termination + (mcsamplerPortfolio/AV returning (None,None,None,None) when the live volume never found + finite in-volume samples) is the STRONGEST rescue trigger, not a reason to skip -- such a + pass still populated _rvs, so the peak seed is available. In the main driver that guard + sits ~200 lines further down and the ordering is implicit; here it is immediately after + integrate, so the ordering is stated and pinned by a test. + + `lnL_offset` is this event's manual_avoid_overflow_logarithm, used only to print absolute + lnZ values. It is a local of the caller in both analyze_event variants, hence a parameter. + """ + # Reset per event before ANY early return. The sampler is reused: a rejected rescue on + # one event must not suppress saving a later healthy event that needs no rescue at all. + sampler._av_state_reuse_safe = True + # APPLICABILITY FIRST, then n_eff. The main driver evaluates + # _neff_val = None if neff is None else float(sampler.identity_convert(neff)) + # BEFORE its guard, which is safe there only by luck: identity_convert comes from + # MCSamplerGeneric, and RIFT.integrators.mcsampler.MCSampler -- the object this driver + # keeps for --sampler-method adaptive_cartesian -- does NOT inherit it. Evaluating it + # unconditionally therefore raises AttributeError on EVERY adaptive_cartesian event, at + # the end of a completed integration and before --output-file is written, losing the + # whole point's compute. The rescue is AV/portfolio-only regardless, so nothing is lost + # by asking whether it applies before touching the sampler's conversion helpers. + # + # DELIBERATE DIVERGENCE from the main driver, which has the same latent defect on the + # line above its own guard and should take the same reordering. + if not (opts.sampler_method in ('AV', 'portfolio') and opts.sampler_warmstart_retry_neff + and hasattr(sampler, 'bootstrap_from_samples')): + return res, var, neff, dict_return + # A DEGENERATE EARLY TERMINATION (neff None) counts as below threshold, not as "skip". + _neff_val = None if neff is None else float(sampler.identity_convert(neff)) + _needs_l0_rescue = (_neff_val is None) or (_neff_val < float(opts.sampler_warmstart_retry_neff or 0)) + if not _needs_l0_rescue: + return res, var, neff, dict_return + + # Cold state to fall back on, captured only once the warm pass is actually about to run. + # `None` means nothing has been disturbed yet, so the handler must not "restore". + _cold_state_l0 = None + try: + # SEED FROM THE POINTS THE PASS RETAINED, not from what survived the fair draw. + # sampler._rvs has by now been REBOUND to a fair-draw subset taken WITH REPLACEMENT -- + # a resample built for EXPORT. On the collapsed pass this rescue exists for the + # effective sample size is ~1, so _rvs can be a single row, or a handful several of + # which are the same point twice. The live set held a thousand. + _res_l0 = _warm_seed_reserve_for(sampler) + if _res_l0 is not None: + _cols = np.asarray(_res_l0['X'], dtype=float) + _lnv = np.asarray(_res_l0['lnL'], dtype=float).ravel() + print(" [L0 auto-rescue] seeding from {} retained sample(s) of {} (fair draw left {} in _rvs)".format( + len(_lnv), _res_l0.get('n_retained', '?'), + len(np.asarray(sampler.identity_convert(sampler._rvs['log_integrand'])).ravel()) + if 'log_integrand' in sampler._rvs else '?')) + else: + _lnkey = 'log_integrand' if 'log_integrand' in sampler._rvs else ('integrand' if 'integrand' in sampler._rvs else None) + _lnv = np.asarray(sampler.identity_convert(sampler._rvs[_lnkey]), dtype=float).ravel() if _lnkey else np.array([]) + _cols = (np.vstack([np.asarray(sampler.identity_convert(sampler._rvs[p]), dtype=float).ravel() + for p in sampler.params_ordered]).T if _lnv.size else np.zeros((0, len(sampler.params_ordered)))) + if _lnv.size >= 1 and np.any(np.isfinite(_lnv)): + # RANK, not count, decides whether this seed can define a live volume. A 2-to-5 + # point seed passes a count test and is still rank-deficient in 6 adaptive + # dimensions, so the warm start contracts onto a degenerate subspace and reports a + # healthy n_eff over a sliver of the support. build_warm_seed applies the rank + # test through the SAME seed_affine_rank the grid builder uses, and puffs to full + # rank when it is short. + _ax_l0, _lo_l0, _hi_l0 = _warm_seed_geometry(sampler) + _seed, _seed_info = mcsamplerAdaptiveVolume.build_warm_seed( + _cols, _lnv, _lo_l0, _hi_l0, _ax_l0, + deltalnL=opts.sampler_sequential_warmstart_deltalnL, + puff_scale=opts.sampler_l0_rescue_puff_scale, + puff_width_frac=opts.sampler_l0_rescue_puff_width_frac, + puff_factor=opts.sampler_l0_rescue_puff_factor) + print(" [L0 auto-rescue] cold n_eff {} < {}; re-running warm from this point's peak ({} pts)".format( + "DEGENERATE (early termination)" if _neff_val is None else "{:.1f}".format(_neff_val), + opts.sampler_warmstart_retry_neff, len(_seed))) + if _seed_info['puffed']: + print(" [L0 auto-rescue] seed of {} point(s) had affine rank {}/{}: PUFFED to rank" + " {}/{} with {} points ({} scale, x{:g}), keeping the original point(s)".format( + _seed_info['n_core'], _seed_info['rank_core'], _seed_info['dim'], + _seed_info['rank_final'], _seed_info['dim'], _seed_info['n_puff'], + _seed_info['puff_scale'], opts.sampler_l0_rescue_puff_factor)) + if _seed_info['rank_final'] < _seed_info['dim']: + print(" [L0 auto-rescue] *** the puffed seed is STILL rank-deficient" + " ({}/{}); the warm pass will be reported as collapsed.".format( + _seed_info['rank_final'], _seed_info['dim'])) + # The warm pass is an estimate over TRUNCATED support: the seeded box provably + # contains the peak the cold pass found, and says nothing about what that pass did + # not reach, so it is biased low by any missed mode. The rescue still runs, because + # it exists to fix the high-SNR n_eff lottery and removing it by default would be a + # certain production regression traded against a possible bias. What the gate below + # changes is only the case where there is POSITIVE EVIDENCE of lost mass. + # + # SNAPSHOT, not an alias: integrate_log repopulates sampler._rvs IN PLACE, so + # `_cold_rvs = sampler._rvs` would be holding the warm samples by the time the + # restore ran -- i.e. the reject path would report the cold lnZ while exporting the + # warm cloud, exactly what it exists to prevent. + _cold_rvs = dict(sampler._rvs) + # Snapshot the RESERVE for the same reason and at the same moment: the warm pass's + # integrate_log clears and rewrites it, so reading it after the fact would compare + # the warm pass against itself. + _cold_reserve_l0 = getattr(sampler, '_warm_seed_reserve', None) + _cold_lnZ, _cold_src = _lnZ_of_reserve_or_rvs(sampler, _cold_rvs, + reserve=_cold_reserve_l0) + # dict_return too: khat, block scatter, ESS and the confidence interval all read it, + # so keeping the warm pass's diagnostics beside a restored cold result would describe + # a run we did not report. And the reserve and the fair-draw marker, one level out. + _cold_state_l0 = _snapshot_pass_state(sampler, res, var, neff, dict_return, + rvs=_cold_rvs) + sampler.bootstrap_from_samples(_seed, cover_frac=0.0) + res, var, neff, dict_return = sampler.integrate(like_to_integrate, *unpinned_params, **pinned_params) + _warm_lnZ, _warm_src = _lnZ_of_reserve_or_rvs(sampler, sampler._rvs) + # BOTH SIDES FROM THE SAME READING, or the difference is not a difference. A + # fair-drawn lnZ sits ~log(n_retained/eff_samp) above a retained-set one, so a mixed + # comparison manufactures a gap of several nats in whichever direction the mismatch + # happens to fall. If the two passes did not produce the same kind of estimate, read + # BOTH from _rvs -- the old behaviour, at least self-consistent. + if _cold_src != _warm_src: + print(" [L0 auto-rescue] lnZ provenance differs (cold={}, warm={});" + " re-reading both from the fair-draw record so the comparison is" + " like-for-like.".format(_cold_src, _warm_src)) + _cold_lnZ = _lnZ_of_rvs(_cold_rvs, already_pooled=False) + _warm_lnZ = _lnZ_of_rvs(sampler._rvs, already_pooled=False) + _cold_src = _warm_src = 'fairdraw' + _evidence_of_loss = ( + (_cold_lnZ is not None) and (_warm_lnZ is not None) + and numpy.isfinite(_cold_lnZ) and numpy.isfinite(_warm_lnZ) + and (_cold_lnZ - _warm_lnZ) > float(opts.sampler_l0_rescue_reject_dlnZ)) + if _evidence_of_loss: + print(" [L0 auto-rescue] *** REJECTING the warm pass *** its lnZ {:.3f} is" + " {:.3f} nats BELOW the full-support cold pass ({:.3f}), which is evidence" + " the seed missed mass the cold pass reached.".format( + _warm_lnZ + lnL_offset, _cold_lnZ - _warm_lnZ, _cold_lnZ + lnL_offset)) + if opts.sampler_l0_rescue_accept_truncated: + print(" [L0 auto-rescue] --sampler-l0-rescue-accept-truncated set:" + " reporting the warm pass anyway (may be biased LOW).") + else: + print(" [L0 auto-rescue] keeping the COLD (full-support) result; its n_eff" + " is lower but it is not missing mass. A portfolio avoids this" + " trade entirely -- its GMM member carries a defensive component.") + # The RESERVE goes back too. Once --sampler-sequential-warmstart is + # ported here, omitting this would seed the next intrinsic point from the + # warm cloud this gate just rejected: _warm_seed_reserve_for would return + # the warm pass's record while _rvs, the estimate and the diagnostics all + # describe the cold one. Nothing reads it in this driver today. + res, var, neff, dict_return = _restore_pass_state(sampler, _cold_state_l0) + sampler._av_state_reuse_safe = False + _clear_warm_state(sampler) + except Exception as _e_l0: + # "skipped" is only true if the warm pass never started. If it raised PARTWAY THROUGH + # sampler.integrate(), the assignment never completed, so res/var/neff/dict_return still + # hold the COLD pass -- while sampler._rvs was repopulated in place and now holds the + # WARM samples. Reporting cold k-hat / ESS / lnZ beside a warm export describes a run + # that was never made, and it did so silently for a whole campaign. + print(" [L0 auto-rescue] *** FAILED *** (", _e_l0, ")") + import traceback as _tb_l0 + _tb_l0.print_exc() + if _cold_state_l0 is not None: + print(" [L0 auto-rescue] the warm pass may already have replaced the stored" + " samples; restoring the COLD pass so the reported diagnostics and the" + " exported samples describe the same integral.") + res, var, neff, dict_return = _restore_pass_state(sampler, _cold_state_l0) + sampler._av_state_reuse_safe = False + _clear_warm_state(sampler) + return res, var, neff, dict_return + + def resample_samples_LISA(my_samples, rholms, cross_terms, right_ascension, declination, P, modes, reference_distance): """This function takes in extrinsic samples and for each sample samples a time shift. This is done by generating a likelihood time series at an extrinsic sample and then weighted sampling in time.""" # How many time samples? Same as the extrinsic samples being passed @@ -1415,6 +2856,20 @@ def resample_samples(my_samples, def analyze_event_LISA(P_list, indx_event, data_dict, psd_dict, fmax, opts, inv_spec_trunc_Q=inv_spec_trunc_Q, T_spec=T_spec): print("\n###########################################################################################\nPrecomputing\n###########################################################################################") nEvals=0 + # PROVENANCE RESET, ON ENTRY, BEFORE ANYTHING CAN FAIL. + # + # _rvs_is_pooled describes the record this call is about to build, and it is set by THIS + # function (the replica block) rather than by a sampler, so no sampler-side per-pass reset + # can clear it. Clearing it only on the normal return is not enough: _reject_if_collapsed + # RAISES after pooling, the caller's `except Exception` swallows that and moves to the next + # event, and the marker survives. The next ordinary fair draw is then read as "pooled", + # _rvs_is_equal_weight goes False, and any consumer that weights rows applies importance + # weights to rows that already carry them -- the w^2 defect, resurrected on the event after + # any failure. + # + # On ENTRY rather than in a `finally`: entry is reached on every call by construction, and + # it leaves the state correct even for a caller that never returns normally at all. + sampler._rvs_is_pooled = False P = P_list[indx_event] # if pin-distance-to-sim, change the distance prior accordingly if opts.pin_distance_to_sim: @@ -1487,7 +2942,7 @@ def analyze_event_LISA(P_list, indx_event, data_dict, psd_dict, fmax, opts, inv_ if 'distance' in sampler.params: sampler.reset_sampling('distance') sampler.reset_sampling('inclination') - elif opts.sampler_method == "GMM": + elif use_gmm_args: # standalone GMM or a portfolio with a GMM member (gmm_dict exists in both) if 'distance' in sampler.params: pair_d_incl = sampler_param_tuple(sampler, ['distance','inclination']) if pair_d_incl in gmm_dict: @@ -1517,8 +2972,21 @@ def analyze_event_LISA(P_list, indx_event, data_dict, psd_dict, fmax, opts, inv_ lnL_oracles = np.zeros(opts.n_chunk) sampler.update_sampling_prior(lnL_oracles, opts.n_chunk, external_rvs=rvs_train,log_scale_weights=True,floor_integrated_probability=opts.adapt_floor_level) + _maybe_load_av_state(sampler) + _maybe_enable_anisotropic_bins(sampler) res, var, neff, dict_return = sampler.integrate(like_to_integrate, *unpinned_params, **pinned_params) + # L0 auto-rescue: on a very sharply-peaked (high-amplitude) point a cold AV can stall at + # n_eff ~ 1 because it never draws near the tiny peak. If so, seed a SECOND pass from this + # same point's own highest-likelihood samples and re-run. Opt-in via + # --sampler-warmstart-retry-neff. MUST run BEFORE the not(res) guard below: a degenerate + # early termination returns (None,None,None,None) and is the strongest rescue trigger, so + # raising on it first would skip exactly the case the rescue exists for. + res, var, neff, dict_return = _maybe_l0_rescue( + sampler, res, var, neff, dict_return, + like_to_integrate, unpinned_params, pinned_params, + lnL_offset=manual_avoid_overflow_logarithm) + if not(res): # no resut raise ValueError(" No integral result returned") @@ -1529,6 +2997,16 @@ def analyze_event_LISA(P_list, indx_event, data_dict, psd_dict, fmax, opts, inv_ log_res = res sqrt_var_over_res = numpy.exp(var/2 - log_res) + # MC-error diagnostics, the collapse report/gate, and replica replication+pooling. This + # OWNS both collapse-gate calls (first run and pooled verdict) -- do not add a separate + # _report_and_gate_collapse call here, or the pooled one gets bypassed. It needs + # log_res/sqrt_var_over_res, hence its position after they are computed; the main driver + # has the same ordering inline. + res, var, neff, log_res, sqrt_var_over_res, dict_return = _maybe_replicate_for_mc_error( + sampler, res, var, neff, dict_return, log_res, sqrt_var_over_res, + like_to_integrate, unpinned_params, pinned_params, + lnL_offset=manual_avoid_overflow_logarithm) + # Report results if opts.output_file: fname_output_txt = opts.output_file +"_"+str(indx_event)+"_" + ".dat" @@ -1566,6 +3044,11 @@ def analyze_event_LISA(P_list, indx_event, data_dict, psd_dict, fmax, opts, inv_ if opts.save_samples and opts.output_file: import copy samples = copy.deepcopy(sampler._rvs) # deep copy: avoid modifying structures and having side effect on integrator, which loops over keys Expensive! + # A POOLED record (--mc-error-replicas) is weighted BETWEEN blocks by the replica + # evidences, and nothing below preserves those weights: convert it to an equal-weight + # draw BEFORE anything consumes it -- including resample_samples_LISA, which picks a time + # per row and so assumes the rows already are the posterior. A no-op otherwise. + samples = _export_rvs_equal_weight(samples, sampler, use_lnL=rvs_integrand_is_lnL) # Insert reference distance if it was marginalized over if "distance" not in samples: # Not distance output is the same as internal calculations: in *Mpc* @@ -1583,7 +3066,7 @@ def analyze_event_LISA(P_list, indx_event, data_dict, psd_dict, fmax, opts, inv_ xmldoc = ligolw.Document() xmldoc.appendChild(ligolw.LIGO_LW()) process.register_to_xmldoc(xmldoc, sys.argv[0], opts.__dict__) - if not(opts.resample_time_marginalization): + if not(opts.resample_time_marginalization): if not opts.time_marginalization: samples["t_ref"] += float(fiducial_epoch) else: @@ -1601,7 +3084,7 @@ def analyze_event_LISA(P_list, indx_event, data_dict, psd_dict, fmax, opts, inv_ samples['psi']= psi_true samples['phi_orb'] = phi_orb_true samples["polarization"] = samples["psi"] - samples["coa_phase"] = samples["phi_orb"] + samples["coa_phase"] = samples["phi_orb"] if ("declination", "right_ascension") in sampler.params: samples["latitude"], samples["longitude"] = samples[("declination", "right_ascension")] else: @@ -1754,6 +3237,20 @@ def analyze_event_LISA(P_list, indx_event, data_dict, psd_dict, fmax, opts, inv_ def analyze_event(P_list, indx_event, data_dict, psd_dict, fmax, opts, inv_spec_trunc_Q=inv_spec_trunc_Q, T_spec=T_spec): nEvals=0 + # PROVENANCE RESET, ON ENTRY, BEFORE ANYTHING CAN FAIL. + # + # _rvs_is_pooled describes the record this call is about to build, and it is set by THIS + # function (the replica block) rather than by a sampler, so no sampler-side per-pass reset + # can clear it. Clearing it only on the normal return is not enough: _reject_if_collapsed + # RAISES after pooling, the caller's `except Exception` swallows that and moves to the next + # event, and the marker survives. The next ordinary fair draw is then read as "pooled", + # _rvs_is_equal_weight goes False, and any consumer that weights rows applies importance + # weights to rows that already carry them -- the w^2 defect, resurrected on the event after + # any failure. + # + # On ENTRY rather than in a `finally`: entry is reached on every call by construction, and + # it leaves the state correct even for a caller that never returns normally at all. + sampler._rvs_is_pooled = False P = P_list[indx_event] # if pin-distance-to-sim, change the distance prior accordingly if opts.pin_distance_to_sim: @@ -2191,7 +3688,7 @@ def analyze_event(P_list, indx_event, data_dict, psd_dict, fmax, opts, inv_spec_ if 'distance' in sampler.params: sampler.reset_sampling('distance') sampler.reset_sampling('inclination') - elif opts.sampler_method == "GMM": + elif use_gmm_args: # standalone GMM or a portfolio with a GMM member (gmm_dict exists in both) if 'distance' in sampler.params: pair_d_incl = sampler_param_tuple(sampler, ['distance','inclination']) if pair_d_incl in gmm_dict: @@ -2222,8 +3719,21 @@ def analyze_event(P_list, indx_event, data_dict, psd_dict, fmax, opts, inv_spec_ lnL_oracles = np.zeros(opts.n_chunk) sampler.update_sampling_prior(lnL_oracles, opts.n_chunk, external_rvs=rvs_train,log_scale_weights=True,floor_integrated_probability=opts.adapt_floor_level) + _maybe_load_av_state(sampler) + _maybe_enable_anisotropic_bins(sampler) res, var, neff, dict_return = sampler.integrate(like_to_integrate, *unpinned_params, **pinned_params) + # L0 auto-rescue: on a very sharply-peaked (high-amplitude) point a cold AV can stall at + # n_eff ~ 1 because it never draws near the tiny peak. If so, seed a SECOND pass from this + # same point's own highest-likelihood samples and re-run. Opt-in via + # --sampler-warmstart-retry-neff. MUST run BEFORE the not(res) guard below: a degenerate + # early termination returns (None,None,None,None) and is the strongest rescue trigger, so + # raising on it first would skip exactly the case the rescue exists for. + res, var, neff, dict_return = _maybe_l0_rescue( + sampler, res, var, neff, dict_return, + like_to_integrate, unpinned_params, pinned_params, + lnL_offset=manual_avoid_overflow_logarithm) + if not(res): # no resut raise ValueError(" No integral result returned") @@ -2234,6 +3744,16 @@ def analyze_event(P_list, indx_event, data_dict, psd_dict, fmax, opts, inv_spec_ log_res = res sqrt_var_over_res = numpy.exp(var/2 - log_res) + # MC-error diagnostics, the collapse report/gate, and replica replication+pooling. This + # OWNS both collapse-gate calls (first run and pooled verdict) -- do not add a separate + # _report_and_gate_collapse call here, or the pooled one gets bypassed. It needs + # log_res/sqrt_var_over_res, hence its position after they are computed; the main driver + # has the same ordering inline. + res, var, neff, log_res, sqrt_var_over_res, dict_return = _maybe_replicate_for_mc_error( + sampler, res, var, neff, dict_return, log_res, sqrt_var_over_res, + like_to_integrate, unpinned_params, pinned_params, + lnL_offset=manual_avoid_overflow_logarithm) + # Report results if opts.output_file: fname_output_txt = opts.output_file +"_"+str(indx_event)+"_" + ".dat" @@ -2267,6 +3787,11 @@ def analyze_event(P_list, indx_event, data_dict, psd_dict, fmax, opts, inv_spec_ if opts.save_samples and opts.output_file: import copy samples = copy.deepcopy(sampler._rvs) # deep copy: avoid modifying structures and having side effect on integrator, which loops over keys Expensive! + # A POOLED record (--mc-error-replicas) is weighted BETWEEN blocks by the replica + # evidences, and nothing below preserves those weights: convert it to an equal-weight + # draw BEFORE anything consumes it -- including the time resampler, which picks a time per + # row and so assumes the rows already are the posterior. A no-op otherwise. + samples = _export_rvs_equal_weight(samples, sampler, use_lnL=rvs_integrand_is_lnL) # Insert reference distance if it was marginalized over if "distance" not in samples: # Not distance output is the same as internal calculations: in *Mpc* @@ -2468,7 +3993,7 @@ for indx in numpy.arange(len(P_list)): if opts.sampler_method == "adaptive_cartesian_gpu": for name in sampler.params: sampler.reset_sampling(name) - elif opts.sampler_method == "GMM": + elif use_gmm_args: # standalone GMM or a portfolio with a GMM member # reset the GMM dictionary for component in gmm_dict: gmm_dict[component] = None diff --git a/MonteCarloMarginalizeCode/Code/bin/resample_uniform_comoving.py b/MonteCarloMarginalizeCode/Code/bin/resample_uniform_comoving.py index f7aa2768e..011235f05 100644 --- a/MonteCarloMarginalizeCode/Code/bin/resample_uniform_comoving.py +++ b/MonteCarloMarginalizeCode/Code/bin/resample_uniform_comoving.py @@ -12,8 +12,13 @@ import h5py import numpy as np import astropy -from astropy.cosmology import LambdaCDM -Planck15_lal = LambdaCDM(H0=67.90, Om0=0.3065, Ode0=0.6935) +import RIFT.likelihood.priors_utils as priors_utils +# MUST match the cosmology the ILE imposed, because this reweighter divides that prior out +# again: the two only cancel if they are the same object. It used to hardcode the lal +# constants (H0=67.90, Om0=0.3065 -- the name Planck15_lal recorded that intent, and it +# reproduced the old ILE cosmology to ~1e-12). Both sides now ask the one helper, so the +# cancellation stays exact when the helper changes. +Planck15_lal = priors_utils.get_astropy_cosmology("Planck15") parser = argparse.ArgumentParser('Program to resample lalinference posteriors from euclidean to uniform-in-comoving-volume distance prior') parser.add_argument('--runid',help='RunID to use from file. If not given, will apply to all runs',default=None) diff --git a/MonteCarloMarginalizeCode/Code/bin/util_HyperparameterTracerUpdate.py b/MonteCarloMarginalizeCode/Code/bin/util_HyperparameterTracerUpdate.py index 3ccdb520b..55f61f606 100755 --- a/MonteCarloMarginalizeCode/Code/bin/util_HyperparameterTracerUpdate.py +++ b/MonteCarloMarginalizeCode/Code/bin/util_HyperparameterTracerUpdate.py @@ -21,7 +21,14 @@ # # NEW # --update-method {smc-mala-bd, smc-mala, birth-death, ucb, puffball} default smc-mala-bd -# --tracer-fit-method {rf, rbf, quadratic, polynomial} default rf +# --tracer-fit-method {rf, rbf, quadratic, polynomial, gp_linmean} default rf +# gp_linmean is a linear-mean GP: unlike rf (piecewise-constant, flat +# outside the training hull) it extrapolates the global lnL trend past +# the sampled region, so placement can chase a peak clipped at a box +# edge. It also supplies a real posterior sigma for --update-method ucb. +# --tracer-lnl-floor-delta FLOAT default None (OFF; legacy unchanged) +# Clamp training lnL at max(lnL)-delta instead of cutting outliers, so +# catastrophic-fit points remain anchors for the surrogate's scale. # --inj-file-prev OPTIONAL previous-iteration .dat (enables SMC bridging) # --no-union-refit opt out of union refit when --inj-file-prev is given # --n-mala-steps INT default 8 @@ -101,8 +108,14 @@ def build_parser(): choices=("smc-mala-bd", "smc-mala", "birth-death", "ucb", "puffball"), default="smc-mala-bd") p.add_argument("--tracer-fit-method", - choices=("rf", "rbf", "quadratic", "polynomial"), + choices=("rf", "rbf", "quadratic", "polynomial", "gp_linmean"), default="rf") + p.add_argument("--tracer-lnl-floor-delta", default=None, type=float, + help="Clamp training lnL from below at max(lnL) - DELTA " + "instead of discarding low points. Keeps catastrophic-fit " + "outliers as anchors that pin the surrogate's length " + "scale and signal variance. Default off (legacy " + "behaviour bit-for-bit unchanged).") p.add_argument("--inj-file-prev", default=None, help="Optional previous-iteration .dat for SMC bridging / union refit.") p.add_argument("--no-union-refit", action="store_true") @@ -361,7 +374,8 @@ def main(argv=None): Y_prev = rows_p[:, 0] S_prev = rows_p[:, 1] if rows_p.shape[1] >= 2 else None fit_prev = _tracer_fits.build(opts.tracer_fit_method, - X_prev, Y_prev, sigma=S_prev) + X_prev, Y_prev, sigma=S_prev, + lnl_floor_delta=opts.tracer_lnl_floor_delta) if not opts.no_union_refit: X_train = np.vstack([X_prev, X]) Y_train = np.concatenate([Y_prev, Y]) @@ -371,7 +385,8 @@ def main(argv=None): S_train = None fit_now = _tracer_fits.build(opts.tracer_fit_method, - X_train, Y_train, sigma=S_train) + X_train, Y_train, sigma=S_train, + lnl_floor_delta=opts.tracer_lnl_floor_delta) state = {} if opts.state_in and os.path.exists(opts.state_in): diff --git a/MonteCarloMarginalizeCode/Code/bin/util_InitMargTable b/MonteCarloMarginalizeCode/Code/bin/util_InitMargTable index 5e72ed1e5..fc6a6d96b 100755 --- a/MonteCarloMarginalizeCode/Code/bin/util_InitMargTable +++ b/MonteCarloMarginalizeCode/Code/bin/util_InitMargTable @@ -79,17 +79,17 @@ elif (opts.d_prior == 'cosmo' or opts.d_prior == 'cosmo_sourceframe'): redshift_to_distance = lambda x: x from astropy.cosmology import z_at_value from astropy import units as u - from astropy.cosmology import FlatLambdaCDM - from astropy.units import Hz import RIFT.likelihood.priors_utils as priors_utils - # ported form https://github.com/lscsoft/lalsuite/blob/master/lalinference/python/lalinference/bayespputils.py - # need way to query lalsuite parameters! See - # https://git.ligo.org/cbc/action_items/-/issues/37#note_1158065 - try: - from lal import H0_SI, OMEGA_M - except: - H0_SI, OMEGA_M = 2.200489137532724e-18, 0.3065 - my_cosmo = FlatLambdaCDM(H0=H0_SI*Hz, Om0=OMEGA_M) + # SAME cosmology as the ILE driver, from the same helper. This file builds the distance + # prior for the MARGINALIZED path (--internal-marginalize-distance), while the ILE driver + # builds it for the unmarginalized one, and helper_LDG_Events passes both the same + # --d-prior. So if these two disagree, identical CLI gives two different cosmological + # priors depending only on whether distance marginalization is on -- which is what + # happened for one commit when the ILE driver moved to the helper and this did not. + # History: the lal-constant route came from + # https://git.ligo.org/cbc/action_items/-/issues/37#note_1158065 and was ported from + # lalinference/bayespputils.py. Superseded deliberately. + my_cosmo = priors_utils.get_astropy_cosmology("Planck15") # omega = lal.CreateDefaultCosmologicalParameters() # matching the lal options. Only needed if we have it zmin = z_at_value(my_cosmo.luminosity_distance, dmin*u.Mpc).value zmax = z_at_value(my_cosmo.luminosity_distance, dmax*u.Mpc).value # use astropy estimate for zmax diff --git a/MonteCarloMarginalizeCode/Code/bin/util_ParameterTracerUpdate.py b/MonteCarloMarginalizeCode/Code/bin/util_ParameterTracerUpdate.py index 12da709c8..5f930d3a6 100755 --- a/MonteCarloMarginalizeCode/Code/bin/util_ParameterTracerUpdate.py +++ b/MonteCarloMarginalizeCode/Code/bin/util_ParameterTracerUpdate.py @@ -27,7 +27,14 @@ # NEW # --update-method {smc-mala-bd, smc-mala, birth-death, puffball} # Default smc-mala-bd. "puffball" reproduces util_ParameterPuffball.py for regression. -# --tracer-fit-method {rf, rbf, quadratic, polynomial} default rf +# --tracer-fit-method {rf, rbf, quadratic, polynomial, gp_linmean} default rf +# gp_linmean is a linear-mean GP: unlike rf (piecewise-constant, flat +# outside the training hull) it extrapolates the global lnL trend past +# the sampled region, so placement can chase a peak clipped at a box +# edge. It also supplies a real posterior sigma (predict_with_std). +# --tracer-lnl-floor-delta FLOAT default None (OFF; legacy unchanged) +# Clamp training lnL at max(lnL)-delta instead of cutting outliers, so +# catastrophic-fit points remain anchors for the surrogate's scale. # --no-union-refit if --fname-prev given, do NOT include prev points in f_k fit # --n-mala-steps INT default 8 # --target-ess-frac FLOAT default 0.5 @@ -119,8 +126,14 @@ def build_parser(): choices=("smc-mala-bd", "smc-mala", "birth-death", "puffball"), default="smc-mala-bd") p.add_argument("--tracer-fit-method", - choices=("rf", "rbf", "quadratic", "polynomial"), + choices=("rf", "rbf", "quadratic", "polynomial", "gp_linmean"), default="rf") + p.add_argument("--tracer-lnl-floor-delta", default=None, type=float, + help="Clamp training lnL from below at max(lnL) - DELTA " + "instead of discarding low points. Keeps catastrophic-fit " + "outliers as anchors that pin the surrogate's length " + "scale and signal variance. Default off (legacy " + "behaviour bit-for-bit unchanged).") p.add_argument("--no-union-refit", action="store_true", help="If --fname-prev is given, do NOT include those points in the f_k fit.") p.add_argument("--n-mala-steps", default=8, type=int) @@ -303,10 +316,12 @@ def main(argv=None): if (S_prev is not None and S_k is not None) else None) # f_{k-1} fit on prior data only fit_prev = _tracer_fits.build(opts.tracer_fit_method, - X_prev, Y_prev, sigma=S_prev) + X_prev, Y_prev, sigma=S_prev, + lnl_floor_delta=opts.tracer_lnl_floor_delta) fit_now = _tracer_fits.build(opts.tracer_fit_method, - X_train_k, Y_train_k, sigma=S_train_k) + X_train_k, Y_train_k, sigma=S_train_k, + lnl_floor_delta=opts.tracer_lnl_floor_delta) state = {} if opts.state_in and os.path.exists(opts.state_in): diff --git a/MonteCarloMarginalizeCode/Code/bin/util_RIFT_hyperpipe.py b/MonteCarloMarginalizeCode/Code/bin/util_RIFT_hyperpipe.py index 7dc4c0153..059eec5d6 100644 --- a/MonteCarloMarginalizeCode/Code/bin/util_RIFT_hyperpipe.py +++ b/MonteCarloMarginalizeCode/Code/bin/util_RIFT_hyperpipe.py @@ -259,6 +259,7 @@ def _build_puff_args(cfg, coord_spec) -> str: setting_flags = [ ("update-method", "--update-method"), ("tracer-fit-method", "--tracer-fit-method"), + ("tracer-lnl-floor-delta", "--tracer-lnl-floor-delta"), ("ucb-kappa", "--ucb-kappa"), ("ucb-n-candidates", "--ucb-n-candidates"), ("n-mala-steps", "--n-mala-steps"), diff --git a/MonteCarloMarginalizeCode/Code/bin/util_RIFT_pseudo_pipe.py b/MonteCarloMarginalizeCode/Code/bin/util_RIFT_pseudo_pipe.py index 2478218e2..ce3486c55 100755 --- a/MonteCarloMarginalizeCode/Code/bin/util_RIFT_pseudo_pipe.py +++ b/MonteCarloMarginalizeCode/Code/bin/util_RIFT_pseudo_pipe.py @@ -55,6 +55,9 @@ # Backward compatibility from RIFT.misc.dag_utils_generic import which from RIFT.misc.cip_pipeline import flag_final_group_unique +# leaf module: numpy only, so this does not drag numba/cupy into the pipeline script +from RIFT.likelihood.time_interp_choice import ( + BARE_FLAG_SENTINEL, CROSSOVER_GUIDANCE, resolve_interpolate_time_request) ligolw_prefix = 'igwn_' if not(which(ligolw_prefix + "ligolw_add")): ligolw_prefix = '' @@ -468,7 +471,7 @@ def run_lisa_known_sky_surface(opts): parser.add_argument("--add-extrinsic-time-resampling",action='store_true',help="adds the time resampling option. Only deployed for vectorized calculations (which should be all that end-users can access)") parser.add_argument("--internal-ile-srate-time-resampling",default=None, help=" Adds --srate-resample-time-marginalization to ILE for output, to provide higher-resolution time output ") parser.add_argument("--internal-ile-srate-internal",default=None, help=" Adds --srate-internal to ILE, modifying how calculations are performed internally to use a higher sampling rate ") -parser.add_argument("--internal-ile-interpolate-time",action='store_true',help="Pass --interpolate-time True to ILE, enabling cubic interpolation of Q_lm at fractional detector arrival times in the maintained NoLoop likelihood.") +parser.add_argument("--internal-ile-interpolate-time",nargs='?',const=BARE_FLAG_SENTINEL,default=None,type=str,help="Enable sub-sample interpolation of Q_lm at fractional detector arrival times in the maintained NoLoop likelihood. REQUIRES AN EXPLICIT STENCIL: nearest|cubic|sinc -- automatic selection was removed as measurably unreliable, and a bare flag is rejected rather than silently doing nothing. MEASURED GUIDANCE (SEOBNRv4, an IMR model): %s. Forwarded verbatim to helper_LDG_Events.py, which validates it. Full tables, limitations and provenance: RIFT/likelihood/DESIGN_q_window_stencil.md." % CROSSOVER_GUIDANCE) parser.add_argument("--internal-ile-n-chunk",default=None,type=int,help="Override the extrinsic chunk size (--n-chunk) passed to ILE, via the helper. Default behaviour (helper): 40000, scaled linearly with SNR above 40 and capped at 160000, because at high SNR the posterior is a vanishing fraction of the prior volume and a small chunk gives few informative samples per adaptation step. Larger chunks cost GPU memory but measured HOST memory (what RequestMemory governs) is flat, so no memory-request change is normally needed. EXPERTS ONLY.") parser.add_argument("--batch-extrinsic",action='store_true') parser.add_argument("--fmin",default=20,type=int,help="Mininum frequency for integration. template minimum frequency (we hope) so all modes resolved at this frequency") # should be 23 for the BNS @@ -603,6 +606,11 @@ def run_lisa_known_sky_surface(opts): parser.add_argument("--internal-force-puff-iterations", default=4, type=int, help="Number of iterations to be puffed") opts= parser.parse_args() +# Resolve the sub-sample stencil request IMMEDIATELY, so a bare flag / retired 'True' / typo +# fails here rather than being forwarded into a workflow build. Value unused at this point -- +# the call is for its validation side effect; the helper resolves it again for the emission. +resolve_interpolate_time_request(opts.internal_ile_interpolate_time) + # Multi-GPU ILE fan-out: --ile-gpu-fanout funnels through RIFT_ILE_GPU_FANOUT, which # create_event_parameter_pipeline_BasicIteration (run via os.system, inheriting this # environment) and dag_utils read at DAG-build time to size request_GPUs/CPUs and bake @@ -1244,11 +1252,18 @@ def approx_supports_precession(approx_name): cmd += " --internal-ile-auto-logarithm-offset " if opts.internal_ile_rotate_phase: cmd += " --internal-ile-rotate-phase " -if opts.internal_ile_interpolate_time: +if resolve_interpolate_time_request(opts.internal_ile_interpolate_time) is not None: + # resolve_interpolate_time_request rather than a truthiness test: the flag takes a VALUE, so + # '--internal-ile-interpolate-time False' passes the STRING 'False' (truthy in Python) and a + # BARE flag passes a sentinel. Both must be distinguished from "a stencil was named", and a + # bare flag must raise rather than silently forward nothing. # HELPER passthrough (not a raw ILE arg): the helper owns ILE argument construction, and it - # also knows whether the NoLoop path (--vectorized --gpu --force-xpy) that --interpolate-time - # requires is actually in use. - cmd += " --internal-ile-interpolate-time " + # also knows whether the maintained NoLoop path that --interpolate-time requires is in use -- + # which needs --time-marginalization AND --vectorized AND one of --gpu/--rotation-slow/ + # --freqresponse; the ILE driver refuses rather than ignoring if any is missing. It also owns the stencil choice, because srate and fmax are + # resolved there -- so forward the request verbatim rather than resolving it here, and let the + # helper's log line be the single record of what was chosen. + cmd += " --internal-ile-interpolate-time " + str(opts.internal_ile_interpolate_time) + " " if not(opts.internal_ile_n_chunk is None): cmd += " --internal-ile-n-chunk {} ".format(int(opts.internal_ile_n_chunk)) # If user provides ini file *and* ini file has fake-cache field, generate a local.cache file, and pass it as argument diff --git a/MonteCarloMarginalizeCode/Code/test/asimov_integration/README.md b/MonteCarloMarginalizeCode/Code/test/asimov_integration/README.md index 53bb11788..521a60e6e 100644 --- a/MonteCarloMarginalizeCode/Code/test/asimov_integration/README.md +++ b/MonteCarloMarginalizeCode/Code/test/asimov_integration/README.md @@ -24,9 +24,8 @@ ILE args, and a deterministic randomized sweep over key scalar options. It does not submit jobs or require production frame/calibration storage. -The RIFT Asimov integration is currently developed against the Asimov `0.5` -series. The pytest is ready to skip cleanly for `0.6` and `0.7` until the -integration is updated for those APIs. +The RIFT Asimov integration is tested against the legacy Asimov `0.5` series +and the plugin-based `0.7` series. Unsupported API series skip cleanly. The bundled blueprints are small snapshots of the current public Asimov data repository (`https://git.ligo.org/asimov/data`) chosen to avoid live network diff --git a/MonteCarloMarginalizeCode/Code/test/asimov_integration/blueprints/GW190426_190642.yaml b/MonteCarloMarginalizeCode/Code/test/asimov_integration/blueprints/GW190426_190642.yaml index 1dc75eb36..876da4e44 100644 --- a/MonteCarloMarginalizeCode/Code/test/asimov_integration/blueprints/GW190426_190642.yaml +++ b/MonteCarloMarginalizeCode/Code/test/asimov_integration/blueprints/GW190426_190642.yaml @@ -15,6 +15,10 @@ interferometers: - V1 kind: event likelihood: + minimum frequency: + H1: 20 + L1: 20 + V1: 20 psd length: 4 reference frequency: 3 sample rate: 1024 diff --git a/MonteCarloMarginalizeCode/Code/test/asimov_integration/test_asimov_rift_build_contract.py b/MonteCarloMarginalizeCode/Code/test/asimov_integration/test_asimov_rift_build_contract.py index 48b925488..08be4c205 100644 --- a/MonteCarloMarginalizeCode/Code/test/asimov_integration/test_asimov_rift_build_contract.py +++ b/MonteCarloMarginalizeCode/Code/test/asimov_integration/test_asimov_rift_build_contract.py @@ -1,3 +1,4 @@ +import configparser import importlib.metadata import pathlib import shutil @@ -8,8 +9,8 @@ ROOT = pathlib.Path(__file__).resolve().parents[4] TRAVIS_INPUTS = ROOT / ".travis" / "ref_ini" -SUPPORTED_SERIES = {"0.5"} -FUTURE_SERIES = {"0.6", "0.7"} +SUPPORTED_SERIES = {"0.5", "0.7"} +FUTURE_SERIES = {"0.6"} def _asimov_version(): @@ -30,11 +31,11 @@ def _require_supported_asimov(): if series in FUTURE_SERIES: pytest.skip( "RIFT Asimov CI is wired for this series, but the integration " - "is currently validated only against Asimov 0.5" + "is currently validated against Asimov 0.5 and 0.7" ) if series not in SUPPORTED_SERIES: pytest.skip( - "RIFT Asimov CI is currently validated only against Asimov 0.5 " + "RIFT Asimov CI is currently validated against Asimov 0.5 and 0.7 " f"(found {version})" ) return version @@ -114,10 +115,13 @@ def get_psds(self, _format): return [] -def test_asimov_05_rift_build_dag_uses_frozen_inputs(monkeypatch, tmp_path): +def test_rift_build_dag_uses_frozen_inputs(monkeypatch, tmp_path): _require_supported_asimov() _require_htcondor() + # Let ASIMOV discover the RIFT entry point before importing its module + # directly, avoiding re-entry through a partially initialized module. + __import__("asimov") from RIFT.asimov import rift as rift_module from RIFT.asimov.rift import Rift @@ -148,9 +152,12 @@ def test_asimov_05_rift_build_dag_uses_frozen_inputs(monkeypatch, tmp_path): monkeypatch.setattr(Rift, "before_build", lambda self: None) def fake_config_get(section, option): + if section == "authentication": + raise configparser.NoSectionError(section) values = { ("condor", "user"): "rift-ci", ("general", "calibration"): "C01", + ("general", "calibration_directory"): "C01_offline", ("pipelines", "environment"): str(tmp_path / "env"), ("rift", "environment"): str(tmp_path / "env"), } diff --git a/MonteCarloMarginalizeCode/Code/test/asimov_integration/test_asimov_rift_project.py b/MonteCarloMarginalizeCode/Code/test/asimov_integration/test_asimov_rift_project.py index 306a7140b..c2d17847a 100644 --- a/MonteCarloMarginalizeCode/Code/test/asimov_integration/test_asimov_rift_project.py +++ b/MonteCarloMarginalizeCode/Code/test/asimov_integration/test_asimov_rift_project.py @@ -8,8 +8,8 @@ BLUEPRINT_DIR = pathlib.Path(__file__).with_name("blueprints") -SUPPORTED_SERIES = {"0.5"} -FUTURE_SERIES = {"0.6", "0.7"} +SUPPORTED_SERIES = {"0.5", "0.7"} +FUTURE_SERIES = {"0.6"} EVENT = "GW190426_190642" RIFT_ANALYSIS = "rift-v5PHM-calmarg" @@ -32,11 +32,11 @@ def _require_supported_asimov(): if series in FUTURE_SERIES: pytest.skip( "RIFT Asimov CI is wired for this series, but the integration " - "is currently validated only against Asimov 0.5" + "is currently validated against Asimov 0.5 and 0.7" ) if series not in SUPPORTED_SERIES: pytest.skip( - "RIFT Asimov CI is currently validated only against Asimov 0.5 " + "RIFT Asimov CI is currently validated against Asimov 0.5 and 0.7 " f"(found {version})" ) return version @@ -78,13 +78,13 @@ def _tree_text(root): return "\n".join(chunks) -def test_asimov_05_can_create_project_and_add_rift_event(tmp_path): +def test_asimov_can_create_project_and_add_rift_event(tmp_path): version = _require_supported_asimov() _require_htcondor() asimov_cli = shutil.which("asimov") assert asimov_cli, "asimov CLI is not on PATH" - # Import after the version gate so 0.6/0.7 API drift skips cleanly. + # Import after the version gate so unsupported API series skip cleanly. from asimov.pipelines import known_pipelines from RIFT.asimov.rift import Rift diff --git a/MonteCarloMarginalizeCode/Code/test/asimov_integration/test_asimov_rift_template_contract.py b/MonteCarloMarginalizeCode/Code/test/asimov_integration/test_asimov_rift_template_contract.py index 06d56506a..2128ff9ff 100644 --- a/MonteCarloMarginalizeCode/Code/test/asimov_integration/test_asimov_rift_template_contract.py +++ b/MonteCarloMarginalizeCode/Code/test/asimov_integration/test_asimov_rift_template_contract.py @@ -149,6 +149,17 @@ def test_rift_liquid_template_renders_realistic_baseline_ledger(): assert "manual-extra-ile-args=--internal-waveform-extra-kwargs" in rendered +def test_rift_liquid_template_prefers_asimov_07_minimum_frequency(): + meta = _base_meta() + meta["likelihood"]["minimum frequency"] = {"H1": 18, "L1": 19} + + _rendered, parser = _render(meta) + + flow = parser.get("lalinference", "flow") + assert '"H1":18' in flow + assert '"L1":19' in flow + + @pytest.mark.parametrize( "distance_prior,expected", [ diff --git a/MonteCarloMarginalizeCode/Code/test/expensive_before_merging/integrators/LISA_DRIVER_DRIFT.md b/MonteCarloMarginalizeCode/Code/test/expensive_before_merging/integrators/LISA_DRIVER_DRIFT.md new file mode 100644 index 000000000..12ad51dcb --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/test/expensive_before_merging/integrators/LISA_DRIVER_DRIFT.md @@ -0,0 +1,179 @@ +# The LISA ILE driver, against the main one + +The two drivers are a **deliberate fork** (RO, 2026-08-13: *"It is super annoying we have to +have two of them, but the overhead of one ring to rule them all is too high."*). Nothing here +argues for merging them. The purpose is to make the *consequence* of the fork -- drift -- +mechanically visible, so it stays a choice. + + bin/integrate_likelihood_extrinsic_batchmode 4,883 lines moves fast + bin/integrate_likelihood_extrinsic_batchmode_lisa 2,526 lines lags + +Both import the SAME integrators and expose the SAME `ok_lnL_methods` +(`GMM, adaptive_cartesian, adaptive_cartesian_gpu, AV, portfolio` -- verified identical), so +anything landed in `RIFT/integrators/` already reaches LISA. **All measured drift is in the +driver.** + +## How to regenerate this + +Do not trust the numbers below; they are a snapshot. The tooling is the authority. + +``` +python3 audit_lisa_driver_drift.py --summary # counts per category and decision +python3 audit_lisa_driver_drift.py --undecided # what nobody has classified yet +python3 audit_lisa_driver_drift.py --check # the CI gate +python3 make_lisa_drift_ledger.py # regenerate lisa_drift_ledger.json +``` + +`audit_lisa_driver_drift.py` extracts four categories from both drivers by AST and diffs them: +`FUNC` (def names, qualified by enclosing function), `OPTION` (`--foo` literals given to +`add_option`/`add_argument`), `CONST` (module-level `UPPER_CASE`), `ATTR` (sampler provenance +markers -- `_rvs_is_*`, `_warm_seed*`, including the `getattr(obj, 'name', default)` form, +which is how the readers actually access them). + +The judgements live in `make_lisa_drift_ledger.py` as ordered +(pattern -> decision + reason) rules, first match wins, so a whole family is decided once. +An item matching no rule is reported and left out, which fails `--check`. That is the +intended path for newly-drifted code: **a person has to classify it.** + +## What this audit CANNOT see + +Stated plainly, because an adversarial review defeated the gate with four realistic drifts +and the honest answer is that some of them are out of scope by construction rather than by +oversight. + +**It is a NAME-PRESENCE set difference.** It answers "does the LISA driver have a thing +called X". It does not compare behaviour. So all of these produce **zero** gap items: + +* a **changed default** on an option present in both drivers (`--adapt-floor-level` going + 0.1 -> 0.9 is invisible here); +* **changed help text**; +* a **changed body** of a same-named function -- the anti-drift tests in + `test/test_lisa_*.py` cover this for the specific helpers that were ported, and nothing + covers it for anything else; +* a **missing `if` branch or `pinned_params` key**, which is not a FUNC/OPTION/CONST/ATTR at + all. A real example is below. + +**Option names built at runtime evade the extractor.** `add_option(_name_var, ...)`, +options added in a `for` loop, and `"--evade-" + "concat"` are all missed, because the +extractor reads string LITERALS out of the AST. Since `OPTION` is the large majority of the +gap, this is the biggest hole. Neither driver does any of this today. + +**`ATTR` is presence-anywhere.** A marker READ but never WRITTEN counts as present, so a +reader-ported/writer-missing port looks closed. `_rvs_is_pooled` is exactly that today, and +its ledger entry says so. + +The gate is worth having anyway -- it catches the ordinary case, which is a helper or an +option appearing in the main driver and nobody asking the LISA question. It is not a proof +of equivalence, and it should not be described as one. + +## The gate + +`test/test_lisa_driver_drift.py`, wired into the `lisa-check` CI job via +`.travis/test-lisa.sh`. It does **not** assert the gap is empty or that anything was ported. +It asserts that every gap item carries one of `PORT` / `PORTED` / `NA` / `PHYSICS` **with a +reason**, that no item claims `PORTED` while still absent, and that the ledger holds no +entries for items that have left the gap. + +*"Does not apply to LISA" is a fine answer; silence is not.* + +## Snapshot, 2026-08-15 (junior/rift_O4d @ 364a22fd) + +132 items before this pass; 8 ported here, leaving 124. + +| decision | n | meaning | +|---|---|---| +| `PORT` | 70 | belongs in LISA, not there yet -- open work | +| `NA` | 43 | does not apply, with the reason | +| `PHYSICS` | 11 | blocked on a physics decision, with the question | +| `PORTED` | 8 | carried across in this pass | + +### Ported in this pass -- the fair-draw correctness family (PR #87) + +`ln_weights_from_rvs`, `ln_weights_for_posterior`, `_rvs_is_export_resample`, +`_rvs_is_equal_weight`, `_rvs_len`, `_rvs_lnL_convention`, and reads of the `_rvs_is_fairdraw` +/ `_rvs_is_pooled` markers. + +The three consumers whose double-weighting PR #87 actually fixed -- the +`--extrinsic-proposal-output` breadcrumb, the `.dgrid` exporter, the `.dslice` reweight core +-- **do not exist in the LISA driver**, so there was no live `w^2` bug there. What existed was +the hazard: the LISA driver sets `igrand_fairdraw_samples` from `--fairdraw-extrinsic-output`, +so its `_rvs` can be a fair draw, and all seven shared rebind sites already set +`_rvs_is_fairdraw`. **The marker was arriving and nothing read it.** This port is preventive, +and it is the "correct thing to reach for" that the audit's Recommendation 1 asks for. + +Tests: `test/test_lisa_fairdraw_weights.py` (29), revert-checked -- each fix broken in turn, +the named test confirmed failing, the file restored and verified byte-identical. + +Two things deliberately NOT done: + +* `ln_weights_for_posterior` passes `use_lnL` **through unresolved**, exactly as the main + driver does, so a caller that omits it gets the linear reading rather than the run's + convention. That is a latent trap **in both drivers**; reproducing it beats having a + same-named helper behave differently in the two forks. Worth fixing in both, together. +* `_truthy_option` was initially classified with this family and moved out: its only caller in + the main driver is the `--interpolate-time` normalizer, so porting it here would have added + dead code. + +### `NA` -- does not apply to LISA (43) + +| family | n | why | +|---|---|---| +| `--calibration-*` + 4 helpers | 19 | LIGO/Virgo **spline calibration envelopes**. The LISA driver models no instrument calibration: no envelope directory, no cal nodes, response applied analytically by `factored_likelihood_LISA`. | +| `.dslice` / `.dgrid` distance export | 11 | Data products for a downstream LIGO CIP distance workflow the LISA pipeline does not run. No consumer. | +| `--freqresponse*` | 3 | Finite light-travel-time across the arms for **3G ground** detectors, on `lalsimulation` geometry with an arm length in metres. LISA's finite-size response is not an add-on -- it is the TDI response the driver already applies. | +| `--rotation-*` | 3 | Sidereal time-dependence of an **Earth-based** antenna pattern. The constellation's motion is already in the LISA response; this would apply Earth rotation to a heliocentric detector. | +| data/waveform io | 6 | LISA has its own equivalents under different names -- `--data-integration-window-half` for the storage window, `--internal-waveform-*` fd/L-frame passthroughs, h5 frames instead of gwpy, rate from the frame rather than `--srate-internal`. | +| `--e-freq`, `--save-meanPerAno` | 2 | Ground-based eccentric-waveform path (TEOBResumS); LISA's own export is `--save-eccentricity`. | + +### `PHYSICS` -- needs a decision before it can be answered (11) + +These are the ones that need you, not more code reading. + +1. **`--d-prior-redshift`, `dLofz`, `dVdz`** (4 items incl. constants) — *which cosmology and + which redshift range should a LISA distance prior use?* Arguably **more** important for + LISA than for ground-based work, since MBHB sit at z~1-20 where a Euclidean `d^2` prior is + badly wrong -- but the main driver's helpers were built and gridded for the ground-based + range. +2. **`--internal-reparam-dl-incl`, `_reparam_A_of_incl`, `_REPARAM_*`** (5 items) — *does the + quadrupole amplitude `A(iota)=sqrt(((1+cos^2 i)/2)^2+cos^2 i)` remain the right axis to + reparameterize distance against under the LISA TDI response?* It is a pure l=|m|=2 + statement; LISA MBHB are strongly higher-mode and TDI mixes the polarizations differently, + so the degeneracy it straightens may not be the degeneracy LISA has. +3. **`--limit-right-ascension`, `--limit-declination`** — *what should a sky zoom box mean for + LISA?* The driver reuses the key names `right_ascension`/`declination` for its sampled sky + pair, but the values are ecliptic and may be further rotated by + `--internal-sky-network-coordinates`. LISA already has + `--ecliptic-latitude`/`--ecliptic-longitude`/`--lisa-fixed-sky`, which may be the intended + mechanism. (`--limit-psi`/`--limit-inclination` have no such ambiguity and are `PORT`.) +4. **`--sampler-warmstart-samples`** — *what frame are the named columns of a LISA pilot file + in?* Same key-names-different-meaning problem; needs a stated convention, or a pilot + written by the LISA driver itself. + +### `PORT` -- open work, highest value first (70) + +Nothing here is blocked on physics; all of it is sampler-agnostic or pure plumbing. + +| family | n | note | +|---|---|---| +| L0 rescue + warm-start state | 15 | **Highest value.** Triggers on low `n_eff`; LISA MBHB are high-SNR, the regime that stalls. `_snapshot_pass_state`/`_restore_pass_state` must port **as a set** -- Finding 5 was a rejected warm pass restoring `_rvs` but not the reserve. | +| portfolio tuning | 12 | Reachable today via LISA's `--sampler-portfolio-args` eval-dict; porting is pipeline parity. | +| GMM tuning | 7 | Pure pass-through to `mcsamplerEnsemble`. | +| MC-error replicas + pooling | 7 | Includes `_pool_replica_rvs`; port the **per-replica sequence** form, not the boolean (Finding 6). | +| extrinsic proposal handoff | 6 | `--extrinsic-proposal-output` is a Finding-2 site: port it **on top of** `ln_weights_for_posterior`, never with a bare `w`. | +| lnZ / n_eff helpers | 3 | `_lnZ_of_rvs`, `_kish_neff_of_rvs`, `_lnZ_of_reserve_or_rvs` -- needed by the two families above. | +| AV state + binning | 3 | `--sampler-save/load-state`, `--sampler-anisotropic-bins`. | +| misc plumbing | 17 | `--limit-psi`/`--limit-inclination` (port the **post-#58** form, incl. the `cos(iota)` endpoint swap), `--check-good-enough`, `--random-event`, `--fairdraw-extrinsic-output-n-max`, interpolate-time normalizer, etc. | + +**One trap recorded against `--fairdraw-extrinsic-output-n-max`:** the LISA driver currently +hardcodes the cap to `opts.n_eff`, while main's default for the flag is **5**. Adopting main's +default verbatim would silently shrink every LISA export by orders of magnitude. Port the flag +with LISA's present behaviour as its default. + +## Note on CI + +The LISA driver is **not** uncovered -- the `lisa-check` job runs nine test files. But all nine +are import / contract / smoke level: they check the driver loads, exposes its CLI surface and +runs a synthetic demo. None asserts anything about integrator weighting or fair-draw +correctness, which is how 2,357 lines of drift accumulated with CI green. That is the gap the +drift gate closes -- not by testing the physics, but by refusing to let a new item through +without a recorded human decision. diff --git a/MonteCarloMarginalizeCode/Code/test/expensive_before_merging/integrators/RETAINED_SET_MEMORY_2026-08-13.log b/MonteCarloMarginalizeCode/Code/test/expensive_before_merging/integrators/RETAINED_SET_MEMORY_2026-08-13.log new file mode 100644 index 000000000..052596809 --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/test/expensive_before_merging/integrators/RETAINED_SET_MEMORY_2026-08-13.log @@ -0,0 +1,11 @@ +================================================================================================ +Retained-set size: what holding it alongside the export would cost +(no fair draw, so _rvs IS the retained set; rho=20.0) +================================================================================================ +sampler nmax ntotal rows cols record MB RSS MB dRSS MB +AV 200000 200886 7934 9 0.5 212.6 7.2 +AV 400000 261900 16242 9 1.1 214.3 1.7 +AV 800000 322587 25374 9 1.7 215.5 1.3 +portfolio 200000 200000 199641 12 18.3 445.9 230.4 +portfolio 400000 400000 399639 12 36.6 513.3 67.4 +portfolio 800000 800000 799637 12 73.2 652.1 138.9 diff --git a/MonteCarloMarginalizeCode/Code/test/expensive_before_merging/integrators/audit_backend_contracts.py b/MonteCarloMarginalizeCode/Code/test/expensive_before_merging/integrators/audit_backend_contracts.py new file mode 100644 index 000000000..e83144eb2 --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/test/expensive_before_merging/integrators/audit_backend_contracts.py @@ -0,0 +1,234 @@ +#!/usr/bin/env python3 +"""What does each sampler backend actually PUT IN `_rvs`, and what does it expect back? + +WHY THIS EXISTS +--------------- +The backends are structurally different in ways nothing states, and a consumer that guesses +wrong gets a plausible number rather than an error. Concretely, this bit twice while wiring +the record in one afternoon: + + * `_rvs['integrand']` holds THREE different things. It is lnL on AV / NFlow / portfolio + (aliased from log_integrand), linear L on mcsampler / mcsamplerGPU, and EITHER on + mcsamplerEnsemble depending on the `return_lnI` kwarg -- i.e. for one backend the column's + meaning is a RUNTIME property of how the pass was called. Feed a log callable to a linear + entry point and the fair draw computes NEGATIVE weights and raises, if you are lucky; + downstream the same mistake does NOT raise, it takes log() of a log and returns a + plausible, almost-flat weight vector. `ln_weights_from_rvs` carries a long comment about + exactly this, which is why it REQUIRES `use_lnL` to be passed explicitly. + * only AV and the portfolio keep a `_warm_seed_reserve`; the L0 rescue and the sequential + warm start have to cope with its absence. + * the portfolio's `_rvs` holds EVERY draw (including -inf rows); AV's holds only the + retained subset. That is a ~90x memory difference and it changes what "n_retained" means. + +None of that is discoverable without reading five files. This prints it as a table, and +`--check` fails when a backend's contract changes without the table being updated -- so the +next developer meets a diff instead of a landmine. + +USAGE +----- + python3 audit_backend_contracts.py # the table + python3 audit_backend_contracts.py --json + python3 audit_backend_contracts.py --check # CI: contracts match the recorded ledger +""" +import argparse +import ast +import json +import os +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +CODE = os.path.abspath(os.path.join(HERE, "..", "..", "..")) + +BACKENDS = [ + "mcsampler", + "mcsamplerAdaptiveVolume", + "mcsamplerEnsemble", + "mcsamplerGPU", + "mcsamplerNFlow", + "mcsamplerPortfolio", +] + +LEDGER = os.path.join(HERE, "backend_contracts.json") + +# Columns whose presence distinguishes the log convention from the linear one. +LOG_COLS = ("log_integrand", "log_joint_prior", "log_joint_s_prior") +LIN_COLS = ("integrand", "joint_prior", "joint_s_prior") + + +def _written_rvs_keys(tree): + """String keys assigned into self._rvs anywhere in the module.""" + keys = set() + for node in ast.walk(tree): + if not isinstance(node, ast.Assign): + continue + for t in node.targets: + if (isinstance(t, ast.Subscript) + and isinstance(t.value, ast.Attribute) + and t.value.attr == "_rvs"): + sl = t.slice + if hasattr(ast, "Index") and isinstance(sl, getattr(ast, "Index")): + sl = sl.value + if isinstance(sl, ast.Constant) and isinstance(sl.value, str): + keys.add(sl.value) + return keys + + +def _entry_points(tree): + names = set() + for node in ast.walk(tree): + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) \ + and node.name in ("integrate", "integrate_log"): + names.add(node.name) + return names + + +def _rebind_count(src): + return src.count("self._rvs_is_fairdraw = True") + + +def scan(name): + path = os.path.join(CODE, "RIFT", "integrators", "{}.py".format(name)) + if not os.path.exists(path): + return {"backend": name, "error": "missing"} + src = open(path, errors="replace").read() + tree = ast.parse(src) + keys = _written_rvs_keys(tree) + # WHAT DOES _rvs['integrand'] ACTUALLY HOLD? Not the same question as "which columns + # exist" -- most backends write both families. Three distinct answers: + # * aliased from log_integrand -> it holds lnL, always + # * a return_lnI/use_lnL kwarg -> it holds L or lnL depending on how the pass was CALLED + # * neither -> it holds L, always + # The middle case is the dangerous one: the column's meaning is a runtime property, so no + # amount of reading the consumer tells you which it is. + aliased = ("_rvs['integrand'] = self._rvs['log_integrand']" in src.replace('"', "'")) + kwarg = "return_lnI" in src + if aliased: + integrand_holds = "log (aliased)" + elif kwarg: + integrand_holds = "L or lnL (kwarg)" + else: + integrand_holds = "linear" + return { + "backend": name, + "entry_points": sorted(_entry_points(tree)), + "integrand_holds": integrand_holds, + "has_return_lnI_kwarg": kwarg, + "rvs_keys": sorted(keys), + "keeps_warm_seed_reserve": "self._warm_seed_reserve" in src, + "builds_reserve": "make_warm_seed_reserve(" in src, + "n_rebind_sites": _rebind_count(src), + "sets_rvs_record": "RvsRecord.fair_draw(" in src, + "has_clear_warm_state": "def clear_warm_state" in src, + "has_reset_sampling": "def reset_sampling" in src, + "has_bootstrap_from_samples": "def bootstrap_from_samples" in src, + } + + +FIELDS = [ + ("entry_points", "entry"), + ("integrand_holds", "_rvs['integrand']"), + ("keeps_warm_seed_reserve", "reserve"), + ("n_rebind_sites", "rebinds"), + ("sets_rvs_record", "record"), + ("has_bootstrap_from_samples", "bootstrap"), + ("has_clear_warm_state", "clear_warm"), + ("has_reset_sampling", "reset_samp"), +] + + +def main(): + ap = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--json", action="store_true") + ap.add_argument("--check", action="store_true") + ap.add_argument("--emit-ledger", action="store_true") + args = ap.parse_args() + + rows = [scan(b) for b in BACKENDS] + + if args.json or args.emit_ledger: + out = {r["backend"]: r for r in rows} + if args.emit_ledger: + with open(LEDGER, "w") as f: + json.dump(out, f, indent=2, sort_keys=True) + f.write("\n") + print("wrote {}".format(os.path.basename(LEDGER))) + return 0 + json.dump(out, sys.stdout, indent=2, sort_keys=True) + sys.stdout.write("\n") + return 0 + + if args.check: + if not os.path.exists(LEDGER): + print("no recorded contracts; run --emit-ledger") + return 1 + want = json.load(open(LEDGER)) + bad = [] + for r in rows: + w = want.get(r["backend"]) + if w is None: + bad.append((r["backend"], "not in the ledger at all")) + continue + for k in sorted(set(list(r)) | set(list(w))): + if r.get(k) != w.get(k): + bad.append((r["backend"], + "{}: recorded {!r}, now {!r}".format(k, w.get(k), r.get(k)))) + if bad: + print("BACKEND CONTRACT CHANGED ({} difference(s)):".format(len(bad))) + for b, msg in bad: + print(" {:<26} {}".format(b, msg)) + print("\nThese differences are the landmine this file exists to surface: a consumer") + print("written against one backend meets another and gets a plausible wrong number.") + print("If the change is intended, re-record it and say why in the PR:") + print(" python3 {} --emit-ledger".format(os.path.basename(__file__))) + return 1 + print("OK: all {} backend contracts match the recorded ledger.".format(len(rows))) + return 0 + + print("=" * 108) + print("SAMPLER BACKEND CONTRACTS -- what each one puts in _rvs and what it expects back") + print("=" * 108) + w = {"_rvs['integrand']": 19} + hdr = "{:<26}".format("backend") + "".join( + "{:<{}}".format(lbl, w.get(lbl, 13)) for _, lbl in FIELDS) + print(hdr) + print("-" * len(hdr)) + for r in rows: + line = "{:<26}".format(r["backend"]) + for key, _ in FIELDS: + v = r.get(key) + if isinstance(v, list): + v = ",".join(x.replace("integrate", "int") for x in v) or "-" + elif isinstance(v, bool): + v = "yes" if v else "-" + line += "{:<{}}".format(str(v), w.get(dict(FIELDS)[key], 13)) + print(line) + + print("\nTHE TRAPS, spelled out:") + print(" * _rvs['integrand'] HOLDS THREE DIFFERENT THINGS:") + for kind in ("linear", "log (aliased)", "L or lnL (kwarg)"): + who = [r["backend"] for r in rows if r.get("integrand_holds") == kind] + print(" {:<20} {}".format(kind, ", ".join(who) or "none")) + print(" The kwarg case is the dangerous one: the column's meaning is a RUNTIME property") + print(" of how the pass was called, so reading the consumer cannot tell you which it is.") + print(" That is why ln_weights_from_rvs REQUIRES use_lnL to be passed explicitly, and") + print(" why it must be the stored convention rather than opts.internal_use_lnL.") + print(" * ENTRY POINT is not the convention either: a backend with only `integrate` takes") + print(" a LINEAR callable, and feeding it a log one makes the fair draw compute NEGATIVE") + print(" weights and raise. Downstream the same mistake does NOT raise -- it takes log()") + print(" of a log and returns a plausible, wrong, almost-flat weight vector.") + no_res = [r["backend"] for r in rows if not r["keeps_warm_seed_reserve"]] + print(" * NO warm-seed reserve: {}".format(", ".join(no_res) or "none")) + print(" So the L0 rescue and the sequential warm start must cope with its absence, and") + print(" RvsRecord.retained_points() answers None rather than pretending.") + print(" * _rvs CONTENTS differ: the portfolio keeps EVERY draw (including -inf rows), AV") + print(" only the retained subset -- ~92 MB vs ~0.9 MB per million nmax (measured,") + print(" measure_retained_set_memory.py). 'n_retained' means different things.") + print("\nPer-backend _rvs keys:") + for r in rows: + print(" {:<26} {}".format(r["backend"], ", ".join(r["rvs_keys"]) or "-")) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/MonteCarloMarginalizeCode/Code/test/expensive_before_merging/integrators/audit_lisa_driver_drift.py b/MonteCarloMarginalizeCode/Code/test/expensive_before_merging/integrators/audit_lisa_driver_drift.py new file mode 100644 index 000000000..94ced56c9 --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/test/expensive_before_merging/integrators/audit_lisa_driver_drift.py @@ -0,0 +1,272 @@ +#!/usr/bin/env python3 +""" +Audit: what the main ILE driver has that the LISA ILE driver does not. + +The two drivers are a DELIBERATE FORK (RO, 2026-08-13: "the overhead of one ring to +rule them all is too high"). This script does not argue with that. It makes the +consequence -- drift -- mechanically visible, so the fork stays a choice rather than +an accident. + + bin/integrate_likelihood_extrinsic_batchmode <- main, moves fast + bin/integrate_likelihood_extrinsic_batchmode_lisa <- LISA, lags + +Both import the SAME integrators (``mcsampler``, ``mcsamplerEnsemble``, ``mcsamplerGPU``, +``mcsamplerAdaptiveVolume``, ``mcsamplerPortfolio``), so anything landed in +``RIFT/integrators/`` already reaches LISA. The drift measured here is entirely in the +driver: helpers, CLI options, module constants and sampler provenance markers. + +WHAT IS EXTRACTED +----------------- +``FUNC`` ``def`` names, qualified by enclosing function (``analyze_event._foo``), so a + nested helper is not confused with a top-level one of the same name. +``OPTION`` ``--foo`` literals passed to ``add_option``/``add_argument``. These drivers + use ``optparse``; both call forms are scanned so a future port to argparse + does not silently empty this category. +``CONST`` module-level ``UPPER_CASE`` assignments -- the sentinels (``_SEQ_WS_PENDING``) + and tuning constants that travel with a feature. +``ATTR`` provenance markers set/read on the sampler object (``_rvs_is_fairdraw``, + ``_warm_seed_reserve``, ...). These are the fair-draw correctness family + from PR #87 and are the reason this audit exists. + +THE LEDGER +---------- +Every gap item needs a recorded decision in ``lisa_drift_ledger.json``: + +``PORT`` belongs in LISA and is not there yet -- an open work item. +``PORTED`` carried across; the item should have disappeared from the gap, so a + ``PORTED`` entry still showing up in the gap is itself an error. +``NA`` does not apply to LISA, WITH A REASON. "Does not apply" is a fine answer; + silence is not. +``PHYSICS`` needs a physics decision before it can be answered, with the question + recorded verbatim. + +USAGE +----- + python3 audit_lisa_driver_drift.py # human-readable gap report + python3 audit_lisa_driver_drift.py --summary # counts per category and decision + python3 audit_lisa_driver_drift.py --json # machine-readable + python3 audit_lisa_driver_drift.py --undecided # only items with no ledger entry + python3 audit_lisa_driver_drift.py --check # CI gate: exit 1 on an undecided item + +``--check`` is the CI form, and it is deliberately weak about physics: it does not assert +that the gap is empty, or that any particular item was ported. Closing the gap is not the +goal -- the fork is intentional. It asserts only that no item drifted in unnoticed. A new +helper or option in the main driver fails the build until a person classifies it, which is +the property we want and the one that was missing when 2,357 lines accumulated. + +Keyed by NAME, not by source hash (the fair-draw audit next door keys by hash because it +tracks reads of one attribute, which move). Names are the stable identity here: renaming a +helper in the main driver SHOULD invalidate its verdict, since the thing being tracked is +"does LISA have this", and a rename means nobody has answered that about the new name. + +Needs Python >= 3.8. +""" +import argparse +import ast +import json +import os +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +CODE_ROOT = os.path.abspath(os.path.join(HERE, "..", "..", "..")) + +MAIN = "bin/integrate_likelihood_extrinsic_batchmode" +LISA = "bin/integrate_likelihood_extrinsic_batchmode_lisa" + +LEDGER = os.path.join(HERE, "lisa_drift_ledger.json") + +DECISIONS = ("PORT", "PORTED", "NA", "PHYSICS") + +# Sampler attributes worth tracking as provenance markers. Prefix-matched. Kept narrow +# on purpose: every one of these is a boolean or a record describing MUTABLE SHARED STATE, +# which is the shape that produced six defects in PR #87 (see RVS_FAIRDRAW_AUDIT.md). +ATTR_PREFIXES = ("_rvs_is", "_warm_seed", "_retained", "_export_") + + +def _is_str(node): + return isinstance(node, ast.Constant) and isinstance(node.value, str) + + +class _Collector(ast.NodeVisitor): + def __init__(self): + self.funcs = {} # qualified name -> lineno + self.options = {} # "--foo" -> lineno + self.consts = {} # NAME -> lineno + self.attrs = {} # attr name -> lineno + self._stack = [] + + def visit_FunctionDef(self, node): + qual = ".".join(self._stack + [node.name]) + self.funcs.setdefault(qual, node.lineno) + self._stack.append(node.name) + self.generic_visit(node) + self._stack.pop() + + visit_AsyncFunctionDef = visit_FunctionDef + + def visit_Call(self, node): + func = node.func + if isinstance(func, ast.Attribute) and func.attr in ("add_option", "add_argument"): + for arg in node.args: + if _is_str(arg) and arg.value.startswith("--"): + self.options.setdefault(arg.value, node.lineno) + # getattr(sampler, '_rvs_is_fairdraw', False) names an attribute just as much as + # sampler._rvs_is_fairdraw does, and the defensive getattr form is the one the + # provenance READERS use. Missing it would let a real port look like a no-op. + if isinstance(func, ast.Name) and func.id in ("getattr", "setattr", "hasattr"): + for arg in node.args[1:2]: + if _is_str(arg) and any(arg.value.startswith(p) for p in ATTR_PREFIXES): + self.attrs.setdefault(arg.value, node.lineno) + self.generic_visit(node) + + def visit_Assign(self, node): + if not self._stack: + for tgt in node.targets: + name = getattr(tgt, "id", None) + if name and name.upper() == name and any(c.isalpha() for c in name): + self.consts.setdefault(name, node.lineno) + self.generic_visit(node) + + def visit_Attribute(self, node): + if any(node.attr.startswith(p) for p in ATTR_PREFIXES): + self.attrs.setdefault(node.attr, node.lineno) + self.generic_visit(node) + + +def collect(relpath): + path = os.path.join(CODE_ROOT, relpath) + with open(path) as fh: + tree = ast.parse(fh.read(), filename=path) + c = _Collector() + c.visit(tree) + return {"FUNC": c.funcs, "OPTION": c.options, "CONST": c.consts, "ATTR": c.attrs} + + +def compute_gap(): + """Items present in the main driver and absent from the LISA driver. + + Returns (gap, extras) where gap is a list of dicts and extras lists LISA-only + items -- reported but never gated, since LISA is allowed its own surface. + """ + main = collect(MAIN) + lisa = collect(LISA) + gap, extras = [], [] + # A FUNC is satisfied by its BARE name as well as its qualified one. The main driver has + # ONE analyze_event and nests helpers inside it; this driver has TWO (analyze_event_LISA + # and analyze_event), so a helper ported here must be hoisted to module level or else + # duplicated -- and duplicating is the failure mode this audit exists to prevent. Without + # this, every correctly-hoisted port would sit in the gap forever as a false positive, + # which is how a gate gets trained out of people. + _lisa_bare = {n.rsplit(".", 1)[-1] for n in lisa["FUNC"]} + for cat in ("FUNC", "OPTION", "CONST", "ATTR"): + for name in sorted(set(main[cat]) - set(lisa[cat])): + if cat == "FUNC" and name.rsplit(".", 1)[-1] in _lisa_bare: + continue + gap.append({"category": cat, "name": name, + "key": "%s:%s" % (cat, name), "main_line": main[cat][name]}) + for name in sorted(set(lisa[cat]) - set(main[cat])): + extras.append({"category": cat, "name": name, "lisa_line": lisa[cat][name]}) + return gap, extras + + +def load_ledger(): + """Missing ledger => empty => --check reports every item, which is the safe direction.""" + if not os.path.exists(LEDGER): + return {} + with open(LEDGER) as fh: + raw = json.load(fh) + return raw.get("entries", raw) + + +def annotate(gap, ledger): + for item in gap: + entry = ledger.get(item["key"]) + item["decision"] = entry.get("decision") if entry else None + item["reason"] = entry.get("reason") if entry else None + return gap + + +def main(): + ap = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--json", action="store_true") + ap.add_argument("--summary", action="store_true") + ap.add_argument("--undecided", action="store_true") + ap.add_argument("--check", action="store_true") + args = ap.parse_args() + + gap, extras = compute_gap() + ledger = load_ledger() + gap = annotate(gap, ledger) + + undecided = [g for g in gap if g["decision"] is None] + # A PORTED item that is still missing from LISA means the ledger is lying about the + # tree -- either the port was reverted or it never landed. Louder than undecided. + stale = [g for g in gap if g["decision"] == "PORTED"] + # A ledger entry naming an item no longer in the gap is spent: either it was ported + # (good) or the main driver dropped it (also fine). Not a failure, but worth showing + # so the ledger does not accumulate fiction. + gap_keys = {g["key"] for g in gap} + spent = sorted(k for k in ledger if k not in gap_keys) + + if args.json: + json.dump({"gap": gap, "lisa_only": extras, "undecided": len(undecided), + "stale_ported": [s["key"] for s in stale], "spent_entries": spent}, + sys.stdout, indent=2, sort_keys=True) + print() + return 0 + + if args.summary: + print("LISA driver drift: %d items in main and absent from lisa" % len(gap)) + for cat in ("FUNC", "OPTION", "CONST", "ATTR"): + rows = [g for g in gap if g["category"] == cat] + if not rows: + continue + counts = {} + for r in rows: + counts[r["decision"] or "UNDECIDED"] = counts.get(r["decision"] or "UNDECIDED", 0) + 1 + detail = " ".join("%s=%d" % (k, counts[k]) for k in sorted(counts)) + print(" %-7s %3d %s" % (cat, len(rows), detail)) + print(" LISA-only surface (never gated): %d" % len(extras)) + if spent: + print(" spent ledger entries (no longer in gap): %d" % len(spent)) + return 0 + + rows = undecided if args.undecided else gap + if args.undecided and not rows: + print("no undecided items: every gap item carries a recorded decision") + for cat in ("FUNC", "OPTION", "CONST", "ATTR"): + sel = [g for g in rows if g["category"] == cat] + if not sel: + continue + print("=== %s (%d)" % (cat, len(sel))) + for g in sel: + print(" %-12s %-52s main:%d" % (g["decision"] or "UNDECIDED", g["name"], g["main_line"])) + if g["reason"]: + print(" %s" % g["reason"]) + print() + + if args.check: + rc = 0 + if undecided: + print("FAIL: %d gap item(s) carry no decision in %s" % ( + len(undecided), os.path.basename(LEDGER)), file=sys.stderr) + for g in undecided: + print(" %s (main:%d)" % (g["key"], g["main_line"]), file=sys.stderr) + print("\nClassify each as PORT / PORTED / NA / PHYSICS with a reason.", + file=sys.stderr) + rc = 1 + if stale: + print("FAIL: %d item(s) marked PORTED are still absent from the LISA driver:" + % len(stale), file=sys.stderr) + for g in stale: + print(" %s" % g["key"], file=sys.stderr) + rc = 1 + if rc == 0: + print("OK: all %d gap items carry a recorded decision" % len(gap)) + return rc + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/MonteCarloMarginalizeCode/Code/test/expensive_before_merging/integrators/backend_contracts.json b/MonteCarloMarginalizeCode/Code/test/expensive_before_merging/integrators/backend_contracts.json new file mode 100644 index 000000000..fa0db3766 --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/test/expensive_before_merging/integrators/backend_contracts.json @@ -0,0 +1,144 @@ +{ + "mcsampler": { + "backend": "mcsampler", + "builds_reserve": false, + "entry_points": [ + "integrate" + ], + "has_bootstrap_from_samples": false, + "has_clear_warm_state": false, + "has_reset_sampling": false, + "has_return_lnI_kwarg": false, + "integrand_holds": "linear", + "keeps_warm_seed_reserve": false, + "n_rebind_sites": 1, + "rvs_keys": [ + "integrand", + "joint_prior", + "joint_s_prior", + "sample_n", + "weights" + ], + "sets_rvs_record": true + }, + "mcsamplerAdaptiveVolume": { + "backend": "mcsamplerAdaptiveVolume", + "builds_reserve": true, + "entry_points": [ + "integrate", + "integrate_log" + ], + "has_bootstrap_from_samples": true, + "has_clear_warm_state": false, + "has_reset_sampling": false, + "has_return_lnI_kwarg": false, + "integrand_holds": "log (aliased)", + "keeps_warm_seed_reserve": true, + "n_rebind_sites": 1, + "rvs_keys": [ + "integrand", + "log_integrand", + "log_joint_prior", + "log_joint_s_prior" + ], + "sets_rvs_record": true + }, + "mcsamplerEnsemble": { + "backend": "mcsamplerEnsemble", + "builds_reserve": false, + "entry_points": [ + "integrate", + "integrate_log" + ], + "has_bootstrap_from_samples": true, + "has_clear_warm_state": false, + "has_reset_sampling": false, + "has_return_lnI_kwarg": true, + "integrand_holds": "L or lnL (kwarg)", + "keeps_warm_seed_reserve": false, + "n_rebind_sites": 1, + "rvs_keys": [ + "integrand", + "joint_prior", + "joint_s_prior", + "log_integrand", + "log_joint_prior", + "log_joint_s_prior", + "log_weights" + ], + "sets_rvs_record": true + }, + "mcsamplerGPU": { + "backend": "mcsamplerGPU", + "builds_reserve": false, + "entry_points": [ + "integrate", + "integrate_log" + ], + "has_bootstrap_from_samples": false, + "has_clear_warm_state": false, + "has_reset_sampling": true, + "has_return_lnI_kwarg": false, + "integrand_holds": "linear", + "keeps_warm_seed_reserve": false, + "n_rebind_sites": 2, + "rvs_keys": [ + "integrand", + "joint_prior", + "joint_s_prior", + "log_integrand", + "log_joint_prior", + "log_joint_s_prior", + "log_weights", + "sample_n", + "weights" + ], + "sets_rvs_record": true + }, + "mcsamplerNFlow": { + "backend": "mcsamplerNFlow", + "builds_reserve": false, + "entry_points": [ + "integrate", + "integrate_log" + ], + "has_bootstrap_from_samples": false, + "has_clear_warm_state": false, + "has_reset_sampling": false, + "has_return_lnI_kwarg": false, + "integrand_holds": "log (aliased)", + "keeps_warm_seed_reserve": false, + "n_rebind_sites": 1, + "rvs_keys": [ + "integrand", + "log_integrand", + "log_joint_prior", + "log_joint_s_prior" + ], + "sets_rvs_record": true + }, + "mcsamplerPortfolio": { + "backend": "mcsamplerPortfolio", + "builds_reserve": true, + "entry_points": [ + "integrate", + "integrate_log" + ], + "has_bootstrap_from_samples": true, + "has_clear_warm_state": true, + "has_reset_sampling": false, + "has_return_lnI_kwarg": false, + "integrand_holds": "log (aliased)", + "keeps_warm_seed_reserve": true, + "n_rebind_sites": 1, + "rvs_keys": [ + "integrand", + "log_integrand", + "log_joint_prior", + "log_joint_s_prior", + "log_weights", + "sample_n" + ], + "sets_rvs_record": true + } +} diff --git a/MonteCarloMarginalizeCode/Code/test/expensive_before_merging/integrators/lisa_drift_ledger.json b/MonteCarloMarginalizeCode/Code/test/expensive_before_merging/integrators/lisa_drift_ledger.json new file mode 100644 index 000000000..b17f53984 --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/test/expensive_before_merging/integrators/lisa_drift_ledger.json @@ -0,0 +1,349 @@ +{ + "_comment": "GENERATED by make_lisa_drift_ledger.py -- edit the RULES there, not this file.", + "entries": { + "CONST:_REPARAM_A_MAX": { + "decision": "PORT", + "reason": "Tuning constants for --internal-reparam-dl-incl; port verbatim, re-tune only if the measurement says the axis helps." + }, + "CONST:_REPARAM_A_MIN": { + "decision": "PORT", + "reason": "Tuning constants for --internal-reparam-dl-incl; port verbatim, re-tune only if the measurement says the axis helps." + }, + "CONST:_REPARAM_LNF": { + "decision": "PORT", + "reason": "Tuning constants for --internal-reparam-dl-incl; port verbatim, re-tune only if the measurement says the axis helps." + }, + "CONST:_SEQ_WS_PENDING": { + "decision": "PORT", + "reason": "Sentinel for the deferred sequential warm-start capture; ports with --sampler-sequential-warmstart." + }, + "CONST:_TI_LEGACY_BOOLEAN": { + "decision": "PORT", + "reason": "Legacy-boolean vocabulary for --interpolate-time. Main (PR #97) now accepts STENCIL NAMES there -- nearest/cubic/sinc -- normalizing into opts._noloop_time_interp, with this tuple for back-compat and an explicit typo guard so a misspelling is not absorbed as falsey. LISA still passes the raw --interpolate-time value straight to the likelihood, so porting means normalizing it AND teaching the LISA time path the stencil name; it travels with _normalize_interpolate_time_argv and _truthy_option." + }, + "FUNC:_cal_rng": { + "decision": "NA", + "reason": "Per-stream RNG for the calibration-side auxiliary draws (the error probe and the adaptive growth of the cal draw set), so those stay reproducible under --seed instead of taking fresh OS entropy. Calibration-envelope internals; see the --calibration-* reason. NOT a seeding gap on the LISA side: this is a thin per-stream counter over RIFT.integrators.seeding.derived_rng, which is a shared module both drivers already import, and the LISA driver calls seed_everything on the same footing as the main one. If LISA ever models calibration, it wants derived_rng directly, not this wrapper." + }, + "FUNC:_cal_setup_prior_with_nodes": { + "decision": "NA", + "reason": "Calibration-envelope internals; see the --calibration-* reason." + }, + "FUNC:_draw_more_calibration_draws": { + "decision": "NA", + "reason": "Calibration-envelope internals; see the --calibration-* reason." + }, + "FUNC:_normalize_interpolate_time_argv": { + "decision": "PORT", + "reason": "Normalizes --interpolate-time argv forms. LISA exposes --interpolate-time, so the same normalization applies." + }, + "FUNC:_reparam_A_of_incl": { + "decision": "PORT", + "reason": "Implementation of --internal-reparam-dl-incl; ports with it, measured before default-on." + }, + "FUNC:_truthy_option": { + "decision": "PORT", + "reason": "Tolerant truthiness for optparse values that may arrive as strings from the pipe. Belongs with _normalize_interpolate_time_argv, its ONLY caller in the main driver (opts._noloop_time_interp), not with the fair-draw family -- porting it alongside those helpers would have added dead code to the LISA driver." + }, + "FUNC:analyze_event._cal_error_probe": { + "decision": "NA", + "reason": "Calibration Monte-Carlo error probe; see the --calibration-* reason." + }, + "FUNC:analyze_event._cal_error_probe._draw_dist": { + "decision": "NA", + "reason": "Calibration Monte-Carlo error probe; see the --calibration-* reason." + }, + "FUNC:dLofz": { + "decision": "PORT", + "reason": "Cosmology helpers behind --d-prior-redshift. Planck15 via the framework helper; the interpolation grid still needs a z ceiling that covers MBHB (z~20), which is a gridding choice rather than a physics decision." + }, + "FUNC:dVdz": { + "decision": "PORT", + "reason": "Cosmology helpers behind --d-prior-redshift. Planck15 via the framework helper; the interpolation grid still needs a z ceiling that covers MBHB (z~20), which is a gridding choice rather than a physics decision." + }, + "OPTION:--calibration-burn-in-neff": { + "decision": "NA", + "reason": "LIGO/Virgo spline calibration-envelope marginalization. The LISA driver models no instrument calibration: it takes no envelope directory, has no cal nodes, and its response is applied analytically by factored_likelihood_LISA. LISA calibration, if it is ever modelled, will not have this data product or this spline parameterization, so porting the LIGO machinery would be actively misleading." + }, + "OPTION:--calibration-burn-in-nmax": { + "decision": "NA", + "reason": "LIGO/Virgo spline calibration-envelope marginalization. The LISA driver models no instrument calibration: it takes no envelope directory, has no cal nodes, and its response is applied analytically by factored_likelihood_LISA. LISA calibration, if it is ever modelled, will not have this data product or this spline parameterization, so porting the LIGO machinery would be actively misleading." + }, + "OPTION:--calibration-conjugate-phase": { + "decision": "NA", + "reason": "LIGO/Virgo spline calibration-envelope marginalization. The LISA driver models no instrument calibration: it takes no envelope directory, has no cal nodes, and its response is applied analytically by factored_likelihood_LISA. LISA calibration, if it is ever modelled, will not have this data product or this spline parameterization, so porting the LIGO machinery would be actively misleading." + }, + "OPTION:--calibration-dump-responsibilities": { + "decision": "NA", + "reason": "LIGO/Virgo spline calibration-envelope marginalization. The LISA driver models no instrument calibration: it takes no envelope directory, has no cal nodes, and its response is applied analytically by factored_likelihood_LISA. LISA calibration, if it is ever modelled, will not have this data product or this spline parameterization, so porting the LIGO machinery would be actively misleading." + }, + "OPTION:--calibration-envelope-directory": { + "decision": "NA", + "reason": "LIGO/Virgo spline calibration-envelope marginalization. The LISA driver models no instrument calibration: it takes no envelope directory, has no cal nodes, and its response is applied analytically by factored_likelihood_LISA. LISA calibration, if it is ever modelled, will not have this data product or this spline parameterization, so porting the LIGO machinery would be actively misleading." + }, + "OPTION:--calibration-export-posterior": { + "decision": "NA", + "reason": "LIGO/Virgo spline calibration-envelope marginalization. The LISA driver models no instrument calibration: it takes no envelope directory, has no cal nodes, and its response is applied analytically by factored_likelihood_LISA. LISA calibration, if it is ever modelled, will not have this data product or this spline parameterization, so porting the LIGO machinery would be actively misleading." + }, + "OPTION:--calibration-fused-kernel": { + "decision": "NA", + "reason": "LIGO/Virgo spline calibration-envelope marginalization. The LISA driver models no instrument calibration: it takes no envelope directory, has no cal nodes, and its response is applied analytically by factored_likelihood_LISA. LISA calibration, if it is ever modelled, will not have this data product or this spline parameterization, so porting the LIGO machinery would be actively misleading." + }, + "OPTION:--calibration-global-norm": { + "decision": "NA", + "reason": "LIGO/Virgo spline calibration-envelope marginalization. The LISA driver models no instrument calibration: it takes no envelope directory, has no cal nodes, and its response is applied analytically by factored_likelihood_LISA. LISA calibration, if it is ever modelled, will not have this data product or this spline parameterization, so porting the LIGO machinery would be actively misleading." + }, + "OPTION:--calibration-mc-error-extrinsic": { + "decision": "NA", + "reason": "LIGO/Virgo spline calibration-envelope marginalization. The LISA driver models no instrument calibration: it takes no envelope directory, has no cal nodes, and its response is applied analytically by factored_likelihood_LISA. LISA calibration, if it is ever modelled, will not have this data product or this spline parameterization, so porting the LIGO machinery would be actively misleading." + }, + "OPTION:--calibration-n-realizations": { + "decision": "NA", + "reason": "LIGO/Virgo spline calibration-envelope marginalization. The LISA driver models no instrument calibration: it takes no envelope directory, has no cal nodes, and its response is applied analytically by factored_likelihood_LISA. LISA calibration, if it is ever modelled, will not have this data product or this spline parameterization, so porting the LIGO machinery would be actively misleading." + }, + "OPTION:--calibration-n-realizations-max": { + "decision": "NA", + "reason": "LIGO/Virgo spline calibration-envelope marginalization. The LISA driver models no instrument calibration: it takes no envelope directory, has no cal nodes, and its response is applied analytically by factored_likelihood_LISA. LISA calibration, if it is ever modelled, will not have this data product or this spline parameterization, so porting the LIGO machinery would be actively misleading." + }, + "OPTION:--calibration-neff-cal-target": { + "decision": "NA", + "reason": "LIGO/Virgo spline calibration-envelope marginalization. The LISA driver models no instrument calibration: it takes no envelope directory, has no cal nodes, and its response is applied analytically by factored_likelihood_LISA. LISA calibration, if it is ever modelled, will not have this data product or this spline parameterization, so porting the LIGO machinery would be actively misleading." + }, + "OPTION:--calibration-pilot-extrinsic": { + "decision": "NA", + "reason": "LIGO/Virgo spline calibration-envelope marginalization. The LISA driver models no instrument calibration: it takes no envelope directory, has no cal nodes, and its response is applied analytically by factored_likelihood_LISA. LISA calibration, if it is ever modelled, will not have this data product or this spline parameterization, so porting the LIGO machinery would be actively misleading." + }, + "OPTION:--calibration-proposal-breadcrumb": { + "decision": "NA", + "reason": "LIGO/Virgo spline calibration-envelope marginalization. The LISA driver models no instrument calibration: it takes no envelope directory, has no cal nodes, and its response is applied analytically by factored_likelihood_LISA. LISA calibration, if it is ever modelled, will not have this data product or this spline parameterization, so porting the LIGO machinery would be actively misleading." + }, + "OPTION:--calibration-spline-count": { + "decision": "NA", + "reason": "LIGO/Virgo spline calibration-envelope marginalization. The LISA driver models no instrument calibration: it takes no envelope directory, has no cal nodes, and its response is applied analytically by factored_likelihood_LISA. LISA calibration, if it is ever modelled, will not have this data product or this spline parameterization, so porting the LIGO machinery would be actively misleading." + }, + "OPTION:--check-good-enough": { + "decision": "PORT", + "reason": "Early-exit when the pipeline has written an 'ile_good_enough' sentinel. Pipeline plumbing, detector-agnostic." + }, + "OPTION:--d-prior-redshift": { + "decision": "PORT", + "reason": "ANSWERED (RO 2026-08-16): Planck15 via the framework helper, RIFT.likelihood.priors_utils.get_astropy_cosmology('Planck15'). RESOLVED AT SOURCE -- the MAIN driver has been moved to that helper too (it previously built its own FlatLambdaCDM from lal.H0_SI/lal.OMEGA_M = 67.900/0.3065 with a hardcoded fallback), so there is no divergence to port around: both codes now ask the same helper and a change is made in one place. Pinned by test_cosmology_single_source.py." + }, + "OPTION:--distance-slice-all-fresh": { + "decision": "NA", + "reason": "The .dslice export and its placement/tuning knobs. This is a data product for a downstream LIGO CIP distance workflow that the LISA pipeline does not run; there is no consumer. If a LISA distance workflow is ever built, note that the .dslice reweight core was the third Finding-2 site and must not be revived in its pre-#87 form." + }, + "OPTION:--distance-slice-chunk": { + "decision": "NA", + "reason": "The .dslice export and its placement/tuning knobs. This is a data product for a downstream LIGO CIP distance workflow that the LISA pipeline does not run; there is no consumer. If a LISA distance workflow is ever built, note that the .dslice reweight core was the third Finding-2 site and must not be revived in its pre-#87 form." + }, + "OPTION:--distance-slice-randomize": { + "decision": "NA", + "reason": "The .dslice export and its placement/tuning knobs. This is a data product for a downstream LIGO CIP distance workflow that the LISA pipeline does not run; there is no consumer. If a LISA distance workflow is ever built, note that the .dslice reweight core was the third Finding-2 site and must not be revived in its pre-#87 form." + }, + "OPTION:--distance-slice-skip-threshold": { + "decision": "NA", + "reason": "The .dslice export and its placement/tuning knobs. This is a data product for a downstream LIGO CIP distance workflow that the LISA pipeline does not run; there is no consumer. If a LISA distance workflow is ever built, note that the .dslice reweight core was the third Finding-2 site and must not be revived in its pre-#87 form." + }, + "OPTION:--distance-slice-wing-delta-lnL": { + "decision": "NA", + "reason": "The .dslice export and its placement/tuning knobs. This is a data product for a downstream LIGO CIP distance workflow that the LISA pipeline does not run; there is no consumer. If a LISA distance workflow is ever built, note that the .dslice reweight core was the third Finding-2 site and must not be revived in its pre-#87 form." + }, + "OPTION:--distance-slice-wing-neff": { + "decision": "NA", + "reason": "The .dslice export and its placement/tuning knobs. This is a data product for a downstream LIGO CIP distance workflow that the LISA pipeline does not run; there is no consumer. If a LISA distance workflow is ever built, note that the .dslice reweight core was the third Finding-2 site and must not be revived in its pre-#87 form." + }, + "OPTION:--distance-slice-wing-nmax": { + "decision": "NA", + "reason": "The .dslice export and its placement/tuning knobs. This is a data product for a downstream LIGO CIP distance workflow that the LISA pipeline does not run; there is no consumer. If a LISA distance workflow is ever built, note that the .dslice reweight core was the third Finding-2 site and must not be revived in its pre-#87 form." + }, + "OPTION:--e-freq": { + "decision": "NA", + "reason": "TEOBResumS eccentric-frequency convention. Tied to a ground-based eccentric waveform path the LISA driver does not offer (it takes --modes / h5 frames)." + }, + "OPTION:--export-distance-slices": { + "decision": "NA", + "reason": "The .dslice export and its placement/tuning knobs. This is a data product for a downstream LIGO CIP distance workflow that the LISA pipeline does not run; there is no consumer. If a LISA distance workflow is ever built, note that the .dslice reweight core was the third Finding-2 site and must not be revived in its pre-#87 form." + }, + "OPTION:--export-marginal-distance-grid": { + "decision": "NA", + "reason": "The .dgrid export. Same absent consumer as .dslice, and the second Finding-2 double-weighting site." + }, + "OPTION:--extrinsic-proposal-adapt": { + "decision": "PORT", + "reason": "Consumes the breadcrumb above. Ports with it." + }, + "OPTION:--extrinsic-proposal-breadcrumb": { + "decision": "PORT", + "reason": "Consumes the breadcrumb above. Ports with it." + }, + "OPTION:--extrinsic-proposal-field": { + "decision": "PORT", + "reason": "AV proposal-field handoff, built by util_BuildProposalField.py from a previous ILE iteration. Sampler-agnostic; blocked only on the LISA pipeline growing that stage, so it is a work item rather than an exclusion." + }, + "OPTION:--extrinsic-proposal-field-cover-frac": { + "decision": "PORT", + "reason": "AV proposal-field handoff, built by util_BuildProposalField.py from a previous ILE iteration. Sampler-agnostic; blocked only on the LISA pipeline growing that stage, so it is a work item rather than an exclusion." + }, + "OPTION:--extrinsic-proposal-field-inflate": { + "decision": "PORT", + "reason": "AV proposal-field handoff, built by util_BuildProposalField.py from a previous ILE iteration. Sampler-agnostic; blocked only on the LISA pipeline growing that stage, so it is a work item rather than an exclusion." + }, + "OPTION:--extrinsic-proposal-output": { + "decision": "PORT", + "reason": "Fits the run's extrinsic posterior to a GMM and writes it as a breadcrumb. This is one of the three Finding-2 double-weighting sites, so it MUST be ported on top of ln_weights_for_posterior (done here) and never with a bare w." + }, + "OPTION:--fairdraw-extrinsic-output-n-max": { + "decision": "PORT", + "reason": "Caps rows per fair-draw export. LISA currently hardcodes this to opts.n_eff at the igrand_fairdraw_samples_max call site. WARNING for the port: main's default is 5, so adopting main's default verbatim would silently shrink every LISA export by orders of magnitude. Port the flag with LISA's present behaviour as its default." + }, + "OPTION:--freqresponse": { + "decision": "NA", + "reason": "Finite light-travel-time transfer across the arms for 3G ground detectors (CE/ET), built on lalsimulation detector geometry and an arm-length override in metres. LISA's finite-size response is not an add-on: it is the whole point of the TDI response the LISA driver already applies." + }, + "OPTION:--freqresponse-arm-length": { + "decision": "NA", + "reason": "Finite light-travel-time transfer across the arms for 3G ground detectors (CE/ET), built on lalsimulation detector geometry and an arm-length override in metres. LISA's finite-size response is not an add-on: it is the whole point of the TDI response the LISA driver already applies." + }, + "OPTION:--freqresponse-qmax": { + "decision": "NA", + "reason": "Finite light-travel-time transfer across the arms for 3G ground detectors (CE/ET), built on lalsimulation detector geometry and an arm-length override in metres. LISA's finite-size response is not an add-on: it is the whole point of the TDI response the LISA driver already applies." + }, + "OPTION:--internal-data-storage-window-half": { + "decision": "NA", + "reason": "Half-width of the main driver's internal precompute storage window. The LISA driver has its own equivalent under a different name, --data-integration-window-half, which it passes straight into PrecomputeAlignedSpinLISA. Same role, already present." + }, + "OPTION:--internal-gmm-adaptive-components": { + "decision": "PORT", + "reason": "mcsamplerEnsemble (GMM) tuning. LISA wires that sampler and exposes the same 'GMM' method string, so these knobs are reachable physics-wise but simply not plumbed. Pure pass-through. Caveat for whoever ports --internal-gmm-sky-components: the default grouping is (sky)(distance,inclination)(psi,phi), and 'sky' for LISA is the ecliptic pair -- the grouping still makes sense, the docstring does not." + }, + "OPTION:--internal-gmm-correlate-all": { + "decision": "PORT", + "reason": "mcsamplerEnsemble (GMM) tuning. LISA wires that sampler and exposes the same 'GMM' method string, so these knobs are reachable physics-wise but simply not plumbed. Pure pass-through. Caveat for whoever ports --internal-gmm-sky-components: the default grouping is (sky)(distance,inclination)(psi,phi), and 'sky' for LISA is the ecliptic pair -- the grouping still makes sense, the docstring does not." + }, + "OPTION:--internal-gmm-defensive-frac": { + "decision": "PORT", + "reason": "mcsamplerEnsemble (GMM) tuning. LISA wires that sampler and exposes the same 'GMM' method string, so these knobs are reachable physics-wise but simply not plumbed. Pure pass-through. Caveat for whoever ports --internal-gmm-sky-components: the default grouping is (sky)(distance,inclination)(psi,phi), and 'sky' for LISA is the ecliptic pair -- the grouping still makes sense, the docstring does not." + }, + "OPTION:--internal-gmm-inflate": { + "decision": "PORT", + "reason": "mcsamplerEnsemble (GMM) tuning. LISA wires that sampler and exposes the same 'GMM' method string, so these knobs are reachable physics-wise but simply not plumbed. Pure pass-through. Caveat for whoever ports --internal-gmm-sky-components: the default grouping is (sky)(distance,inclination)(psi,phi), and 'sky' for LISA is the ecliptic pair -- the grouping still makes sense, the docstring does not." + }, + "OPTION:--internal-gmm-max-components": { + "decision": "PORT", + "reason": "mcsamplerEnsemble (GMM) tuning. LISA wires that sampler and exposes the same 'GMM' method string, so these knobs are reachable physics-wise but simply not plumbed. Pure pass-through. Caveat for whoever ports --internal-gmm-sky-components: the default grouping is (sky)(distance,inclination)(psi,phi), and 'sky' for LISA is the ecliptic pair -- the grouping still makes sense, the docstring does not." + }, + "OPTION:--internal-gmm-phase-components": { + "decision": "PORT", + "reason": "mcsamplerEnsemble (GMM) tuning. LISA wires that sampler and exposes the same 'GMM' method string, so these knobs are reachable physics-wise but simply not plumbed. Pure pass-through. Caveat for whoever ports --internal-gmm-sky-components: the default grouping is (sky)(distance,inclination)(psi,phi), and 'sky' for LISA is the ecliptic pair -- the grouping still makes sense, the docstring does not." + }, + "OPTION:--internal-gmm-sky-components": { + "decision": "PORT", + "reason": "mcsamplerEnsemble (GMM) tuning. LISA wires that sampler and exposes the same 'GMM' method string, so these knobs are reachable physics-wise but simply not plumbed. Pure pass-through. Caveat for whoever ports --internal-gmm-sky-components: the default grouping is (sky)(distance,inclination)(psi,phi), and 'sky' for LISA is the ecliptic pair -- the grouping still makes sense, the docstring does not." + }, + "OPTION:--internal-precompute-ignore-threshold": { + "decision": "PORT", + "reason": "Drops negligible modes during precompute. LISA is mode-heavy (--modes, --restricted-mode-list-file) and pays more per mode than a ground-based run, so if anything this matters more there. No LIGO-specific assumption." + }, + "OPTION:--internal-reparam-dl-incl": { + "decision": "PORT", + "reason": "ANSWERED (RO 2026-08-16): 'should be good enough; it is a testable axis though -- do not guess, measure.' So: port it, but do NOT enable by default until measured. The test is cheap and direct -- compare n_eff / lnZ scatter with and without the reparameterization on a fixed LISA MBHB intrinsic point, since if the axis is wrong for TDI it shows up as no improvement or worse conditioning, not as a bias." + }, + "OPTION:--internal-use-gwpy": { + "decision": "NA", + "reason": "gwpy low-level frame io. The LISA driver reads its data from h5 frames (--h5-frame/--h5-frame-FD), not from GWF via gwpy." + }, + "OPTION:--internal-waveform-extra-kwargs": { + "decision": "NA", + "reason": "lalsimulation taper / extra-kwargs passthrough for the ground-based waveform path. The LISA driver has its own passthroughs for the generator it uses (--internal-waveform-extra-lalsuite-args, --internal-waveform-fd-L-frame, --internal-waveform-fd-no-condition)." + }, + "OPTION:--internal-waveform-taper": { + "decision": "NA", + "reason": "lalsimulation taper / extra-kwargs passthrough for the ground-based waveform path. The LISA driver has its own passthroughs for the generator it uses (--internal-waveform-extra-lalsuite-args, --internal-waveform-fd-L-frame, --internal-waveform-fd-no-condition)." + }, + "OPTION:--limit-declination": { + "decision": "PORT", + "reason": "ANSWERED (RO 2026-08-16): LISA and LIGO are never overlapping use cases, so follow the convention already in this driver, document it in the help string, and DO NOT rename the options. VERIFIED that convention is ECLIPTIC: the sampled right_ascension/declination columns flow to P.phi/P.theta and then to lisa_sky_lamda/lisa_sky_beta, i.e. ecliptic longitude/latitude, under the historical key names. So --limit-right-ascension bounds lambda and --limit-declination bounds beta; say exactly that in the help text. Port the post-PR#58 form including the cos(iota)/cos(dec) endpoint swap under the cosine samplers." + }, + "OPTION:--limit-inclination": { + "decision": "PORT", + "reason": "Zoom-box limits on psi and inclination. These parameters mean the same thing in both drivers and LISA exposes --inclination-cosine-sampler, which is exactly the case junior PR #58 found silently ignored -- so port the POST-#58 form, including the cos(iota) endpoint swap." + }, + "OPTION:--limit-psi": { + "decision": "PORT", + "reason": "Zoom-box limits on psi and inclination. These parameters mean the same thing in both drivers and LISA exposes --inclination-cosine-sampler, which is exactly the case junior PR #58 found silently ignored -- so port the POST-#58 form, including the cos(iota) endpoint swap." + }, + "OPTION:--limit-right-ascension": { + "decision": "PORT", + "reason": "ANSWERED (RO 2026-08-16): LISA and LIGO are never overlapping use cases, so follow the convention already in this driver, document it in the help string, and DO NOT rename the options. VERIFIED that convention is ECLIPTIC: the sampled right_ascension/declination columns flow to P.phi/P.theta and then to lisa_sky_lamda/lisa_sky_beta, i.e. ecliptic longitude/latitude, under the historical key names. So --limit-right-ascension bounds lambda and --limit-declination bounds beta; say exactly that in the help text. Port the post-PR#58 form including the cos(iota)/cos(dec) endpoint swap under the cosine samplers." + }, + "OPTION:--n-distance-slice-core": { + "decision": "NA", + "reason": "The .dslice export and its placement/tuning knobs. This is a data product for a downstream LIGO CIP distance workflow that the LISA pipeline does not run; there is no consumer. If a LISA distance workflow is ever built, note that the .dslice reweight core was the third Finding-2 site and must not be revived in its pre-#87 form." + }, + "OPTION:--n-distance-slice-wing": { + "decision": "NA", + "reason": "The .dslice export and its placement/tuning knobs. This is a data product for a downstream LIGO CIP distance workflow that the LISA pipeline does not run; there is no consumer. If a LISA distance workflow is ever built, note that the .dslice reweight core was the third Finding-2 site and must not be revived in its pre-#87 form." + }, + "OPTION:--nf-flow-load": { + "decision": "PORT", + "reason": "Normalizing-flow persistence is detector-agnostic, but the LISA portfolio factory currently constructs only AV, GMM, and adaptive_cartesian_gpu members. Port the NF member construction and route load/save to that member before exposing these flags; hooks on the portfolio aggregate are a silent no-op because it has no flow API." + }, + "OPTION:--nf-flow-save": { + "decision": "PORT", + "reason": "Normalizing-flow persistence is detector-agnostic, but the LISA portfolio factory currently constructs only AV, GMM, and adaptive_cartesian_gpu members. Port the NF member construction and route load/save to that member before exposing these flags; hooks on the portfolio aggregate are a silent no-op because it has no flow API." + }, + "OPTION:--random-event": { + "decision": "PORT", + "reason": "Pick a random event from the input file. Detector-agnostic; flagged dangerous in its own help text for oversampling reasons that apply equally to LISA." + }, + "OPTION:--rotation-n-harmonics": { + "decision": "NA", + "reason": "Sidereal time-dependence of an EARTH-BASED antenna pattern F(t). The LISA constellation's motion is already carried by the LISA response itself (factored_likelihood_LISA + the h5/TDI frames), so this correction is both unnecessary and wrong there -- it would apply Earth rotation to a heliocentric detector." + }, + "OPTION:--rotation-p-max": { + "decision": "NA", + "reason": "Sidereal time-dependence of an EARTH-BASED antenna pattern F(t). The LISA constellation's motion is already carried by the LISA response itself (factored_likelihood_LISA + the h5/TDI frames), so this correction is both unnecessary and wrong there -- it would apply Earth rotation to a heliocentric detector." + }, + "OPTION:--rotation-slow": { + "decision": "NA", + "reason": "Sidereal time-dependence of an EARTH-BASED antenna pattern F(t). The LISA constellation's motion is already carried by the LISA response itself (factored_likelihood_LISA + the h5/TDI frames), so this correction is both unnecessary and wrong there -- it would apply Earth rotation to a heliocentric detector." + }, + "OPTION:--sampler-sequential-warmstart": { + "decision": "PORT", + "reason": "Warm-start each intrinsic point from the previous one's cloud. Applies whenever --n-events-to-analyze>1, which LISA supports. Its snapshot/restore prerequisites (Finding 5) already landed with the L0 rescue, so this is now capture + the event-loop wiring only." + }, + "OPTION:--sampler-sequential-warmstart-cover-frac": { + "decision": "PORT", + "reason": "Coverage floor for the above; meaningless without it, so they travel together." + }, + "OPTION:--sampler-warmstart-cover-frac": { + "decision": "PORT", + "reason": "Coverage floor and inflation for a handed-off seed. Pure geometry on the sampled unit cube." + }, + "OPTION:--sampler-warmstart-inflate": { + "decision": "PORT", + "reason": "Coverage floor and inflation for a handed-off seed. Pure geometry on the sampled unit cube." + }, + "OPTION:--sampler-warmstart-samples": { + "decision": "PORT", + "reason": "RESOLVED (RO 2026-08-16). The convention does not matter: the seed is points in the sampler's OWN coordinate space, read positionally against params_ordered, so any self-consistent choice works and the ecliptic sky answer already determines it. The hazard is only that a mismatch is UNDETECTABLE -- ecliptic lambda and RA share [0,2pi), beta and dec share [-pi/2,pi/2], so no range check separates them and a wrong-frame seed silently contracts the live volume around the wrong region. SCOPE (RO): these files are used INTERNALLY within a homogeneous run -- we are talking to ourselves, not to heterogeneous tooling -- so keep it simple: a one-line frame stamp in the file header written by the producer, warn if it is absent or disagrees. Do NOT build a validation framework for it." + }, + "OPTION:--save-meanPerAno": { + "decision": "NA", + "reason": "Exports the eccentric mean anomaly. Tied to the ground-based eccentric waveform path (see --e-freq); the LISA driver's own eccentricity export is --save-eccentricity." + }, + "OPTION:--save-samples-process-params": { + "decision": "PORT", + "reason": "Retain the process_params table in the XML output. Pure output plumbing." + }, + "OPTION:--srate-internal": { + "decision": "NA", + "reason": "Separate internal sampling rate for the ground-based precompute. LISA's precompute takes its rate from the h5 frame and P.deltaT; there is no second internal rate to set." + }, + "OPTION:--srate-resample-time-marginalization": { + "decision": "PORT", + "reason": "Interpolate the lnL time series onto a finer grid before time resampling. LISA already has --resample-time-marginalization and its own time-resampling block, so this is the matching resolution knob and applies directly." + } + } +} diff --git a/MonteCarloMarginalizeCode/Code/test/expensive_before_merging/integrators/make_lisa_drift_ledger.py b/MonteCarloMarginalizeCode/Code/test/expensive_before_merging/integrators/make_lisa_drift_ledger.py new file mode 100644 index 000000000..144fb7be8 --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/test/expensive_before_merging/integrators/make_lisa_drift_ledger.py @@ -0,0 +1,430 @@ +#!/usr/bin/env python3 +""" +Regenerate ``lisa_drift_ledger.json`` -- the recorded decision for every item the main +ILE driver has and the LISA ILE driver does not. + + python3 make_lisa_drift_ledger.py # rewrite the ledger + python3 make_lisa_drift_ledger.py --dry-run # show what would change + python3 audit_lisa_driver_drift.py --check # CI gate over the result + +The gap itself is computed by ``audit_lisa_driver_drift.py``; this file holds only the +JUDGEMENTS, as ordered (pattern -> decision + reason) rules so a whole family is decided +once. First match wins, so put specific items above their family. + +DECISIONS + PORT belongs in LISA, not there yet. An open work item. + PORTED carried across. The audit re-checks these: a PORTED item still missing from + the LISA driver fails the build. + NA does not apply to LISA, with the reason. + PHYSICS cannot be answered without a physics decision, with the question. + +An item matching NO rule is reported and left out of the ledger, so ``--check`` fails on +it. That is the intended path for newly-drifted code: it must be classified by a person. + +WHY THESE DECISIONS LOOK THE WAY THEY DO +The two drivers import the SAME integrators and expose the SAME ``ok_lnL_methods`` +(``GMM, adaptive_cartesian, adaptive_cartesian_gpu, AV, portfolio``, verified identical +2026-08-15). So anything that is pure sampler plumbing applies to LISA by construction and +is PORT; the NA items are the ones tied to a ground-based detector, to LIGO/Virgo +calibration envelopes, or to a downstream pipeline stage LISA does not run. +""" +import argparse +import json +import os +import re +import sys + +import audit_lisa_driver_drift as audit + +HERE = os.path.dirname(os.path.abspath(__file__)) +OUT = os.path.join(HERE, "lisa_drift_ledger.json") + +# --------------------------------------------------------------------------------------- +# Ordered rules. (regex over the audit key "CATEGORY:name", decision, reason) +# First match wins. +# --------------------------------------------------------------------------------------- +RULES = [ + + # ---------------------------------------------------------------- the fair-draw family + # PORTED in this pass. These are the PR #87 correctness helpers. They are pure + # functions of the _rvs record plus the sampler's own provenance markers, and the + # markers are already set by the shared integrators at all seven rebind sites, so they + # already arrive on LISA's sampler objects at runtime -- only the driver-side readers + # were missing. + (r"^FUNC:ln_weights_from_rvs$", "PORTED", + "Importance weight of an _rvs record. Pure function of the record; no extrinsic " + "coordinate assumptions. LISA sets igrand_fairdraw_samples, so its records can be " + "fair draws and need the same answer."), + (r"^FUNC:ln_weights_for_posterior$", "PORTED", + "How rows should be weighted to REPRESENT THE POSTERIOR, as distinct from their " + "importance weight. Returns zeros on an equal-weight record. This is the helper " + "that makes the w^2 double-weighting defect unrepresentable."), + (r"^FUNC:_rvs_is_export_resample$", "PORTED", + "Predicate: rows were drawn proportional to w (survives pooling). Reads the shared " + "marker the integrators already set."), + (r"^FUNC:_rvs_is_equal_weight$", "PORTED", + "Predicate: record is globally equal-weight (fairdraw and not pooled). Finding 6 " + "split this from _rvs_is_export_resample; porting one without the other rebuilds " + "the flag-answering-two-questions bug."), + (r"^FUNC:_rvs_len$", "PORTED", + "Row count of an _rvs record, tolerant of the tuple-keyed sky column. Support " + "helper for the above."), + (r"^ATTR:_rvs_is_fairdraw$", "PORTED", + "Set by all seven shared rebind sites in RIFT/integrators/, so it already reaches " + "LISA at runtime; the LISA driver simply never read it."), + (r"^ATTR:_rvs_is_pooled$", "PORTED", + "READER ONLY, deliberately. The marker is read by _rvs_is_equal_weight and carried " + "by the pass snapshot/restore; nothing in this driver ever SETS it, because there " + "is no replica pooling here yet. Main's reset-on-entry (Finding 7: the marker " + "outliving a FAILED event) is therefore NOT ported and MUST come with " + "--mc-error-replicas -- without it the first pooled record would leave the marker " + "set on the next event. Note this is also the ATTR category's blind spot: a name " + "read anywhere counts as present, so reader-ported/writer-missing looks closed."), + + # ---------------------------------------------------------------------- the _rvs record + # PORT decision, not NA: these are PREREQUISITES of helpers already marked PORTED. + # ln_weights_for_posterior / _snapshot_pass_state / _restore_pass_state call them by + # name, so leaving them out of the LISA driver does not keep the fork simpler -- it + # breaks the ported copies outright. The INTEGRATORS are shared between the two + # drivers, so the samplers already carry SamplerOutputMixin and populate a record; + # only the driver-side accessors had to come across. + (r"^FUNC:(_rvs_record_for|_sampler_keeps_records)$", "PORTED", + "Driver-side accessors for the sampler's RvsRecord: the identity-guarded lookup, " + "and the 'does this backend keep records at all' test. Prerequisites of the " + "already-PORTED ln_weights_for_posterior and the pass-state snapshot/restore."), + (r"^FUNC:(_internal_record_of|_rebound_record|_lw_of)$", "PORTED", + "The rest of the record accessor set: the INTERNAL record (handed back only so the " + "driver can thread it, never as user-facing API), the post-rebind rebuild, and the " + "weight helper. Ported as a SET with the above -- the callers reference them " + "directly, so a partial port is a NameError at runtime, not a smaller fork."), + + # ---------------------------------------------------------------------- lnZ / n_eff + (r"^FUNC:_lnZ_of_rvs$", "PORTED", + "Evidence of an _rvs record with the already_pooled/fairdraw correction. Landed " + "with the L0 rescue gate, which is its first consumer here."), + (r"^FUNC:_kish_neff_of_rvs$", "PORTED", + "Kish n_eff of a record. Landed with _lnZ_of_rvs; its own consumer (replica " + "pooling) arrives in the MC-error pass."), + (r"^FUNC:_lnZ_of_reserve_or_rvs$", "PORTED", + "Reads a pass's lnZ from the points it RETAINED where available, so the reject " + "gate is not comparing two differently-sized fair-draw artifacts."), + (r"^FUNC:(_snapshot_pass_state|_restore_pass_state)$", "PORTED", + "Snapshot/restore of everything that must travel with a put-back pass -- the " + "reserve and the fair-draw marker included (Finding 5). Ported as a SET with the " + "rescue; either one alone rebuilds the defect."), + (r"^FUNC:(_warm_seed_reserve_for|_warm_seed_geometry|_clear_warm_state)$", "PORTED", + "Shared reserve lookup (with the column-order guard), adaptive-axis geometry for " + "the rank test, and the warm-state clear that reaches portfolio MEMBERS."), + (r"^ATTR:_warm_seed_reserve$", "PORTED", + "The retained-sample reserve the rescue seeds from and the snapshot carries."), + (r"^OPTION:--sampler-warmstart-retry-neff$", "PORTED", + "The L0 rescue trigger. High value for LISA: MBHB are high-SNR, which is the " + "regime that stalls at n_eff~1."), + (r"^OPTION:--sampler-l0-rescue-", "PORTED", + "L0 rescue tuning, defaults and help text kept identical to the main driver " + "(including reject-dlnZ 3.0, the measured value -- see " + "L0_REJECT_DLNZ_MEASUREMENT.md). Pinned by test_lisa_l0_rescue.py."), + (r"^OPTION:--sampler-sequential-warmstart-deltalnL$", "PORTED", + "The lnL window build_warm_seed keeps. Consumed by the L0 rescue, so it landed " + "with that pass rather than with the sequential warm start it is named for."), + + # --------------------------------------------------------------- L0 rescue / warm start + (r"^OPTION:--reject-collapsed-live-volume$", "PORTED", + "AV live-volume collapse rejection. AV is wired in the LISA driver identically. " + "NOTE the main driver calls its gate TWICE -- first run and replica pool -- and only " + "the first call exists here, because there is no pooling yet; the second MUST be " + "added with --mc-error-replicas or the flag is bypassed for the case pooling creates."), + (r"^FUNC:analyze_event\._reject_if_collapsed$", "PORTED", + "Hoisted to module level rather than nested, because this driver has TWO " + "analyze_event variants. The audit matches FUNC items on the bare name for exactly " + "this reason."), + (r"^OPTION:--sampler-sequential-warmstart$", "PORT", + "Warm-start each intrinsic point from the previous one's cloud. Applies whenever " + "--n-events-to-analyze>1, which LISA supports. Its snapshot/restore prerequisites " + "(Finding 5) already landed with the L0 rescue, so this is now capture + the " + "event-loop wiring only."), + (r"^OPTION:--sampler-sequential-warmstart-cover-frac$", "PORT", + "Coverage floor for the above; meaningless without it, so they travel together."), + (r"^OPTION:--sampler-anisotropic-bins$", "PORTED", + "AV per-axis bin counts during contraction. AV is wired in LISA, and the argument " + "for it is if anything stronger there: the LISA extrinsic axes are no more " + "isotropic than the ground-based ones, and a sky pair that localizes tightly " + "while distance stays broad is the exact case this exists for."), + (r"^OPTION:--sampler-(save|load)-state$", "PORTED", + "AV live-volume state serialization. AV is wired in LISA; the state is the " + "sampler's own internal grid, so it carries no LIGO-specific convention."), + (r"^OPTION:--sampler-warmstart-(cover-frac|inflate)$", "PORT", + "Coverage floor and inflation for a handed-off seed. Pure geometry on the " + "sampled unit cube."), + (r"^OPTION:--sampler-warmstart-samples$", "PORT", + "RESOLVED (RO 2026-08-16). The convention does not matter: the seed is points in the " + "sampler's OWN coordinate space, read positionally against params_ordered, so any " + "self-consistent choice works and the ecliptic sky answer already determines it. The " + "hazard is only that a mismatch is UNDETECTABLE -- ecliptic lambda and RA share " + "[0,2pi), beta and dec share [-pi/2,pi/2], so no range check separates them and a " + "wrong-frame seed silently contracts the live volume around the wrong region. SCOPE " + "(RO): these files are used INTERNALLY within a homogeneous run -- we are talking to " + "ourselves, not to heterogeneous tooling -- so keep it simple: a one-line frame stamp " + "in the file header written by the producer, warn if it is absent or disagrees. Do NOT " + "build a validation framework for it."), + + # --------------------------------------------------------------------- MC error replicas + (r"^OPTION:--mc-error-(replicas|sigma-trigger|ess-trigger|khat-trigger)$", "PORTED", + "Replica-based lnL error stabilization. Triggers on weight-tail diagnostics of the " + "run's own weights; nothing detector-specific. Valuable for LISA for the same " + "reason as for high-SNR ground events: the reported sigma is the thing downstream " + "CIP trusts."), + (r"^FUNC:_pool_replica_rvs(\._block_resampled|\._block_record)?$", "PORTED", + "Pools replica records by evidence, verbatim -- including the PER-REPLICA " + "already_resampled sequence (Finding 6). A single global boolean is wrong near the " + "n_extr boundary, where a run produces a MIXTURE of raw and resampled replicas."), + (r"^FUNC:(analyze_event\.)?_extract_mc_diag$", "PORTED", + "Diagnostics for the replica triggers. Hoisted to module level (two analyze_event " + "variants); the audit matches FUNC on the bare name for exactly this reason."), + + # ------------------------------------------------------------------------ GMM plumbing + (r"^OPTION:--internal-gmm-", "PORT", + "mcsamplerEnsemble (GMM) tuning. LISA wires that sampler and exposes the same " + "'GMM' method string, so these knobs are reachable physics-wise but simply not " + "plumbed. Pure pass-through. Caveat for whoever ports --internal-gmm-sky-components: " + "the default grouping is (sky)(distance,inclination)(psi,phi), and 'sky' for LISA " + "is the ecliptic pair -- the grouping still makes sense, the docstring does not."), + + # ------------------------------------------------------------------ portfolio plumbing + (r"^OPTION:--portfolio-", "PORTED", + "mcsamplerPortfolio freeze/allocation policy. Definitions copied verbatim and the " + "_freeze_policy_kwargs assembly is textually identical to the main driver's, so " + "unset options (None) stay out of the dict and the sampler keeps its own defaults. " + "--portfolio-varaha-can-freeze wins over --portfolio-varaha-never-freeze, as there."), + + # ------------------------------------------------------------------- NF flow plumbing + (r"^OPTION:--nf-flow-(load|save)$", "PORT", + "Normalizing-flow persistence is detector-agnostic, but the LISA portfolio factory " + "currently constructs only AV, GMM, and adaptive_cartesian_gpu members. Port the NF " + "member construction and route load/save to that member before exposing these flags; " + "hooks on the portfolio aggregate are a silent no-op because it has no flow API."), + + # --------------------------------------------------------- extrinsic proposal handoff + (r"^OPTION:--extrinsic-proposal-output$", "PORT", + "Fits the run's extrinsic posterior to a GMM and writes it as a breadcrumb. This " + "is one of the three Finding-2 double-weighting sites, so it MUST be ported on top " + "of ln_weights_for_posterior (done here) and never with a bare w."), + (r"^OPTION:--extrinsic-proposal-(breadcrumb|adapt)$", "PORT", + "Consumes the breadcrumb above. Ports with it."), + (r"^OPTION:--extrinsic-proposal-field(-cover-frac|-inflate)?$", "PORT", + "AV proposal-field handoff, built by util_BuildProposalField.py from a previous " + "ILE iteration. Sampler-agnostic; blocked only on the LISA pipeline growing that " + "stage, so it is a work item rather than an exclusion."), + + # ------------------------------------------------------------------------ fair-draw size + (r"^OPTION:--fairdraw-extrinsic-output-n-max$", "PORT", + "Caps rows per fair-draw export. LISA currently hardcodes this to opts.n_eff at " + "the igrand_fairdraw_samples_max call site. WARNING for the port: main's default " + "is 5, so adopting main's default verbatim would silently shrink every LISA " + "export by orders of magnitude. Port the flag with LISA's present behaviour as " + "its default."), + + # ------------------------------------------------------- LIGO/Virgo calibration envelopes + (r"^OPTION:--calibration-", "NA", + "LIGO/Virgo spline calibration-envelope marginalization. The LISA driver models no " + "instrument calibration: it takes no envelope directory, has no cal nodes, and its " + "response is applied analytically by factored_likelihood_LISA. LISA calibration, if " + "it is ever modelled, will not have this data product or this spline parameterization, " + "so porting the LIGO machinery would be actively misleading."), + (r"^FUNC:(_cal_setup_prior_with_nodes|_draw_more_calibration_draws)$", "NA", + "Calibration-envelope internals; see the --calibration-* reason."), + (r"^FUNC:_cal_rng$", "NA", + "Per-stream RNG for the calibration-side auxiliary draws (the error probe and the " + "adaptive growth of the cal draw set), so those stay reproducible under --seed instead " + "of taking fresh OS entropy. Calibration-envelope internals; see the --calibration-* " + "reason. NOT a seeding gap on the LISA side: this is a thin per-stream counter over " + "RIFT.integrators.seeding.derived_rng, which is a shared module both drivers already " + "import, and the LISA driver calls seed_everything on the same footing as the main " + "one. If LISA ever models calibration, it wants derived_rng directly, not this wrapper."), + (r"^FUNC:analyze_event\._cal_error_probe(\._draw_dist)?$", "NA", + "Calibration Monte-Carlo error probe; see the --calibration-* reason."), + + # ------------------------------------------------------- ground-based detector geometry + (r"^OPTION:--rotation-(slow|n-harmonics|p-max)$", "NA", + "Sidereal time-dependence of an EARTH-BASED antenna pattern F(t). The LISA " + "constellation's motion is already carried by the LISA response itself " + "(factored_likelihood_LISA + the h5/TDI frames), so this correction is both " + "unnecessary and wrong there -- it would apply Earth rotation to a heliocentric " + "detector."), + (r"^OPTION:--freqresponse(-arm-length|-qmax)?$", "NA", + "Finite light-travel-time transfer across the arms for 3G ground detectors " + "(CE/ET), built on lalsimulation detector geometry and an arm-length override in " + "metres. LISA's finite-size response is not an add-on: it is the whole point of " + "the TDI response the LISA driver already applies."), + (r"^OPTION:--e-freq$", "NA", + "TEOBResumS eccentric-frequency convention. Tied to a ground-based eccentric " + "waveform path the LISA driver does not offer (it takes --modes / h5 frames)."), + + # ---------------------------------------------------------- distance slice / grid export + (r"^OPTION:--(export-distance-slices|distance-slice-|n-distance-slice-)", "NA", + "The .dslice export and its placement/tuning knobs. This is a data product for a " + "downstream LIGO CIP distance workflow that the LISA pipeline does not run; there " + "is no consumer. If a LISA distance workflow is ever built, note that the .dslice " + "reweight core was the third Finding-2 site and must not be revived in its " + "pre-#87 form."), + (r"^OPTION:--export-marginal-distance-grid$", "NA", + "The .dgrid export. Same absent consumer as .dslice, and the second Finding-2 " + "double-weighting site."), + + # ----------------------------------------------------------------- cosmology / d prior + (r"^OPTION:--d-prior-redshift$", "PORT", + "ANSWERED (RO 2026-08-16): Planck15 via the framework helper, " + "RIFT.likelihood.priors_utils.get_astropy_cosmology('Planck15'). RESOLVED AT SOURCE -- " + "the MAIN driver has been moved to that helper too (it previously built its own " + "FlatLambdaCDM from lal.H0_SI/lal.OMEGA_M = 67.900/0.3065 with a hardcoded fallback), " + "so there is no divergence to port around: both codes now ask the same helper and a " + "change is made in one place. Pinned by test_cosmology_single_source.py."), + (r"^FUNC:(dLofz|dVdz)$", "PORT", + "Cosmology helpers behind --d-prior-redshift. Planck15 via the framework helper; the " + "interpolation grid still needs a z ceiling that covers MBHB (z~20), which is a " + "gridding choice rather than a physics decision."), + + # -------------------------------------------------------------- distance/incl reparam + (r"^OPTION:--internal-reparam-dl-incl$", "PORT", + "ANSWERED (RO 2026-08-16): 'should be good enough; it is a testable axis though -- " + "do not guess, measure.' So: port it, but do NOT enable by default until measured. The " + "test is cheap and direct -- compare n_eff / lnZ scatter with and without the " + "reparameterization on a fixed LISA MBHB intrinsic point, since if the axis is wrong " + "for TDI it shows up as no improvement or worse conditioning, not as a bias."), + (r"^FUNC:_reparam_A_of_incl$", "PORT", + "Implementation of --internal-reparam-dl-incl; ports with it, measured before default-on."), + (r"^CONST:_REPARAM_", "PORT", + "Tuning constants for --internal-reparam-dl-incl; port verbatim, re-tune only if the " + "measurement says the axis helps."), + + # ---------------------------------------------------------------------- extrinsic boxes + (r"^OPTION:--limit-(psi|inclination)$", "PORT", + "Zoom-box limits on psi and inclination. These parameters mean the same thing in " + "both drivers and LISA exposes --inclination-cosine-sampler, which is exactly the " + "case junior PR #58 found silently ignored -- so port the POST-#58 form, including " + "the cos(iota) endpoint swap."), + (r"^OPTION:--limit-(right-ascension|declination)$", "PORT", + "ANSWERED (RO 2026-08-16): LISA and LIGO are never overlapping use cases, so follow " + "the convention already in this driver, document it in the help string, and DO NOT " + "rename the options. VERIFIED that convention is ECLIPTIC: the sampled " + "right_ascension/declination columns flow to P.phi/P.theta and then to " + "lisa_sky_lamda/lisa_sky_beta, i.e. ecliptic longitude/latitude, under the historical " + "key names. So --limit-right-ascension bounds lambda and --limit-declination bounds " + "beta; say exactly that in the help text. Port the post-PR#58 form including the " + "cos(iota)/cos(dec) endpoint swap under the cosine samplers."), + + # --------------------------------------------------------------------- data / waveform io + (r"^OPTION:--internal-data-storage-window-half$", "NA", + "Half-width of the main driver's internal precompute storage window. The LISA " + "driver has its own equivalent under a different name, --data-integration-window-half, " + "which it passes straight into PrecomputeAlignedSpinLISA. Same role, already present."), + (r"^OPTION:--internal-use-gwpy$", "NA", + "gwpy low-level frame io. The LISA driver reads its data from h5 frames " + "(--h5-frame/--h5-frame-FD), not from GWF via gwpy."), + (r"^OPTION:--internal-waveform-(taper|extra-kwargs)$", "NA", + "lalsimulation taper / extra-kwargs passthrough for the ground-based waveform " + "path. The LISA driver has its own passthroughs for the generator it uses " + "(--internal-waveform-extra-lalsuite-args, --internal-waveform-fd-L-frame, " + "--internal-waveform-fd-no-condition)."), + (r"^OPTION:--srate-internal$", "NA", + "Separate internal sampling rate for the ground-based precompute. LISA's " + "precompute takes its rate from the h5 frame and P.deltaT; there is no second " + "internal rate to set."), + (r"^OPTION:--srate-resample-time-marginalization$", "PORT", + "Interpolate the lnL time series onto a finer grid before time resampling. LISA " + "already has --resample-time-marginalization and its own time-resampling block, " + "so this is the matching resolution knob and applies directly."), + (r"^CONST:_TI_LEGACY_BOOLEAN$", "PORT", + "Legacy-boolean vocabulary for --interpolate-time. Main (PR #97) now accepts STENCIL " + "NAMES there -- nearest/cubic/sinc -- normalizing into opts._noloop_time_interp, with " + "this tuple for back-compat and an explicit typo guard so a misspelling is not " + "absorbed as falsey. LISA still passes the raw --interpolate-time value straight to " + "the likelihood, so porting means normalizing it AND teaching the LISA time path the " + "stencil name; it travels with _normalize_interpolate_time_argv and _truthy_option."), + (r"^FUNC:_normalize_interpolate_time_argv$", "PORT", + "Normalizes --interpolate-time argv forms. LISA exposes --interpolate-time, so " + "the same normalization applies."), + (r"^OPTION:--internal-precompute-ignore-threshold$", "PORT", + "Drops negligible modes during precompute. LISA is mode-heavy (--modes, " + "--restricted-mode-list-file) and pays more per mode than a ground-based run, so " + "if anything this matters more there. No LIGO-specific assumption."), + + # ------------------------------------------------------------------------------- misc + (r"^OPTION:--check-good-enough$", "PORT", + "Early-exit when the pipeline has written an 'ile_good_enough' sentinel. Pipeline " + "plumbing, detector-agnostic."), + (r"^OPTION:--random-event$", "PORT", + "Pick a random event from the input file. Detector-agnostic; flagged dangerous in " + "its own help text for oversampling reasons that apply equally to LISA."), + (r"^OPTION:--save-samples-process-params$", "PORT", + "Retain the process_params table in the XML output. Pure output plumbing."), + (r"^OPTION:--save-meanPerAno$", "NA", + "Exports the eccentric mean anomaly. Tied to the ground-based eccentric waveform " + "path (see --e-freq); the LISA driver's own eccentricity export is " + "--save-eccentricity."), + (r"^OPTION:--calibration-spline-count$", "NA", "See the --calibration-* reason."), + (r"^CONST:_SEQ_WS_PENDING$", "PORT", + "Sentinel for the deferred sequential warm-start capture; ports with " + "--sampler-sequential-warmstart."), + (r"^FUNC:_truthy_option$", "PORT", + "Tolerant truthiness for optparse values that may arrive as strings from the pipe. " + "Belongs with _normalize_interpolate_time_argv, its ONLY caller in the main driver " + "(opts._noloop_time_interp), not with the fair-draw family -- porting it alongside " + "those helpers would have added dead code to the LISA driver."), + (r"^FUNC:_rvs_lnL_convention$", "PORTED", + "Resolves the stored-integrand convention from the run's rvs_integrand_is_lnL. " + "Ported alongside the weight helpers because it is how a caller is SUPPOSED to " + "obtain use_lnL: ln_weights_for_posterior passes the argument through unresolved " + "in both drivers, so omitting it silently yields the linear reading."), +] + + +def classify(key): + for pat, decision, reason in RULES: + if re.search(pat, key): + return decision, reason + return None, None + + +def main(): + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--dry-run", action="store_true") + args = ap.parse_args() + + gap, _extras = audit.compute_gap() + entries, unmatched = {}, [] + for item in gap: + decision, reason = classify(item["key"]) + if decision is None: + unmatched.append(item) + continue + entries[item["key"]] = {"decision": decision, "reason": reason} + + counts = {} + for e in entries.values(): + counts[e["decision"]] = counts.get(e["decision"], 0) + 1 + print("classified %d/%d gap items: %s" % ( + len(entries), len(gap), " ".join("%s=%d" % kv for kv in sorted(counts.items())))) + + if unmatched: + print("\n%d item(s) match NO rule -- add one, or they fail --check:" % len(unmatched)) + for item in unmatched: + print(" %-58s main:%d" % (item["key"], item["main_line"])) + + if args.dry_run: + return 1 if unmatched else 0 + + payload = { + "_comment": "GENERATED by make_lisa_drift_ledger.py -- edit the RULES there, not this file.", + "entries": entries, + } + with open(OUT, "w") as fh: + json.dump(payload, fh, indent=2, sort_keys=True) + fh.write("\n") + print("wrote %s" % os.path.relpath(OUT, HERE)) + return 1 if unmatched else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/MonteCarloMarginalizeCode/Code/test/expensive_before_merging/integrators/make_rvs_fairdraw_ledger.py b/MonteCarloMarginalizeCode/Code/test/expensive_before_merging/integrators/make_rvs_fairdraw_ledger.py index 5dec706af..48e6e84fc 100644 --- a/MonteCarloMarginalizeCode/Code/test/expensive_before_merging/integrators/make_rvs_fairdraw_ledger.py +++ b/MonteCarloMarginalizeCode/Code/test/expensive_before_merging/integrators/make_rvs_fairdraw_ledger.py @@ -29,7 +29,36 @@ def verdict(h): s = " ".join(src.split()) # --- the integrators themselves ------------------------------------------------ + # --- option A: the record (DESIGN_rvs_naming.md) --------------------------- + if "RvsRecord.fair_draw(" in s or "RvsRecord.retained(" in s \ + or "n_retained=self._rvs_record.n_retained()" in s \ + or "reserve=getattr(self, '_warm_seed_reserve', None))" in s: + return ("PER_ROW", + "Hands the columns to RvsRecord as a VIEW, with the pre-draw count taken from " + "the previous record's PROVENANCE (eager) rather than from len() (lazy, and " + "would read the already-rebound dict). Reads no statistic of the rows: it " + "records WHAT THEY ARE at the moment that changes.") + if "_rebound_record(sampler, dict(sampler._rvs)" in s: + return ("PER_ROW", + "Snapshots the columns for a possible restore and rebinds the record to that " + "copy, so the restored record describes what is actually put back rather than " + "the original dict (which would fail every identity check and be inert). A " + "dict copy; reads no statistic of the rows.") + if "_rvs_record_for(sampler, sampler._rvs)" in s: + return ("PER_ROW", + "Looks up the record describing these columns, declining it if _rvs has been " + "replaced since. The consumer then asks a NAMED question " + "(rows_are_resampled / blocks_were_flattened) instead of combining flags; the " + "flags remain the fallback until every sampler is converted.") if f.startswith("RIFT/integrators/"): + if "n_retained=_n_retained_before_draw" in s or "RvsRecord.fair_draw" in s \ + or "reserve=getattr(self, '_warm_seed_reserve', None))" in s: + return ("PER_ROW", + "(DESIGN_rvs_naming.md) hands the just-rebound columns to RvsRecord " + "as a VIEW, together with the pre-draw row count. Reads no statistic of " + "them -- it records that they ARE the export resample, at the moment that " + "becomes true, which is the whole point of the record. Nothing consumes it " + "yet.") if "indx_list" in s: return ("PER_ROW", "The rebind's own right-hand side: this IS the fair draw, gathering each " @@ -73,7 +102,15 @@ def verdict(h): "so, rather than reporting a plausible wrong number.") # --- the L0 rescue and the sequential warm start --------------------------------- - if f == "bin/integrate_likelihood_extrinsic_batchmode": + # BOTH ILE drivers. The LISA driver now carries a ported copy of the L0 rescue, and the + # `_rvs` reads inside it are byte-identical to the ones here -- these rules match on + # source TEXT, so the same text earns the same verdict. (It lives in a module-level + # _maybe_l0_rescue there rather than inlined in analyze_event, because that driver has TWO + # analyze_event variants; the enclosing function name is not part of the match.) Rules in + # this block naming things the LISA driver does not have -- _rep_rvs, extrinsic_handoff, + # the sequential-warm-start seeds -- simply never match for it. + if f in ("bin/integrate_likelihood_extrinsic_batchmode", + "bin/integrate_likelihood_extrinsic_batchmode_lisa"): if "_lnZ_of_reserve_or_rvs" in s: return ("FIXED", "PR #79, re-landed as #86. sampler._rvs is passed as the FALLBACK " @@ -119,9 +156,14 @@ def verdict(h): if "_rep_rvs" in s: return ("PER_ROW", "Collects each replica's record for pooling. _pool_replica_rvs is told " - "already_resampled=bool(opts.fairdraw_extrinsic_output) and forces flat " - "within-block weights on that path, so the resampling is accounted for " - "THERE rather than here.") + "already_resampled=_rep_fairdraw -- the PER-REPLICA sequence, captured " + "beside each record from that pass's own _rvs_is_fairdraw marker -- and " + "forces flat within-block weights for the blocks that were resampled, so " + "the resampling is accounted for THERE rather than here. (This text used " + "to say bool(opts.fairdraw_extrinsic_output); that is the CLI flag, which " + "is precisely the Finding-6 defect the sequence exists to avoid. A verdict " + "whose reason describes a mechanism the code does not use certifies " + "nothing.)") if "extrinsic_handoff" in s or (s == "_rvs = sampler._rvs"): return ("FIXED", diff --git a/MonteCarloMarginalizeCode/Code/test/expensive_before_merging/integrators/measure_retained_set_memory.py b/MonteCarloMarginalizeCode/Code/test/expensive_before_merging/integrators/measure_retained_set_memory.py new file mode 100644 index 000000000..902b401dc --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/test/expensive_before_merging/integrators/measure_retained_set_memory.py @@ -0,0 +1,151 @@ +#!/usr/bin/env python3 +"""How much memory would it cost to KEEP the retained set alongside the export? + +Open question 2 of DESIGN_rvs_naming.md. Holding the retained rows would close the last +BROKEN ledger entry (#79's cross-source lnZ fallback) and let .dslice reweight properly +instead of falling back to all-fresh -- but today the fair draw REPLACES `_rvs`, so the +pre-draw arrays become garbage and the peak is transient. Keeping them makes the peak +persistent for the rest of analyze_event. + +This is an operations question, so it is measured rather than argued. Reported per sampler: +the retained row count, the column count, the implied bytes, and the process RSS actually +observed. + + OMP_NUM_THREADS=1 python3 measure_retained_set_memory.py + OMP_NUM_THREADS=1 python3 measure_retained_set_memory.py --nmax 400000 1000000 +""" +import argparse +import gc +import os +import resource +import sys + +import numpy as np + +HERE = os.path.dirname(os.path.abspath(__file__)) +CODE = os.path.abspath(os.path.join(HERE, "..", "..", "..")) +sys.path.insert(0, CODE) + +import RIFT.integrators.mcsamplerAdaptiveVolume as mcsamplerAV # noqa: E402 + +NAMES = ['right_ascension', 'declination', 'phi_orb', 'inclination', 'psi', 'distance'] +NDIM = len(NAMES) + + +def _rss_mb(): + # ru_maxrss is KiB on Linux + return resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024.0 + + +def _av(n_chunk): + s = mcsamplerAV.MCSampler(n_chunk=n_chunk) + s.xpy = mcsamplerAV.xpy_default + s.identity_convert = mcsamplerAV.identity_convert + for name in NAMES: + s.add_parameter(name, pdf=None, left_limit=0.0, right_limit=1.0, + prior_pdf=lambda x: np.ones(np.shape(x)), adaptive_sampling=True) + return s + + +def _portfolio(n_chunk): + import RIFT.integrators.mcsamplerPortfolio as mcsamplerPF + import RIFT.integrators.mcsamplerEnsemble as mcsamplerEnsemble + members = [mcsamplerAV.MCSampler(n_chunk=n_chunk), mcsamplerEnsemble.MCSampler()] + s = mcsamplerPF.MCSampler(portfolio=members) + pdf = np.vectorize(lambda x: 1.0) + for name in NAMES: + s.add_parameter(name, pdf, prior_pdf=pdf, left_limit=0.0, right_limit=1.0, + adaptive_sampling=True) + s.setup() + return s + + +def _peaked(rho): + x0 = 0.5 * np.ones(NDIM) + w = (0.5 / rho) * np.ones(NDIM) + lnLmax = 0.5 * rho ** 2 + + def lnL(*args, **kwargs): + x = np.array([np.asarray(a, dtype=float).ravel() for a in args]).T + out = lnLmax - 0.5 * np.sum(((x - x0) / w) ** 2, axis=-1) + return np.where(out > lnLmax - 745.0, out, -np.inf) + return lnL + + +def _record_bytes(rvs): + """Bytes actually held by the record's columns.""" + tot = 0 + for v in rvs.values(): + a = np.asarray(mcsamplerAV.identity_convert(v)) + tot += a.nbytes + return tot + + +def measure(kind, nmax, rho=20.0, n_chunk=20000): + """Run WITHOUT a fair draw, so _rvs IS the retained set, and weigh it.""" + gc.collect() + rss0 = _rss_mb() + s = _portfolio(n_chunk) if kind == 'portfolio' else _av(n_chunk) + kw = dict(no_protect_names=True, verbose=False) + if kind == 'portfolio': + kw['save_intg'] = True + try: + s.integrate_log(_peaked(rho), *NAMES, nmax=nmax, neff=100, n=n_chunk, **kw) + except Exception as e: + return dict(kind=kind, nmax=nmax, error=str(e)[:70]) + rvs = s._rvs + n_rows = len(np.atleast_1d(np.asarray( + mcsamplerAV.identity_convert(rvs['log_integrand']))).ravel()) + out = dict(kind=kind, nmax=nmax, ntotal=int(getattr(s, 'ntotal', 0)), + n_rows=n_rows, n_cols=len(rvs), + mb=_record_bytes(rvs) / 1024.0 ** 2, + rss_mb=_rss_mb(), rss_delta=_rss_mb() - rss0) + del s + gc.collect() + return out + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--nmax", type=int, nargs='+', default=[200000, 400000, 800000]) + ap.add_argument("--rho", type=float, default=20.0) + args = ap.parse_args() + + print("=" * 96) + print("Retained-set size: what holding it alongside the export would cost") + print("(no fair draw, so _rvs IS the retained set; rho={})".format(args.rho)) + print("=" * 96) + print("{:<11} {:>10} {:>10} {:>10} {:>6} {:>10} {:>10} {:>10}".format( + "sampler", "nmax", "ntotal", "rows", "cols", "record MB", "RSS MB", "dRSS MB")) + rows = [] + for kind in ('AV', 'portfolio'): + for nmax in args.nmax: + r = measure(kind, nmax, rho=args.rho) + rows.append(r) + if 'error' in r: + print("{:<11} {:>10} FAILED: {}".format(kind, nmax, r['error'])) + continue + print("{:<11} {:>10} {:>10} {:>10} {:>6} {:>10.1f} {:>10.1f} {:>10.1f}".format( + kind, nmax, r['ntotal'], r['n_rows'], r['n_cols'], + r['mb'], r['rss_mb'], r['rss_delta'])) + + print() + print("READING THIS. `rows` is what the record would have to keep. For AV it is the") + print("RETAINED (in-volume) subset, so it grows far more slowly than ntotal. For the") + print("PORTFOLIO _rvs holds EVERY draw, so rows ~ ntotal and the cost is set by nmax.") + ok = [r for r in rows if 'error' not in r and r['n_rows'] > 0] + for kind in ('AV', 'portfolio'): + sub = [r for r in ok if r['kind'] == kind] + if len(sub) >= 2: + per = (sub[-1]['mb'] - sub[0]['mb']) / max(1, sub[-1]['nmax'] - sub[0]['nmax']) + print(" {:<10} ~{:.1f} MB per million nmax -> {:.0f} MB at nmax=4e6".format( + kind, per * 1e6, per * 4e6 + sub[0]['mb'])) + print() + print("Compare: _warm_seed_reserve already keeps a BOUNDED copy (n_max=20000 rows,") + print("stratified by finite-ness), which is the affordable precedent. The question is") + print("whether the UNBOUNDED retained set is affordable too, per ILE process, alongside") + print("the waveform and PSD working set.") + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/MonteCarloMarginalizeCode/Code/test/expensive_before_merging/integrators/rvs_fairdraw_verdicts.json b/MonteCarloMarginalizeCode/Code/test/expensive_before_merging/integrators/rvs_fairdraw_verdicts.json index bdecbd3d8..d04fccef9 100644 --- a/MonteCarloMarginalizeCode/Code/test/expensive_before_merging/integrators/rvs_fairdraw_verdicts.json +++ b/MonteCarloMarginalizeCode/Code/test/expensive_before_merging/integrators/rvs_fairdraw_verdicts.json @@ -1,4 +1,9 @@ { + "RIFT/integrators/mcsampler.py:integrate:3f9099aa38": { + "source": "self._rvs, n_retained=self._rvs_record.n_retained(),", + "verdict": "PER_ROW", + "why": "Hands the columns to RvsRecord as a VIEW, with the pre-draw count taken from the previous record's PROVENANCE (eager) rather than from len() (lazy, and would read the already-rebound dict). Reads no statistic of the rows: it records WHAT THEY ARE at the moment that changes." + }, "RIFT/integrators/mcsampler.py:integrate:ac2283de73": { "source": "self._rvs[key] = self._rvs[key][indx_list]", "verdict": "PER_ROW", @@ -14,6 +19,11 @@ "verdict": "PER_ROW", "why": "Element-wise dtype/backend conversion (cupy -> numpy) over whatever rows are present. Touches no population statistic; correct on any row set." }, + "RIFT/integrators/mcsamplerAdaptiveVolume.py:integrate_log:3f9099aa38": { + "source": "self._rvs, n_retained=self._rvs_record.n_retained(),", + "verdict": "PER_ROW", + "why": "Hands the columns to RvsRecord as a VIEW, with the pre-draw count taken from the previous record's PROVENANCE (eager) rather than from len() (lazy, and would read the already-rebound dict). Reads no statistic of the rows: it records WHAT THEY ARE at the moment that changes." + }, "RIFT/integrators/mcsamplerAdaptiveVolume.py:integrate_log:8f476f15d1": { "source": "for name in self._rvs:", "verdict": "PER_ROW", @@ -24,6 +34,11 @@ "verdict": "PER_ROW", "why": "Element-wise dtype/backend conversion (cupy -> numpy) over whatever rows are present. Touches no population statistic; correct on any row set." }, + "RIFT/integrators/mcsamplerEnsemble.py:integrate:3f9099aa38": { + "source": "self._rvs, n_retained=self._rvs_record.n_retained(),", + "verdict": "PER_ROW", + "why": "Hands the columns to RvsRecord as a VIEW, with the pre-draw count taken from the previous record's PROVENANCE (eager) rather than from len() (lazy, and would read the already-rebound dict). Reads no statistic of the rows: it records WHAT THEY ARE at the moment that changes." + }, "RIFT/integrators/mcsamplerEnsemble.py:integrate:ac2283de73": { "source": "self._rvs[key] = self._rvs[key][indx_list]", "verdict": "PER_ROW", @@ -44,6 +59,11 @@ "verdict": "PER_ROW", "why": "Element-wise dtype/backend conversion (cupy -> numpy) over whatever rows are present. Touches no population statistic; correct on any row set." }, + "RIFT/integrators/mcsamplerGPU.py:integrate:3f9099aa38": { + "source": "self._rvs, n_retained=self._rvs_record.n_retained(),", + "verdict": "PER_ROW", + "why": "Hands the columns to RvsRecord as a VIEW, with the pre-draw count taken from the previous record's PROVENANCE (eager) rather than from len() (lazy, and would read the already-rebound dict). Reads no statistic of the rows: it records WHAT THEY ARE at the moment that changes." + }, "RIFT/integrators/mcsamplerGPU.py:integrate:8f476f15d1": { "source": "for name in self._rvs:", "verdict": "PER_ROW", @@ -69,6 +89,11 @@ "verdict": "PER_ROW", "why": "Element-wise dtype/backend conversion (cupy -> numpy) over whatever rows are present. Touches no population statistic; correct on any row set." }, + "RIFT/integrators/mcsamplerGPU.py:integrate_log:3f9099aa38": { + "source": "self._rvs, n_retained=self._rvs_record.n_retained(),", + "verdict": "PER_ROW", + "why": "Hands the columns to RvsRecord as a VIEW, with the pre-draw count taken from the previous record's PROVENANCE (eager) rather than from len() (lazy, and would read the already-rebound dict). Reads no statistic of the rows: it records WHAT THEY ARE at the moment that changes." + }, "RIFT/integrators/mcsamplerGPU.py:integrate_log:8f476f15d1": { "source": "for name in self._rvs:", "verdict": "PER_ROW", @@ -94,6 +119,11 @@ "verdict": "PER_ROW", "why": "Element-wise dtype/backend conversion (cupy -> numpy) over whatever rows are present. Touches no population statistic; correct on any row set." }, + "RIFT/integrators/mcsamplerNFlow.py:integrate_log:3f9099aa38": { + "source": "self._rvs, n_retained=self._rvs_record.n_retained(),", + "verdict": "PER_ROW", + "why": "Hands the columns to RvsRecord as a VIEW, with the pre-draw count taken from the previous record's PROVENANCE (eager) rather than from len() (lazy, and would read the already-rebound dict). Reads no statistic of the rows: it records WHAT THEY ARE at the moment that changes." + }, "RIFT/integrators/mcsamplerNFlow.py:integrate_log:8f476f15d1": { "source": "for name in self._rvs:", "verdict": "PER_ROW", @@ -114,6 +144,11 @@ "verdict": "PER_ROW", "why": "Element-wise dtype/backend conversion (cupy -> numpy) over whatever rows are present. Touches no population statistic; correct on any row set." }, + "RIFT/integrators/mcsamplerPortfolio.py:integrate_log:3f9099aa38": { + "source": "self._rvs, n_retained=self._rvs_record.n_retained(),", + "verdict": "PER_ROW", + "why": "Hands the columns to RvsRecord as a VIEW, with the pre-draw count taken from the previous record's PROVENANCE (eager) rather than from len() (lazy, and would read the already-rebound dict). Reads no statistic of the rows: it records WHAT THEY ARE at the moment that changes." + }, "RIFT/integrators/mcsamplerPortfolio.py:integrate_log:8f476f15d1": { "source": "for name in self._rvs:", "verdict": "PER_ROW", @@ -244,21 +279,21 @@ "verdict": "PER_ROW", "why": "_snapshot_pass_state takes a dict copy of whatever rows are present so a rejected warm pass can be undone. It makes no claim about their statistics, and it snapshots the fair-draw MARKER alongside them so the restored record and the marker describing it cannot disagree." }, + "bin/integrate_likelihood_extrinsic_batchmode:_snapshot_pass_state:4b8666916a": { + "source": "rvs_record=_rebound_record(sampler, dict(sampler._rvs) if rvs is None else rvs),", + "verdict": "PER_ROW", + "why": "Snapshots the columns for a possible restore and rebinds the record to that copy, so the restored record describes what is actually put back rather than the original dict (which would fail every identity check and be inert). A dict copy; reads no statistic of the rows." + }, "bin/integrate_likelihood_extrinsic_batchmode:analyze_event:011d296b48": { "source": "_rep_rvs = [sampler._rvs]", "verdict": "PER_ROW", - "why": "Collects each replica's record for pooling. _pool_replica_rvs is told already_resampled=bool(opts.fairdraw_extrinsic_output) and forces flat within-block weights on that path, so the resampling is accounted for THERE rather than here." + "why": "Collects each replica's record for pooling. _pool_replica_rvs is told already_resampled=_rep_fairdraw -- the PER-REPLICA sequence, captured beside each record from that pass's own _rvs_is_fairdraw marker -- and forces flat within-block weights for the blocks that were resampled, so the resampling is accounted for THERE rather than here. (This text used to say bool(opts.fairdraw_extrinsic_output); that is the CLI flag, which is precisely the Finding-6 defect the sequence exists to avoid. A verdict whose reason describes a mechanism the code does not use certifies nothing.)" }, "bin/integrate_likelihood_extrinsic_batchmode:analyze_event:07f46c212f": { "source": "_lnv = (np.asarray(sampler.identity_convert(sampler._rvs[_lnkey]), dtype=float).ravel()", "verdict": "BENIGN", "why": "The DELIBERATE no-reserve fallback for a sampler that keeps none: reads _rvs so the feature degrades to its previous behaviour rather than to no seed at all. The rank hazard is still handled -- build_warm_seed puffs the result to full rank -- so what remains is only 'fewer points', which cannot be improved without a reserve." }, - "bin/integrate_likelihood_extrinsic_batchmode:analyze_event:0ab512f38a": { - "source": "_warm_lnZ = _lnZ_of_rvs(sampler._rvs, already_pooled=False)", - "verdict": "BROKEN", - "why": "PR #79's CROSS-SOURCE FALLBACK: fires only when the cold and warm passes produced different reading sources (one had a reserve, the other did not), and then re-reads BOTH sides from the fair-draw record. That is self-consistent, which is what #79 claims for it, but it is not unbiased: the two passes sit at different n_eff, so the log(n/n_eff) artifact does not cancel and this branch is back in the regime measured at +3.48 nats / 100% rejection at the 0.5 default. A known, documented, BOUNDED residual -- not a defect anyone introduced. Closing it needs a retained-set reading on both sides, i.e. a reserve for the samplers that keep none. Follow-up, not a regression." - }, "bin/integrate_likelihood_extrinsic_batchmode:analyze_event:1240e69c24": { "source": "len(numpy.atleast_1d(list(sampler._rvs.values())[0])) if sampler._rvs else 0,", "verdict": "FIXED", @@ -282,7 +317,7 @@ "bin/integrate_likelihood_extrinsic_batchmode:analyze_event:25d9742c4d": { "source": "_rep_rvs.append(sampler._rvs)", "verdict": "PER_ROW", - "why": "Collects each replica's record for pooling. _pool_replica_rvs is told already_resampled=bool(opts.fairdraw_extrinsic_output) and forces flat within-block weights on that path, so the resampling is accounted for THERE rather than here." + "why": "Collects each replica's record for pooling. _pool_replica_rvs is told already_resampled=_rep_fairdraw -- the PER-REPLICA sequence, captured beside each record from that pass's own _rvs_is_fairdraw marker -- and forces flat within-block weights for the blocks that were resampled, so the resampling is accounted for THERE rather than here. (This text used to say bool(opts.fairdraw_extrinsic_output); that is the CLI flag, which is precisely the Finding-6 defect the sequence exists to avoid. A verdict whose reason describes a mechanism the code does not use certifies nothing.)" }, "bin/integrate_likelihood_extrinsic_batchmode:analyze_event:29d2d2ebb6": { "source": "P.dist = sampler._rvs[\"distance\"][indx_guess]*1e6*lal.PC_SI", @@ -314,11 +349,21 @@ "verdict": "BENIGN", "why": "MAP-seed read: argmax over the record, then the SAME row's coordinates. The fair draw picks rows proportional to weight, so the retained argmax is a genuinely-drawn high-likelihood point; it seeds a local search and a printed diagnostic, and no downstream number is a statistic of it. Degraded (it may not be the global MAP), not wrong." }, + "bin/integrate_likelihood_extrinsic_batchmode:analyze_event:4906465e6d": { + "source": "_rec_ne = _rvs_record_for(sampler, sampler._rvs)", + "verdict": "PER_ROW", + "why": "Looks up the record describing these columns, declining it if _rvs has been replaced since. The consumer then asks a NAMED question (rows_are_resampled / blocks_were_flattened) instead of combining flags; the flags remain the fallback until every sampler is converted." + }, "bin/integrate_likelihood_extrinsic_batchmode:analyze_event:4dd03bebc6": { "source": "P.phi = sampler._rvs[\"right_ascension\"][indx_guess]", "verdict": "BENIGN", "why": "MAP-seed read: argmax over the record, then the SAME row's coordinates. The fair draw picks rows proportional to weight, so the retained argmax is a genuinely-drawn high-likelihood point; it seeds a local search and a printed diagnostic, and no downstream number is a statistic of it. Degraded (it may not be the global MAP), not wrong." }, + "bin/integrate_likelihood_extrinsic_batchmode:analyze_event:5a200a208f": { + "source": "_warm_lnZ = _lnZ_of_rvs(sampler._rvs, already_pooled=False,", + "verdict": "BROKEN", + "why": "PR #79's CROSS-SOURCE FALLBACK: fires only when the cold and warm passes produced different reading sources (one had a reserve, the other did not), and then re-reads BOTH sides from the fair-draw record. That is self-consistent, which is what #79 claims for it, but it is not unbiased: the two passes sit at different n_eff, so the log(n/n_eff) artifact does not cancel and this branch is back in the regime measured at +3.48 nats / 100% rejection at the 0.5 default. A known, documented, BOUNDED residual -- not a defect anyone introduced. Closing it needs a retained-set reading on both sides, i.e. a reserve for the samplers that keep none. Follow-up, not a regression." + }, "bin/integrate_likelihood_extrinsic_batchmode:analyze_event:65d3d3b519": { "source": "_rvs = sampler._rvs", "verdict": "PER_ROW", @@ -379,16 +424,16 @@ "verdict": "BENIGN", "why": "The DELIBERATE no-reserve fallback for a sampler that keeps none: reads _rvs so the feature degrades to its previous behaviour rather than to no seed at all. The rank hazard is still handled -- build_warm_seed puffs the result to full rank -- so what remains is only 'fewer points', which cannot be improved without a reserve." }, - "bin/integrate_likelihood_extrinsic_batchmode:analyze_event:acdd1e28bd": { - "source": "_neff_pooled = _kish_neff_of_rvs(sampler._rvs)", - "verdict": "FIXED", - "why": "Kish of the pooled record is only used when the export is NOT fair-drawn. When it is, _pool_replica_rvs has flattened each block, and the Kish of piecewise-constant weights is just the row count (5K at the default --fairdraw-extrinsic-output-n-max 5). The ILE now computes the same quantity one level up -- (sum Z_k)^2 / sum(Z_k^2/neff_k), Kish over the BLOCKS -- which reduces to sum(neff_k) when replicas agree and falls below it when they do not. The row count beside it is a deliberate report of the export size." - }, "bin/integrate_likelihood_extrinsic_batchmode:analyze_event:b8c45b4f30": { "source": "samples = copy.deepcopy(sampler._rvs) # deep copy: avoid modifying structures and having side effect on integrator, which loops over keys Expensive!", "verdict": "BENIGN", "why": "THE export itself. Under --fairdraw-extrinsic-output the fair draw is precisely what these rows are supposed to be, so the resample is the correct input here rather than a hazard." }, + "bin/integrate_likelihood_extrinsic_batchmode:analyze_event:b8eaf9c12b": { + "source": "_rec_ds = _rvs_record_for(sampler, sampler._rvs)", + "verdict": "PER_ROW", + "why": "Looks up the record describing these columns, declining it if _rvs has been replaced since. The consumer then asks a NAMED question (rows_are_resampled / blocks_were_flattened) instead of combining flags; the flags remain the fallback until every sampler is converted." + }, "bin/integrate_likelihood_extrinsic_batchmode:analyze_event:c717be4455": { "source": "dL = np.array(sampler._rvs[\"distance\"])", "verdict": "PER_ROW", @@ -414,11 +459,91 @@ "verdict": "BENIGN", "why": "Printed diagnostic of the best retained sample. Explicitly labelled as what ILE reports including weights; not an input to any result." }, + "bin/integrate_likelihood_extrinsic_batchmode:analyze_event:e09c3e2b32": { + "source": "record=_rvs_record_for(sampler, sampler._rvs))", + "verdict": "PER_ROW", + "why": "Looks up the record describing these columns, declining it if _rvs has been replaced since. The consumer then asks a NAMED question (rows_are_resampled / blocks_were_flattened) instead of combining flags; the flags remain the fallback until every sampler is converted." + }, "bin/integrate_likelihood_extrinsic_batchmode:analyze_event:e0bbb0348e": { "source": "P.phiref = sampler._rvs[\"phi_orb\"][indx_guess]", "verdict": "BENIGN", "why": "MAP-seed read: argmax over the record, then the SAME row's coordinates. The fair draw picks rows proportional to weight, so the retained argmax is a genuinely-drawn high-likelihood point; it seeds a local search and a printed diagnostic, and no downstream number is a statistic of it. Degraded (it may not be the global MAP), not wrong." }, + "bin/integrate_likelihood_extrinsic_batchmode:analyze_event:f2a39eb3a7": { + "source": "sampler._rvs, record=_rvs_record_for(sampler, sampler._rvs))", + "verdict": "PER_ROW", + "why": "Looks up the record describing these columns, declining it if _rvs has been replaced since. The consumer then asks a NAMED question (rows_are_resampled / blocks_were_flattened) instead of combining flags; the flags remain the fallback until every sampler is converted." + }, + "bin/integrate_likelihood_extrinsic_batchmode_lisa:_maybe_l0_rescue:0ab512f38a": { + "source": "_warm_lnZ = _lnZ_of_rvs(sampler._rvs, already_pooled=False)", + "verdict": "BROKEN", + "why": "PR #79's CROSS-SOURCE FALLBACK: fires only when the cold and warm passes produced different reading sources (one had a reserve, the other did not), and then re-reads BOTH sides from the fair-draw record. That is self-consistent, which is what #79 claims for it, but it is not unbiased: the two passes sit at different n_eff, so the log(n/n_eff) artifact does not cancel and this branch is back in the regime measured at +3.48 nats / 100% rejection at the 0.5 default. A known, documented, BOUNDED residual -- not a defect anyone introduced. Closing it needs a retained-set reading on both sides, i.e. a reserve for the samplers that keep none. Follow-up, not a regression." + }, + "bin/integrate_likelihood_extrinsic_batchmode_lisa:_maybe_l0_rescue:1331dd70ea": { + "source": "len(np.asarray(sampler.identity_convert(sampler._rvs['log_integrand'])).ravel())", + "verdict": "BENIGN", + "why": "Reports how many rows the fair draw left, for the log line that contrasts it with the retained count. Reading the resample's size is the POINT here." + }, + "bin/integrate_likelihood_extrinsic_batchmode_lisa:_maybe_l0_rescue:32c64bcd73": { + "source": "_lnv = np.asarray(sampler.identity_convert(sampler._rvs[_lnkey]), dtype=float).ravel() if _lnkey else np.array([])", + "verdict": "BENIGN", + "why": "The DELIBERATE no-reserve fallback for a sampler that keeps none: reads _rvs so the feature degrades to its previous behaviour rather than to no seed at all. The rank hazard is still handled -- build_warm_seed puffs the result to full rank -- so what remains is only 'fewer points', which cannot be improved without a reserve." + }, + "bin/integrate_likelihood_extrinsic_batchmode_lisa:_maybe_l0_rescue:36e58e97fb": { + "source": "if 'log_integrand' in sampler._rvs else '?'))", + "verdict": "PER_ROW", + "why": "Key-presence / column reference, not a population statistic." + }, + "bin/integrate_likelihood_extrinsic_batchmode_lisa:_maybe_l0_rescue:7a4e161eb0": { + "source": "_cold_rvs = dict(sampler._rvs)", + "verdict": "PER_ROW", + "why": "Snapshots the cold record so the reject path can restore it. A dict copy of whatever rows exist; makes no claim about their statistics." + }, + "bin/integrate_likelihood_extrinsic_batchmode_lisa:_maybe_l0_rescue:a6ea5209f8": { + "source": "_cols = (np.vstack([np.asarray(sampler.identity_convert(sampler._rvs[p]), dtype=float).ravel()", + "verdict": "BENIGN", + "why": "The DELIBERATE no-reserve fallback for a sampler that keeps none: reads _rvs so the feature degrades to its previous behaviour rather than to no seed at all. The rank hazard is still handled -- build_warm_seed puffs the result to full rank -- so what remains is only 'fewer points', which cannot be improved without a reserve." + }, + "bin/integrate_likelihood_extrinsic_batchmode_lisa:_maybe_l0_rescue:cfe2518e26": { + "source": "_lnkey = 'log_integrand' if 'log_integrand' in sampler._rvs else ('integrand' if 'integrand' in sampler._rvs else None)", + "verdict": "PER_ROW", + "why": "Key-presence / column reference, not a population statistic." + }, + "bin/integrate_likelihood_extrinsic_batchmode_lisa:_maybe_l0_rescue:d2514d9663": { + "source": "_warm_lnZ, _warm_src = _lnZ_of_reserve_or_rvs(sampler, sampler._rvs)", + "verdict": "FIXED", + "why": "PR #79, re-landed as #86. sampler._rvs is passed as the FALLBACK argument only: the helper prefers the retained reserve via lnZ_from_reserve, and the gate refuses to compare across sources (_cold_src != _warm_src forces BOTH back to the fair-draw reading, which is at least self-consistent). Measured before #79: two passes with identical true lnZ at n_eff 1.8 vs 53 produced a +3.48 nat gap and rejected the good warm pass 100% of the time at the 0.5 default." + }, + "bin/integrate_likelihood_extrinsic_batchmode_lisa:_maybe_replicate_for_mc_error:011d296b48": { + "source": "_rep_rvs = [sampler._rvs]", + "verdict": "PER_ROW", + "why": "Collects each replica's record for pooling. _pool_replica_rvs is told already_resampled=_rep_fairdraw -- the PER-REPLICA sequence, captured beside each record from that pass's own _rvs_is_fairdraw marker -- and forces flat within-block weights for the blocks that were resampled, so the resampling is accounted for THERE rather than here. (This text used to say bool(opts.fairdraw_extrinsic_output); that is the CLI flag, which is precisely the Finding-6 defect the sequence exists to avoid. A verdict whose reason describes a mechanism the code does not use certifies nothing.)" + }, + "bin/integrate_likelihood_extrinsic_batchmode_lisa:_maybe_replicate_for_mc_error:1240e69c24": { + "source": "len(numpy.atleast_1d(list(sampler._rvs.values())[0])) if sampler._rvs else 0,", + "verdict": "FIXED", + "why": "Kish of the pooled record is only used when the export is NOT fair-drawn. When it is, _pool_replica_rvs has flattened each block, and the Kish of piecewise-constant weights is just the row count (5K at the default --fairdraw-extrinsic-output-n-max 5). The ILE now computes the same quantity one level up -- (sum Z_k)^2 / sum(Z_k^2/neff_k), Kish over the BLOCKS -- which reduces to sum(neff_k) when replicas agree and falls below it when they do not. The row count beside it is a deliberate report of the export size." + }, + "bin/integrate_likelihood_extrinsic_batchmode_lisa:_maybe_replicate_for_mc_error:25d9742c4d": { + "source": "_rep_rvs.append(sampler._rvs)", + "verdict": "PER_ROW", + "why": "Collects each replica's record for pooling. _pool_replica_rvs is told already_resampled=_rep_fairdraw -- the PER-REPLICA sequence, captured beside each record from that pass's own _rvs_is_fairdraw marker -- and forces flat within-block weights for the blocks that were resampled, so the resampling is accounted for THERE rather than here. (This text used to say bool(opts.fairdraw_extrinsic_output); that is the CLI flag, which is precisely the Finding-6 defect the sequence exists to avoid. A verdict whose reason describes a mechanism the code does not use certifies nothing.)" + }, + "bin/integrate_likelihood_extrinsic_batchmode_lisa:_maybe_replicate_for_mc_error:acdd1e28bd": { + "source": "_neff_pooled = _kish_neff_of_rvs(sampler._rvs)", + "verdict": "FIXED", + "why": "Kish of the pooled record is only used when the export is NOT fair-drawn. When it is, _pool_replica_rvs has flattened each block, and the Kish of piecewise-constant weights is just the row count (5K at the default --fairdraw-extrinsic-output-n-max 5). The ILE now computes the same quantity one level up -- (sum Z_k)^2 / sum(Z_k^2/neff_k), Kish over the BLOCKS -- which reduces to sum(neff_k) when replicas agree and falls below it when they do not. The row count beside it is a deliberate report of the export size." + }, + "bin/integrate_likelihood_extrinsic_batchmode_lisa:_snapshot_pass_state:02594db9f0": { + "source": "rvs=(dict(sampler._rvs) if rvs is None else rvs),", + "verdict": "PER_ROW", + "why": "_snapshot_pass_state takes a dict copy of whatever rows are present so a rejected warm pass can be undone. It makes no claim about their statistics, and it snapshots the fair-draw MARKER alongside them so the restored record and the marker describing it cannot disagree." + }, + "bin/integrate_likelihood_extrinsic_batchmode_lisa:_snapshot_pass_state:4b8666916a": { + "source": "rvs_record=_rebound_record(sampler, dict(sampler._rvs) if rvs is None else rvs),", + "verdict": "PER_ROW", + "why": "Snapshots the columns for a possible restore and rebinds the record to that copy, so the restored record describes what is actually put back rather than the original dict (which would fail every identity check and be inert). A dict copy; reads no statistic of the rows." + }, "bin/integrate_likelihood_extrinsic_batchmode_lisa:analyze_event:16e8b48c86": { "source": "(sampler._rvs[\"phi_orb\"][indx_guess]/(2*numpy.pi)), \\", "verdict": "BENIGN", diff --git a/MonteCarloMarginalizeCode/Code/test/expensive_before_merging/integrators/tier3/analyze_final.py b/MonteCarloMarginalizeCode/Code/test/expensive_before_merging/integrators/tier3/analyze_final.py new file mode 100644 index 000000000..0a619f1f0 --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/test/expensive_before_merging/integrators/tier3/analyze_final.py @@ -0,0 +1,89 @@ +"""Tier-3 analysis. + +The GPU ILE is NOT deterministic at fixed --seed (measured: base vs base, same code, +same seed, differs by dlnL 0.33 and neff 4.2 vs 22.5). So the comparison is between +DISTRIBUTIONS, and the null has to be MEASURED rather than assumed: + + * permutation test on the base/cand labels -- exact, no normality assumption; + * an A/A control that splits the BASE runs into two pseudo-arms and runs the same + test, so we can see what |t| identical code produces on this hardware. +""" +import csv, math, sys, random, statistics as st + +random.seed(20260818) +NPERM = 20000 + +def load(path): + return [r for r in csv.DictReader(open(path))] + +def vals(rows, cfg, arm, m): + out = [] + for r in rows: + if r['cfg'] == cfg and r['arm'] == arm: + try: v = float(r[m]) + except (ValueError, TypeError): continue + if not math.isnan(v): out.append(v) + return out + +def perm_p(b, c): + """Two-sided permutation p-value on the difference of means.""" + obs = abs(st.mean(c) - st.mean(b)) + pool = b + c; nb = len(b) + hits = 0 + for _ in range(NPERM): + random.shuffle(pool) + if abs(st.mean(pool[nb:]) - st.mean(pool[:nb])) >= obs - 1e-15: + hits += 1 + return (hits + 1) / (NPERM + 1) + +def aa_control(b): + """Split base in half -> two pseudo-arms of IDENTICAL code.""" + x = list(b); random.shuffle(x) + h = len(x) // 2 + return x[:h], x[h:2*h] + +CFG = {'A':'GPU linear backend, plain', + 'B':'GPU linear backend + replica POOLING', + 'D':'cubic NoLoop time interpolation', + 'AV':'AV (lnL family) + pooling + .dgrid', + 'GMM':'GMM (lnL family) + pooling + .dgrid'} +METRICS = ('lnL','sigma_lnL','neff','dgrid_lnL_mean','dgrid_lnL_max') + +rows = [] +for p in sys.argv[1:]: + rows += load(p) +bad = [r for r in rows if r['rc'] != '0' or r['failed'] != '0'] +print("runs=%d clean=%d failed=%d\n" % (len(rows), len(rows)-len(bad), len(bad))) + +results, aa = [], [] +for cfg in ('A','B','D','AV','GMM'): + if not any(r['cfg'] == cfg for r in rows): continue + print("=== %s : %s ===" % (cfg, CFG[cfg])) + for m in METRICS: + b, c = vals(rows, cfg, 'base', m), vals(rows, cfg, 'cand', m) + if len(b) < 3 or len(c) < 3: continue + d = st.mean(c) - st.mean(b) + se = math.sqrt(st.stdev(b)**2/len(b) + st.stdev(c)**2/len(c)) + t = d/se if se else float('nan') + p = perm_p(b, c) + results.append((cfg, m, d, t, p)) + print(" %-15s n=%2d/%2d base %9.4f +-%7.4f cand %9.4f +-%7.4f d=%+8.4f t=%+5.2f p=%.3f%s" + % (m, len(b), len(c), st.mean(b), st.stdev(b), st.mean(c), st.stdev(c), + d, t, p, ' <<<' if p < 0.05 else '')) + # A/A control on the same data + b1, b2 = aa_control(b) + if len(b1) >= 3: + aa.append((cfg, m, perm_p(b1, b2))) + print() + +n = len(results); sig = [r for r in results if r[4] < 0.05] +print("SUMMARY") +print(" %d comparisons; %d with p<0.05 (expected by chance at alpha=0.05: %.1f)" + % (n, len(sig), 0.05*n)) +for cfg, m, d, t, p in sig: + print(" p<0.05: %s %s d=%+.4f t=%+.2f p=%.3f" % (cfg, m, d, t, p)) +naa = len(aa); saa = [a for a in aa if a[2] < 0.05] +print(" A/A CONTROL (base split against ITSELF, identical code):") +print(" %d comparisons; %d with p<0.05" % (naa, len(saa))) +for cfg, m, p in saa: + print(" %s %s p=%.3f" % (cfg, m, p)) diff --git a/MonteCarloMarginalizeCode/Code/test/expensive_before_merging/integrators/tier3/ensemble1.csv b/MonteCarloMarginalizeCode/Code/test/expensive_before_merging/integrators/tier3/ensemble1.csv new file mode 100644 index 000000000..4e51f0e7b --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/test/expensive_before_merging/integrators/tier3/ensemble1.csv @@ -0,0 +1,41 @@ +cfg,arm,rep,rc,failed,lnL,sigma_lnL,neff,secs +A,base,1,0,0,66.35284746251008,0.17895872705491764,9.00968285988907,8 +A,cand,1,0,0,66.42742827628986,0.1792762574175363,8.860971050233658,8 +B,base,1,0,0,66.6279200079249,0.10475160538521777,33.0543858493373,9 +B,cand,1,0,0,66.74868616311201,0.11886466430133813,22.570225306906877,8 +A,base,2,0,0,66.56042581454659,0.17014259276920082,10.530762321533071,8 +A,cand,2,0,0,66.29151934193229,0.21247691882956357,7.886873889644363,8 +B,base,2,0,0,66.79343109706457,0.09447658262992663,31.67952662509708,8 +B,cand,2,0,0,66.65254052497757,0.09027647492549035,32.14551877685127,9 +A,base,3,0,0,66.3422966577914,0.16233586184940357,9.582835808819851,8 +A,cand,3,0,0,66.58666536631831,0.21373861880689615,8.224245227653869,7 +B,base,3,0,0,66.61274334804828,0.11072583041265563,37.92215823249447,9 +B,cand,3,0,0,66.52805665317142,0.09690844042784497,31.90091933652768,9 +A,base,4,0,0,66.7365702787439,0.30534693807328833,4.150694074473091,9 +A,cand,4,0,0,66.81950691936855,0.1954986810358669,9.313336135159702,8 +B,base,4,0,0,66.63552037490571,0.09740630181633106,38.55898671522511,10 +B,cand,4,0,0,66.75077062088782,0.13010057814440518,19.802400689891265,9 +A,base,5,0,0,66.90454874685176,0.20409178536336312,8.434239002242334,7 +A,cand,5,0,0,66.54401576222904,0.19249679826590355,9.72530674115805,8 +B,base,5,0,0,66.68027365810266,0.10042336105709214,36.13802290410366,8 +B,cand,5,0,0,66.93841728035807,0.15705813848495123,16.49567294491765,9 +A,base,6,0,0,66.48095101318047,0.1485384144952602,12.501062879752338,8 +A,cand,6,0,0,66.87544776652311,0.19101984586967524,10.790353521828976,8 +B,base,6,0,0,66.4923066166899,0.10633478282164839,27.598098999452915,9 +B,cand,6,0,0,66.578409534533,0.10346813645026612,31.65697946194633,9 +A,base,7,0,0,66.70816940829128,0.17807993992914486,9.17395143231485,8 +A,cand,7,0,0,66.63960456124249,0.22542142888875882,7.5906094013989565,8 +B,base,7,0,0,66.42720032753101,0.09539934079666562,35.93288246965157,9 +B,cand,7,0,0,66.72071181299846,0.11217313144934402,24.978619303734156,9 +A,base,8,0,0,66.51520011117579,0.1810665814614276,8.035087185523064,9 +A,cand,8,0,0,66.70089096202923,0.18056893526345227,9.05928842015516,8 +B,base,8,0,0,66.48795370545936,0.09658074193100492,37.82561972569615,9 +B,cand,8,0,0,66.57792883035944,0.0838552506069948,41.30110735992629,11 +A,base,9,0,0,66.69424928670807,0.25150286509844066,5.626227634270917,11 +A,cand,9,0,0,66.4057337317337,0.14970112536293423,13.783624895645028,10 +B,base,9,0,0,66.60099642127462,0.13285158156174426,17.992398643637344,11 +B,cand,9,0,0,66.58544165440783,0.1542064486889392,35.15482485188212,10 +A,base,10,0,0,66.38540407596679,0.27013363120095746,4.048840737443927,9 +A,cand,10,0,0,66.44513515282267,0.2341279679523272,5.302023444455016,8 +B,base,10,0,0,66.57201926199284,0.09373486292127536,34.18263674067028,10 +B,cand,10,0,0,66.79240115704638,0.1122080821620026,25.954674706465674,9 diff --git a/MonteCarloMarginalizeCode/Code/test/expensive_before_merging/integrators/tier3/ensemble2.csv b/MonteCarloMarginalizeCode/Code/test/expensive_before_merging/integrators/tier3/ensemble2.csv new file mode 100644 index 000000000..3222c6eba --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/test/expensive_before_merging/integrators/tier3/ensemble2.csv @@ -0,0 +1,301 @@ +cfg,arm,rep,rc,failed,lnL,sigma_lnL,neff,dgrid_lnL_mean,dgrid_lnL_max,secs +A,base,1,0,0,67.11473668508705,0.16392798236061812,14.928475515059791,nan,nan,10 +A,cand,1,0,0,66.58737907805842,0.1784083893094088,8.500306763212938,nan,nan,9 +B,base,1,0,0,66.88374295529641,0.12782117557061187,21.52309694195693,nan,nan,10 +B,cand,1,0,0,66.58215254676799,0.08121850786982737,51.137791155114975,nan,nan,9 +D,base,1,0,0,66.65419092235788,0.17006328158166883,13.219118638352493,nan,nan,8 +D,cand,1,0,0,66.77908895548445,0.1370475904738456,16.652372166826808,nan,nan,8 +AV,base,1,0,0,67.26177057205835,0.06065388052132646,96.21061947319203,71.60015092169,73.37003336851359,10 +AV,cand,1,0,0,67.33902362519191,0.06302665273345376,103.48419503731607,71.62907858666931,72.28291881505068,11 +GMM,base,1,0,0,65.89634542305997,0.2667538551531655,8.911926721817107,76.14053505453668,109.07441729806007,23 +GMM,cand,1,0,0,66.1282218793277,0.3512611393039839,6.359235375101886,76.48731397102607,108.3316677142292,20 +A,base,2,0,0,66.36219914899725,0.20449571017472779,6.704563667722012,nan,nan,10 +A,cand,2,0,0,66.7966356187582,0.16896786846662895,8.911300945005339,nan,nan,9 +B,base,2,0,0,66.59765585290302,0.14624944483251315,25.179754550013293,nan,nan,9 +B,cand,2,0,0,66.51792034278634,0.10843782716330859,29.017017739501892,nan,nan,9 +D,base,2,0,0,66.8679680290235,0.2879043266430088,3.788971526547713,nan,nan,8 +D,cand,2,0,0,66.90201656820392,0.22200359267789432,5.263436635442925,nan,nan,8 +AV,base,2,0,0,67.29363921957409,0.06099960523281372,107.50447481983501,72.03645888690426,75.54930644043071,9 +AV,cand,2,0,0,67.24279650768914,0.06035896755382244,127.64112577298454,71.66869179521692,73.31195758762537,9 +GMM,base,2,0,0,66.17991836754692,0.24252874623726509,11.846965261628217,74.40275485347023,108.87349470989489,18 +GMM,cand,2,0,0,65.97762348690854,0.25081707113342394,8.946599864519271,70.0044979048013,71.31105357099784,17 +A,base,3,0,0,66.82321558386198,0.20147110465481485,9.809720658269946,nan,nan,9 +A,cand,3,0,0,66.88322686136168,0.20675873665522243,7.552829661944192,nan,nan,9 +B,base,3,0,0,66.80819278316383,0.12532368350486603,21.350038943716868,nan,nan,10 +B,cand,3,0,0,66.56207645420521,0.1278916479108125,23.630425858872044,nan,nan,10 +D,base,3,0,0,66.54910125546674,0.19050629983490117,9.907192285634446,nan,nan,8 +D,cand,3,0,0,66.66603296476018,0.20388118844432096,6.381748173312033,nan,nan,8 +AV,base,3,0,0,67.30772984051971,0.06394604887142193,120.70326544743962,72.06467761565428,74.13655369238643,11 +AV,cand,3,0,0,67.19281241961731,0.06891328963731164,122.1957260774504,71.80684438779433,74.74775341822559,10 +GMM,base,3,0,0,67.84021443709769,0.41155058119781956,5.197240377740131,76.439396473545,110.15617587333728,19 +GMM,cand,3,0,0,66.506148130815,0.4088051222496118,8.728088006958735,70.51005225888625,72.82645577375752,19 +A,base,4,0,0,66.3547274021195,0.15469134115872052,13.33420099959256,nan,nan,7 +A,cand,4,0,0,66.79195995481204,0.3701217164068033,3.150963782221899,nan,nan,8 +B,base,4,0,0,66.58476317298658,0.08729183143074228,45.83810953052577,nan,nan,8 +B,cand,4,0,0,66.57668607468676,0.10167585044004095,31.35349243223152,nan,nan,9 +D,base,4,0,0,66.25174185574619,0.13133670118818222,18.170435259536188,nan,nan,8 +D,cand,4,0,0,66.7234970051984,0.19532823072711347,6.834233209597565,nan,nan,8 +AV,base,4,0,0,67.2589749499584,0.06034913828651035,132.80530496296078,73.21195141557219,109.69745940740681,11 +AV,cand,4,0,0,67.32253485918453,0.061022289658436396,112.63286143126741,71.7131621248921,75.14003553436537,10 +GMM,base,4,0,0,66.54803009600484,0.546642754619647,3.664984159824752,73.38201107524087,106.58979117433321,17 +GMM,cand,4,0,0,66.17788176825107,0.282489401499411,8.04649021283051,76.16121528532821,109.59532894978845,17 +A,base,5,0,0,66.76716233905833,0.18842416400598827,7.179154258239169,nan,nan,9 +A,cand,5,0,0,66.67858182344489,0.16275842353691258,9.33374373566156,nan,nan,8 +B,base,5,0,0,66.50540703611003,0.12719430140485868,37.86847496887512,nan,nan,9 +B,cand,5,0,0,66.88451341527852,0.15253512466100225,18.343798438955417,nan,nan,9 +D,base,5,0,0,66.63803803282046,0.20353025454474782,7.684797773008029,nan,nan,9 +D,cand,5,0,0,66.67925917902623,0.22669376922434628,7.809881420086583,nan,nan,8 +AV,base,5,0,0,67.31201173189004,0.06093510880434795,102.15763891888847,71.56790702611768,73.3379643244246,10 +AV,cand,5,0,0,67.35756223397782,0.06156871330641822,111.9765476036631,71.67439043716755,74.0563137363266,10 +GMM,base,5,0,0,67.02459103949282,0.36308304029308064,5.483355743200404,77.03479222882099,110.90002585815407,20 +GMM,cand,5,0,0,66.64731494756718,0.5702610387874232,2.580326740442386,69.76276884505617,72.58735976119692,20 +A,base,6,0,0,66.26919729222219,0.18350195410037415,8.146241319478957,nan,nan,9 +A,cand,6,0,0,66.92164985715861,0.191593597465731,8.232669769711634,nan,nan,8 +B,base,6,0,0,66.48053907644133,0.09406330615656437,34.16343846496077,nan,nan,8 +B,cand,6,0,0,66.6888371536088,0.12987203284441573,20.218874748670977,nan,nan,10 +D,base,6,0,0,66.52610126169931,0.18630235579698365,8.98876348037139,nan,nan,8 +D,cand,6,0,0,66.47844080536338,0.22530462486814837,6.700154898204894,nan,nan,9 +AV,base,6,0,0,67.29262898149302,0.06892795590747942,111.59058983509378,73.36073531284445,110.38071575001882,9 +AV,cand,6,0,0,67.2684042138176,0.05950412114578768,112.48692823343903,73.46045630278537,108.7952527513208,8 +GMM,base,6,0,0,66.37412057172277,0.45318956406241234,4.058557326061345,70.59162645417676,73.74673793868145,16 +GMM,cand,6,0,0,66.58677053893119,0.34525618916319767,6.241539851347796,75.127568685832,110.77087296320227,18 +A,base,7,0,0,66.46763692601517,0.19094033063162677,7.659305268081379,nan,nan,9 +A,cand,7,0,0,66.88088024322538,0.20350979881458964,8.250886743113456,nan,nan,8 +B,base,7,0,0,66.58126153192289,0.14205252547503253,40.02331415019134,nan,nan,9 +B,cand,7,0,0,66.60894585084914,0.10186129386050237,29.51644012260925,nan,nan,11 +D,base,7,0,0,66.41413771795077,0.18232125872129434,9.938360406087257,nan,nan,9 +D,cand,7,0,0,66.52839225201254,0.20469279481792713,8.111031698373223,nan,nan,9 +AV,base,7,0,0,67.21561100796495,0.0728397566709354,105.72496416748814,71.36061067590994,73.18964234075952,10 +AV,cand,7,0,0,67.2994701078574,0.06060715474201121,102.95403072793961,71.4387162034287,73.30427980864002,10 +GMM,base,7,0,0,66.8948892220445,0.4293860132087539,4.295103004554285,81.3339042111022,109.64530550248762,17 +GMM,cand,7,0,0,66.19616456697662,0.5968647422207597,7.417122168554211,73.30793299532476,106.24948370194994,17 +A,base,8,0,0,66.54055598959793,0.1633875468597376,15.46859628206887,nan,nan,9 +A,cand,8,0,0,66.64177591074775,0.20459791905110197,8.063031858389175,nan,nan,8 +B,base,8,0,0,66.71414528465641,0.10309288021042479,28.305610775458028,nan,nan,10 +B,cand,8,0,0,66.81933585401886,0.1893936333529415,11.218048583212749,nan,nan,9 +D,base,8,0,0,66.40109887930093,0.15956013715438286,10.730894485814957,nan,nan,9 +D,cand,8,0,0,66.33688276553663,0.14671377424503235,17.8460173993921,nan,nan,9 +AV,base,8,0,0,67.27886421432923,0.06177176647824158,93.0463867829062,71.67967490444393,73.67740567026682,9 +AV,cand,8,0,0,67.2077108565116,0.0588616107579744,127.43447220717626,71.82955190520411,74.44770159297755,9 +GMM,base,8,0,0,66.49576989420592,0.2911650591825579,9.91136026246702,74.9806180720112,107.24366463253217,17 +GMM,cand,8,0,0,67.05345954482075,0.8200071406882609,3.24675333947357,70.56690703512919,73.10202669023082,19 +A,base,9,0,0,66.52105915857356,0.16297287860330711,13.372965911069166,nan,nan,8 +A,cand,9,0,0,66.70049164543191,0.38768160579332006,2.6735435052754553,nan,nan,9 +B,base,9,0,0,66.713158061614,0.16016154792363094,25.988203741761538,nan,nan,9 +B,cand,9,0,0,66.62224992610648,0.10528644443702884,30.750951107707014,nan,nan,9 +D,base,9,0,0,66.38310636394058,0.18011675529042434,8.560105428985013,nan,nan,8 +D,cand,9,0,0,66.44523164746619,0.1697233948997189,10.304353057694598,nan,nan,9 +AV,base,9,0,0,67.31316566410251,0.07946643486581086,104.15980774775306,71.6825857013486,73.24076424040682,9 +AV,cand,9,0,0,67.24583602280963,0.061081927513950175,104.24150854322183,71.48330770553966,73.16143953060352,9 +GMM,base,9,0,0,65.96806732367715,0.25432266804383563,9.276156202876194,73.73026832267662,108.28319874092927,16 +GMM,cand,9,0,0,67.13593587699562,0.4850359251103216,3.0229872095536234,70.51766166561437,73.72414458698373,17 +A,base,10,0,0,66.52927034719782,0.2117553623106783,7.759600432490676,nan,nan,8 +A,cand,10,0,0,66.75635290639585,0.23387819046373237,5.16011650265007,nan,nan,8 +B,base,10,0,0,66.8970512811505,0.14377796462807477,16.08688032190521,nan,nan,9 +B,cand,10,0,0,66.50999221000251,0.12253853778112304,23.837538056035466,nan,nan,8 +D,base,10,0,0,66.93016182726069,0.3712560975442621,2.8540830989401402,nan,nan,8 +D,cand,10,0,0,66.78713905827556,0.16287605080998438,14.252911364966518,nan,nan,8 +AV,base,10,0,0,67.30256497760598,0.06132238879593425,100.26535258934557,71.59977738478824,74.79969815747668,9 +AV,cand,10,0,0,67.43694906358395,0.07305853308221848,102.33425240081326,71.43697478934146,72.4219559315452,10 +GMM,base,10,0,0,66.23415284376887,0.35471586718022774,5.431738373754948,70.42844753154935,72.62218358881941,20 +GMM,cand,10,0,0,66.47913836145611,0.31700878315098513,8.665658732433718,71.05892118297658,71.82416695239162,22 +A,base,11,0,0,66.77845360787354,0.19866894095793378,8.655805118526986,nan,nan,11 +A,cand,11,0,0,66.95532918781278,0.17557793389720538,9.40850004676225,nan,nan,10 +B,base,11,0,0,66.72654174535315,0.13364798441615008,21.09651115958584,nan,nan,10 +B,cand,11,0,0,66.66344822990143,0.09764592506505818,32.09554466469232,nan,nan,11 +D,base,11,0,0,66.63135743925633,0.1605776765738179,15.388535087766456,nan,nan,9 +D,cand,11,0,0,67.00780931867106,0.31395495703060783,3.4641538383231154,nan,nan,10 +AV,base,11,0,0,67.14231802049709,0.059749173408889834,117.84551462346722,71.66841412143131,72.80849734524627,10 +AV,cand,11,0,0,67.29395997248102,0.05864911039335097,121.64874304227966,71.6922519325372,73.47947557680098,11 +GMM,base,11,0,0,66.19895075044383,0.3764836771313075,5.359593597169257,73.67984788230348,109.45919970269173,19 +GMM,cand,11,0,0,66.15700814409672,0.34446337514747116,6.124433387686423,73.58600719398176,108.75608672571748,21 +A,base,12,0,0,66.76015832969324,0.2436348686791618,6.311446156403474,nan,nan,11 +A,cand,12,0,0,66.39245192631938,0.22361247991481795,5.628841060282776,nan,nan,10 +B,base,12,0,0,66.652522170547,0.10127189978099839,32.06203844830727,nan,nan,11 +B,cand,12,0,0,66.4946526798895,0.09118077231004112,37.45155488905059,nan,nan,12 +D,base,12,0,0,66.53186288780624,0.1959798522407383,7.012155584197614,nan,nan,10 +D,cand,12,0,0,66.68938161842584,0.20876884447573832,6.993370222265429,nan,nan,9 +AV,base,12,0,0,67.30654712944686,0.059210971750538174,118.51581694341397,71.64391836112105,73.92987175763658,11 +AV,cand,12,0,0,67.24195646907837,0.09828730705485186,81.84406912019296,71.46034818215381,73.89492217306619,11 +GMM,base,12,0,0,67.09505254888933,0.48446429109202815,4.164452666978826,71.50268358974607,73.96202746082287,17 +GMM,cand,12,0,0,66.39338403539028,0.6199515886813209,8.395109703325447,70.41656470544716,72.57787793674058,18 +A,base,13,0,0,66.6222879596841,0.20870906196158823,8.590072487946633,nan,nan,9 +A,cand,13,0,0,66.58594868152414,0.15005795897358923,9.259590224156767,nan,nan,9 +B,base,13,0,0,66.80416448715187,0.14908627049367598,15.906051057129948,nan,nan,10 +B,cand,13,0,0,66.6113799984193,0.17966554920498176,35.73836335063852,nan,nan,11 +D,base,13,0,0,66.5109177807087,0.2233956541944356,6.500415301500902,nan,nan,10 +D,cand,13,0,0,66.4160893906525,0.1666975583208613,8.387322797770778,nan,nan,11 +AV,base,13,0,0,67.20421142443001,0.09165271369772253,103.84432337048327,71.62736723691036,73.41051530026135,11 +AV,cand,13,0,0,67.16769211846703,0.08006328536151922,100.02960075333905,71.62663799730043,72.67920797934885,11 +GMM,base,13,0,0,67.38263510773577,0.5635474519652868,3.248832221224094,73.800447118302,108.17528690625312,19 +GMM,cand,13,0,0,66.8152366262089,0.2863837868122077,8.442672764098143,75.02442634237975,110.07237335043581,18 +A,base,14,0,0,66.94205286939243,0.2677097689915621,6.2872397018615445,nan,nan,8 +A,cand,14,0,0,66.59078702789857,0.16037095743694044,16.68177103250158,nan,nan,8 +B,base,14,0,0,66.70666932209636,0.08993398944914087,40.111378999365925,nan,nan,9 +B,cand,14,0,0,66.38345588217231,0.07844974437577065,54.99572501973355,nan,nan,9 +D,base,14,0,0,66.25437881560723,0.1609352378762636,12.433112652090868,nan,nan,8 +D,cand,14,0,0,66.48568606799468,0.19985679601354758,9.156851002882023,nan,nan,8 +AV,base,14,0,0,67.18996412317755,0.06027888867331743,119.97226434336316,71.90856749199816,73.90554587216711,9 +AV,cand,14,0,0,67.27604509626867,0.059896679874784524,117.51125805472404,71.49476700563356,73.53391911913707,9 +GMM,base,14,0,0,67.0010695255892,0.7773640166013446,6.0781220454053475,73.52003853539516,105.97476586466854,18 +GMM,cand,14,0,0,65.9039199535963,0.46858807676674946,8.67145775554885,71.77194440341613,106.92773182664516,19 +A,base,15,0,0,66.76409805771227,0.16192470337242548,10.006258723804166,nan,nan,9 +A,cand,15,0,0,66.61417023293089,0.268826286074972,4.189759002966466,nan,nan,9 +B,base,15,0,0,66.50879160946032,0.0979857808154375,33.28906204255058,nan,nan,10 +B,cand,15,0,0,66.57834953691813,0.08843921473185798,44.836964385559675,nan,nan,10 +D,base,15,0,0,66.7357216140138,0.14735572280880188,18.893830422835237,nan,nan,10 +D,cand,15,0,0,66.37874379417042,0.14547075772389342,18.49721967573249,nan,nan,10 +AV,base,15,0,0,67.16033444335687,0.06226619507096394,91.75761597369271,71.57078420715382,74.06489806015782,12 +AV,cand,15,0,0,67.24358117032727,0.06277652408912295,78.12026105944513,71.96409974515544,73.48761895587151,9 +GMM,base,15,0,0,66.24675367868929,0.27389771126836243,8.011793406297748,70.86912033502689,72.52401731227361,17 +GMM,cand,15,0,0,66.13508135476901,0.28423437114937655,7.804370281371581,70.9372917686839,73.17123469830217,18 +A,base,16,0,0,66.5291639864864,0.20168700650794036,6.274749936454826,nan,nan,9 +A,cand,16,0,0,66.21277623144793,0.1647160108425664,11.839088736191723,nan,nan,8 +B,base,16,0,0,66.5825092902021,0.13948289548499646,19.56424974010575,nan,nan,9 +B,cand,16,0,0,66.6611640597252,0.1522056331863241,17.586574800942127,nan,nan,9 +D,base,16,0,0,66.5633002331695,0.16240186002990614,15.393277597351139,nan,nan,9 +D,cand,16,0,0,66.5283388060282,0.17713198920644985,10.669989199671603,nan,nan,9 +AV,base,16,0,0,67.23469487014107,0.06089237056983646,105.39378398241833,71.74613355371918,73.34746717470958,9 +AV,cand,16,0,0,67.33405933127634,0.0709514209331001,122.61137660392511,71.56019356014905,72.71773558464291,9 +GMM,base,16,0,0,67.06516702432833,0.5061394670700472,4.448759936023422,73.80593985602151,108.78261355896171,16 +GMM,cand,16,0,0,66.89817476032275,0.28396337002536565,7.22232195523822,77.18511057543452,109.24001207690681,18 +A,base,17,0,0,66.53788289870505,0.1606359705498035,12.206425391848207,nan,nan,9 +A,cand,17,0,0,66.30619112336174,0.2694190623893625,4.167761138416115,nan,nan,9 +B,base,17,0,0,66.51836352565685,0.10039668541863762,33.76731776579694,nan,nan,11 +B,cand,17,0,0,66.52561684105143,0.1420037135927573,16.69267268513254,nan,nan,11 +D,base,17,0,0,66.79099757885454,0.20220910701477246,6.1697372476656644,nan,nan,9 +D,cand,17,0,0,66.3451562004805,0.1515871519083809,13.92657258840181,nan,nan,9 +AV,base,17,0,0,67.16915955119063,0.06142277145518612,128.352572956555,71.62995001811856,74.07688943823656,10 +AV,cand,17,0,0,67.23661847952954,0.05911952732044566,121.96790524962603,71.63165636819619,74.03531092503547,10 +GMM,base,17,0,0,65.42819697980381,0.4567065958243231,7.089255066729108,71.67031037987275,105.46400809626591,17 +GMM,cand,17,0,0,67.4041726498326,0.4695248530894314,3.8218890839404867,71.84541906850976,73.78450710807418,17 +A,base,18,0,0,66.83451080686594,0.15306104028494497,11.99644698664446,nan,nan,9 +A,cand,18,0,0,66.22453894460328,0.17213101669584602,8.93990040095426,nan,nan,8 +B,base,18,0,0,66.8262924433696,0.12029060657286152,20.236720872687542,nan,nan,9 +B,cand,18,0,0,66.57306015492135,0.09858668398265487,33.493913531452776,nan,nan,10 +D,base,18,0,0,66.34676075696329,0.24927675214561024,4.743440997648601,nan,nan,8 +D,cand,18,0,0,66.77040248994477,0.1618427557233396,13.197949915952307,nan,nan,8 +AV,base,18,0,0,67.26847696795441,0.06075335809201572,126.99058087366934,71.49844635799356,73.24232032608298,9 +AV,cand,18,0,0,67.24510163538417,0.060788614265474245,106.99844609175543,71.30271481869013,72.7456265375701,9 +GMM,base,18,0,0,66.6125900361984,0.32098740473673953,6.361703488168335,75.18763219895155,109.87257246417606,18 +GMM,cand,18,0,0,66.36221400792638,0.2968792124562214,7.359656628414663,70.50763015176065,71.62276879601217,18 +A,base,19,0,0,66.90616167296172,0.2542983217865598,4.437883862286133,nan,nan,9 +A,cand,19,0,0,66.45921881842254,0.18975362085815123,7.711380084233412,nan,nan,9 +B,base,19,0,0,66.74969927382178,0.11326890263540383,29.806557942203806,nan,nan,9 +B,cand,19,0,0,66.77766198053804,0.1469981674881646,16.994767390677808,nan,nan,9 +D,base,19,0,0,66.70266949624812,0.3382580342243928,3.190886947318972,nan,nan,8 +D,cand,19,0,0,66.65798003012846,0.2166533412879603,6.027248045334387,nan,nan,8 +AV,base,19,0,0,67.19405356378148,0.06043372833737057,118.83144554224846,71.563293922472,73.48849651538481,9 +AV,cand,19,0,0,67.39807995487723,0.06094586970769123,104.11312338205494,71.94140193539765,75.0730818093561,10 +GMM,base,19,0,0,66.1405898086568,0.2904717458976045,7.411985407976482,74.25432187958614,109.18060951079715,17 +GMM,cand,19,0,0,67.18726671694169,0.5663069444823438,4.717190600602201,74.99256136728681,110.09199257988526,16 +A,base,20,0,0,66.54095455262922,0.16092779534455073,10.913776472246155,nan,nan,8 +A,cand,20,0,0,66.61507864386407,0.13717425962484,21.778349187185984,nan,nan,8 +B,base,20,0,0,66.47517856685296,0.11555564617231365,26.07945791526403,nan,nan,9 +B,cand,20,0,0,66.62211881356318,0.10912203861129091,29.647349733254156,nan,nan,8 +D,base,20,0,0,66.68252449965242,0.238169118952141,5.510500919895205,nan,nan,7 +D,cand,20,0,0,67.00966734749059,0.3111126476579373,4.466179457593055,nan,nan,8 +AV,base,20,0,0,67.24799303101251,0.061299100798252994,103.42628891876916,71.48557914349217,73.47212153837016,9 +AV,cand,20,0,0,67.2115919513633,0.062165494500936766,105.77532617464841,71.40099602921696,72.41611340427708,9 +GMM,base,20,0,0,67.4481297299026,0.6580213202377087,1.8868972748404875,70.40900211164828,73.50349985891599,16 +GMM,cand,20,0,0,67.47120506305596,0.3310693085809401,6.538494057126797,74.80033466693298,108.9204184179494,17 +A,base,21,0,0,66.84838314887165,0.24936998833250407,4.584016060897539,nan,nan,8 +A,cand,21,0,0,66.46241578875286,0.1799771784793265,10.865181573086254,nan,nan,8 +B,base,21,0,0,66.5702099966822,0.15639225293767892,36.99023169705039,nan,nan,10 +B,cand,21,0,0,66.53266126982712,0.09978697761215877,32.760988077166346,nan,nan,9 +D,base,21,0,0,66.6275231730268,0.15978570014386959,13.630257944176764,nan,nan,8 +D,cand,21,0,0,66.62705521588319,0.1903711226538088,10.204584684485633,nan,nan,9 +AV,base,21,0,0,67.1320708302387,0.06455249381767358,99.2641046546748,71.52237800211842,72.89914576588357,9 +AV,cand,21,0,0,67.15679866629357,0.09306494445245359,102.90748995735044,71.39691462700988,74.4559851727129,9 +GMM,base,21,0,0,66.14248319529439,0.247127016596899,9.594039250463403,70.87265572869067,73.80119632042816,16 +GMM,cand,21,0,0,66.58931224033826,0.3446640985817793,5.795457196292395,70.48110404628174,74.30688533782356,17 +A,base,22,0,0,66.42110271881931,0.1858280601123635,7.521117126494227,nan,nan,9 +A,cand,22,0,0,66.35879284012454,0.16684174045150982,13.372583522121888,nan,nan,8 +B,base,22,0,0,66.54228666285766,0.0920606278100717,34.529333846743675,nan,nan,9 +B,cand,22,0,0,66.55261566335176,0.12518251039836023,32.24382969289989,nan,nan,9 +D,base,22,0,0,66.3512743080345,0.14307858457941727,12.445424557024786,nan,nan,8 +D,cand,22,0,0,66.45638443412035,0.16215048027690004,14.61251147038529,nan,nan,8 +AV,base,22,0,0,67.3710944116138,0.07093808680120103,106.74899209087064,71.89808199552115,74.41878241102299,9 +AV,cand,22,0,0,67.27245017198642,0.06069063553882634,97.73018010065817,71.87673147172808,73.36159624616056,9 +GMM,base,22,0,0,66.90995402207746,0.4522336667596963,4.164277280461312,71.23499189815385,73.63403786806633,16 +GMM,cand,22,0,0,66.64063507463509,0.2664500822564878,10.050107148985932,73.69762840006501,110.0206824282045,18 +A,base,23,0,0,67.0699941592175,0.3728172594783907,2.797334362223388,nan,nan,8 +A,cand,23,0,0,66.54087790493323,0.16556072510879918,10.781404183441785,nan,nan,10 +B,base,23,0,0,66.5918981604862,0.11720786191019257,38.41345545460173,nan,nan,10 +B,cand,23,0,0,66.64016772212602,0.1320547384573391,20.42843023507422,nan,nan,10 +D,base,23,0,0,66.56092398029617,0.2125729446426754,9.614119426025471,nan,nan,9 +D,cand,23,0,0,66.5350286058114,0.16174972996774903,14.119581108269182,nan,nan,9 +AV,base,23,0,0,67.26764469863423,0.0633409031424116,84.54599150913636,71.69051083122689,73.106279091915,9 +AV,cand,23,0,0,67.25628585303076,0.08590227964214518,106.59744830989371,71.49047954442761,73.7180769040885,8 +GMM,base,23,0,0,66.64213237738217,0.28172768323589337,8.644240252341323,76.59249640467958,110.5973076480456,18 +GMM,cand,23,0,0,66.82810411493048,0.6309335768234543,2.9049698261418113,76.2453397415084,112.8333414646811,17 +A,base,24,0,0,66.53137890596872,0.1444912708134028,15.394340446957399,nan,nan,8 +A,cand,24,0,0,66.56203812248073,0.2628567796071066,4.491405101922059,nan,nan,9 +B,base,24,0,0,66.56472703167339,0.11993206361194123,28.30725158613797,nan,nan,9 +B,cand,24,0,0,66.53417149030123,0.13986063694870987,17.86377630119108,nan,nan,9 +D,base,24,0,0,66.98145110513094,0.19198563280091777,9.48627908393527,nan,nan,8 +D,cand,24,0,0,66.4790664902459,0.13598508809275,19.92309552301964,nan,nan,8 +AV,base,24,0,0,67.20998919609329,0.062373694080355355,100.26665210577833,71.64924247606453,73.27379384169825,9 +AV,cand,24,0,0,67.26325820013571,0.06363928700273062,99.36021472240274,71.75159981718872,73.53201377826576,8 +GMM,base,24,0,0,66.56738433934932,0.4388452286558974,4.031472016898324,70.60109318379749,72.87930806015184,16 +GMM,cand,24,0,0,66.70221052565878,0.32606276320716465,6.3826093596803295,70.84802465867232,72.84533556207569,18 +A,base,25,0,0,66.79756405076354,0.21648044507998315,8.042016606969641,nan,nan,8 +A,cand,25,0,0,66.5935831783952,0.17442483512476745,7.298595621363768,nan,nan,8 +B,base,25,0,0,66.65519795336574,0.11236139507934267,26.387604673931126,nan,nan,9 +B,cand,25,0,0,66.52326246627904,0.09053454621485031,35.470124693387795,nan,nan,9 +D,base,25,0,0,66.41768094515237,0.15470449928702681,12.878992140131242,nan,nan,9 +D,cand,25,0,0,66.5248540137408,0.23278800040919195,5.430251431210038,nan,nan,8 +AV,base,25,0,0,67.28250123688136,0.06385735316131293,112.18881518291707,73.56523624419118,108.85438502157493,9 +AV,cand,25,0,0,67.25098205476759,0.06022746844491118,123.3423131122221,73.52065179351725,109.25816145818044,10 +GMM,base,25,0,0,66.45346639033576,0.6005313306351622,2.440520580953972,79.24606144513821,109.41262920140697,17 +GMM,cand,25,0,0,66.39828875613478,0.37859299550512826,5.7898845727020225,73.39464257974562,109.513511994014,17 +A,base,26,0,0,66.46880191611422,0.26601849031650765,5.393911788045052,nan,nan,9 +A,cand,26,0,0,66.50488619371836,0.18691674612226553,8.02734534478015,nan,nan,9 +B,base,26,0,0,66.67750459572213,0.12243103027595456,34.688484860857564,nan,nan,9 +B,cand,26,0,0,66.69847398375944,0.16325553345558147,32.94030421443467,nan,nan,9 +D,base,26,0,0,66.7610573609199,0.24039679599843036,5.923360268873995,nan,nan,8 +D,cand,26,0,0,66.649240829544,0.19751702624043324,7.423182001641133,nan,nan,8 +AV,base,26,0,0,67.19195639958988,0.06063155601052853,119.8104239315123,71.38633995765156,72.39903495395635,9 +AV,cand,26,0,0,67.26494418729916,0.06070331626078871,126.19464663443362,71.20144596712741,72.4093159361167,9 +GMM,base,26,0,0,66.72910738281443,0.5179822950065326,5.280598639773675,74.25208102173639,108.61226006821776,17 +GMM,cand,26,0,0,65.89016601799118,0.24321952577090544,10.844488664447882,80.17670978934501,108.58827424673024,18 +A,base,27,0,0,66.50348344444834,0.212098176933094,5.824833443400036,nan,nan,9 +A,cand,27,0,0,66.74348016121665,0.3230956136877463,3.2722279384459103,nan,nan,9 +B,base,27,0,0,66.54256942305881,0.12485101873797311,27.182821343387022,nan,nan,10 +B,cand,27,0,0,66.7188343230144,0.19008628368827846,14.144384000823413,nan,nan,10 +D,base,27,0,0,66.84551161377374,0.20053225246161835,9.104547402559467,nan,nan,9 +D,cand,27,0,0,66.83579668440835,0.19009372786707132,10.373932632070177,nan,nan,9 +AV,base,27,0,0,67.34679054421322,0.05975766336047638,105.77893778201563,71.87434560489018,75.22528883586207,11 +AV,cand,27,0,0,67.2124159261826,0.07409696550479553,95.48824024752813,71.82047952563104,74.65577223701764,9 +GMM,base,27,0,0,66.16788719691917,0.2530812475981168,9.451562845585947,70.59171825733816,72.33789776052177,17 +GMM,cand,27,0,0,65.97559880483306,0.2766342235097537,9.564074497510545,86.53344507921352,108.97487063795052,18 +A,base,28,0,0,66.52019051174597,0.23822756731061392,4.993831393492872,nan,nan,8 +A,cand,28,0,0,66.42248482669284,0.14513964543521862,14.340176029072174,nan,nan,8 +B,base,28,0,0,66.72973060233471,0.18266136536855102,32.86007629744961,nan,nan,10 +B,cand,28,0,0,66.88585327347444,0.18233625896060232,13.183275749152775,nan,nan,9 +D,base,28,0,0,66.77264556649905,0.19866850381781564,9.81877486926352,nan,nan,8 +D,cand,28,0,0,66.92112436634385,0.28237395365078394,4.001921346826034,nan,nan,9 +AV,base,28,0,0,67.22125599242908,0.06058097352030991,99.85528181339278,71.5480863714188,73.33091100669998,9 +AV,cand,28,0,0,67.23804264312385,0.06249611681682931,118.92573215851651,71.70555195283131,72.88615370804665,9 +GMM,base,28,0,0,67.08639276298217,0.38925163138795005,4.912131809236954,76.53540348421177,110.28804816308967,17 +GMM,cand,28,0,0,66.59312046658765,0.7083178270524403,1.6996436939448158,80.11000134144058,107.26244891428274,17 +A,base,29,0,0,66.81494693340741,0.16010633795023296,18.221027241841426,nan,nan,8 +A,cand,29,0,0,66.72888278315807,0.4795303968766184,2.1205105337648167,nan,nan,8 +B,base,29,0,0,66.63293440862321,0.10053255960098749,33.44772183508666,nan,nan,9 +B,cand,29,0,0,66.74746829783909,0.12062032142007537,31.24399265771186,nan,nan,8 +D,base,29,0,0,66.63561925553218,0.19080786677570036,7.70766496390216,nan,nan,8 +D,cand,29,0,0,66.46675412026144,0.24173240962844925,4.676849693675205,nan,nan,8 +AV,base,29,0,0,67.19443244538311,0.06472530664066946,94.87767255926747,71.25099959924668,72.49944405227788,9 +AV,cand,29,0,0,67.23315569530946,0.06375795077588199,109.28358684195521,71.41570714730311,72.7273340417205,8 +GMM,base,29,0,0,66.3131170848151,0.4008878065970567,6.354438405109509,76.49316587432875,107.6386076971974,17 +GMM,cand,29,0,0,66.58455364108545,0.7394540554190094,7.991283131660379,78.37230798956737,109.38709093481586,17 +A,base,30,0,0,66.47828303370646,0.16828237467049106,8.974852232026935,nan,nan,8 +A,cand,30,0,0,66.45201404433395,0.2131491617747461,8.817584607200342,nan,nan,10 +B,base,30,0,0,66.68969616779563,0.15708895029150963,24.028509574228302,nan,nan,10 +B,cand,30,0,0,66.84731680715775,0.2567701554954724,12.643817664940082,nan,nan,10 +D,base,30,0,0,66.87580780879676,0.22748946890726937,8.11168172067457,nan,nan,9 +D,cand,30,0,0,66.72182598105844,0.21136518665177886,6.3218752402821625,nan,nan,8 +AV,base,30,0,0,67.20014537329388,0.06744530384439651,104.80281963549176,71.42389475434216,74.22269681028449,9 +AV,cand,30,0,0,67.20920361521709,0.06547312297373022,110.48508501466087,71.70565123397007,73.25844409016388,9 +GMM,base,30,0,0,66.5120957464486,0.5424027654886697,4.659001424007903,73.34192270756752,108.94625335645986,17 +GMM,cand,30,0,0,65.57848216085979,0.3887298296397083,8.826409945078918,70.72567101806801,73.52262915680183,17 diff --git a/MonteCarloMarginalizeCode/Code/test/expensive_before_merging/integrators/tier3/ensemble3.csv b/MonteCarloMarginalizeCode/Code/test/expensive_before_merging/integrators/tier3/ensemble3.csv new file mode 100644 index 000000000..2840768aa --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/test/expensive_before_merging/integrators/tier3/ensemble3.csv @@ -0,0 +1,151 @@ +cfg,arm,rep,rc,failed,lnL,sigma_lnL,neff,dgrid_lnL_mean,dgrid_lnL_max,secs +A,base,1,0,0,66.78966522552969,0.14964304879021062,14.61286419739411,nan,nan,16 +A,cand,1,0,0,66.78966522552969,0.14964304879021081,14.612864197394128,nan,nan,10 +B,base,1,0,0,66.5787342687934,0.08602278061190108,42.89132049981725,nan,nan,12 +B,cand,1,0,0,66.5787342687934,0.08602278061190645,42.8913204998173,nan,nan,11 +D,base,1,0,0,66.74161666632813,0.13945682114752056,17.348654835058824,nan,nan,11 +D,cand,1,0,0,66.74161666632794,0.13945682114750266,17.348654835056877,nan,nan,10 +AV,base,1,0,0,67.29414461196241,0.06288117050456414,102.85867704245004,72.19921650827823,74.5779339243629,12 +AV,cand,1,0,0,67.29414461196241,0.06288117050456414,102.85867704245004,72.19921650827823,74.5779339243629,12 +GMM,base,1,0,0,66.78218533647913,0.5032464843048173,3.489358719719847,73.69509120410305,108.27387006528195,19 +GMM,cand,1,0,0,66.78218533647913,0.5032464843048173,3.489358719719847,73.69509120410305,108.27387006528195,21 +A,base,2,0,0,66.57931611686517,0.2383005960552487,6.504137504550438,nan,nan,12 +A,cand,2,0,0,66.57931611686519,0.2383005960552497,6.504137504550326,nan,nan,10 +B,base,2,0,0,66.44727916034434,0.10114308377874591,33.393917781945866,nan,nan,12 +B,cand,2,0,0,66.44727916034437,0.10114308377874409,33.39391778194683,nan,nan,11 +D,base,2,0,0,66.63625390437473,0.1896286190888218,8.241295896290003,nan,nan,11 +D,cand,2,0,0,66.63625390437473,0.189628619088824,8.241295896290016,nan,nan,10 +AV,base,2,0,0,67.22854623656895,0.06241344424178761,95.77319336098894,71.79265825052153,73.29802815551136,11 +AV,cand,2,0,0,67.22854623656895,0.06241344424178761,95.77319336098894,71.79265825052153,73.29802815551136,12 +GMM,base,2,0,0,66.7699646166049,0.331455118616663,6.363704685264169,71.16526784749476,72.21436822709096,19 +GMM,cand,2,0,0,66.7699646166049,0.331455118616663,6.363704685264169,71.16526784749476,72.21436822709096,19 +A,base,3,0,0,66.74997568033477,0.1775297462721331,8.950501870108257,nan,nan,11 +A,cand,3,0,0,66.74997568033476,0.17752974627213247,8.950501870108205,nan,nan,10 +B,base,3,0,0,66.6279009099132,0.10396324923820524,26.994015224433607,nan,nan,11 +B,cand,3,0,0,66.6279009099132,0.1039632492382051,26.994015224433547,nan,nan,10 +D,base,3,0,0,66.74067434896078,0.15793234924510113,16.61726415447276,nan,nan,11 +D,cand,3,0,0,66.7406743489608,0.15793234924510016,16.617264154472846,nan,nan,12 +AV,base,3,0,0,67.2946044864974,0.06259811191403163,101.64952791882807,71.22536685089923,72.30373967587174,12 +AV,cand,3,0,0,67.2946044864974,0.06259811191403163,101.64952791882807,71.22536685089923,72.30373967587174,13 +GMM,base,3,0,0,66.53059820451388,0.6587656810589407,4.122609341618768,74.5159862025726,109.80393661817685,21 +GMM,cand,3,0,0,66.53059820451388,0.6587656810589407,4.122609341618768,74.5159862025726,109.80393661817685,20 +A,base,4,0,0,66.42759499332855,0.17731542676509793,11.581027768186038,nan,nan,12 +A,cand,4,0,0,66.42759499332855,0.17731542676509776,11.581027768185965,nan,nan,10 +B,base,4,0,0,66.77803972635088,0.11235690635818683,31.7012425564062,nan,nan,12 +B,cand,4,0,0,66.77803972635088,0.11235690635818683,31.701242556406534,nan,nan,11 +D,base,4,0,0,66.58475605237301,0.24046856639508232,5.09059213604579,nan,nan,10 +D,cand,4,0,0,66.58475605237301,0.24046856639508274,5.090592136045777,nan,nan,10 +AV,base,4,0,0,67.28176714451662,0.06462253986643808,102.44670037456982,71.40532237657762,72.75723693421263,12 +AV,cand,4,0,0,67.28176714451662,0.06462253986643808,102.44670037456982,71.40532237657762,72.75723693421263,12 +GMM,base,4,0,0,66.7235927371381,0.5584164228839693,10.078999393744521,73.8681602531445,109.25394695768591,20 +GMM,cand,4,0,0,66.7235927371381,0.5584164228839693,10.078999393744521,73.8681602531445,109.25394695768591,19 +A,base,5,0,0,66.54812599862287,0.1648650433910751,10.873894136610227,nan,nan,10 +A,cand,5,0,0,66.54812599862287,0.16486504339107527,10.873894136610266,nan,nan,10 +B,base,5,0,0,66.71207665842027,0.09699801265777944,34.57669043473278,nan,nan,11 +B,cand,5,0,0,66.71207665842027,0.09699801265777913,34.576690434732924,nan,nan,11 +D,base,5,0,0,66.64043603490123,0.1760002945762585,8.30571582188317,nan,nan,11 +D,cand,5,0,0,66.64043603490123,0.17600029457625777,8.30571582188313,nan,nan,10 +AV,base,5,0,0,67.18144895274496,0.06628158372230217,99.78749666971538,71.78742977412342,74.1830704839889,11 +AV,cand,5,0,0,67.18144895274496,0.06628158372230217,99.78749666971538,71.78742977412342,74.1830704839889,11 +GMM,base,5,0,0,67.07972300225975,0.3505105030176031,6.430614720505619,71.24112740160393,73.7655615252209,18 +GMM,cand,5,0,0,67.07972300225975,0.3505105030176031,6.430614720505619,71.24112740160393,73.7655615252209,21 +A,base,6,0,0,66.63858857885059,0.1834651684700292,9.782859374003516,nan,nan,12 +A,cand,6,0,0,66.6385885788506,0.1834651684700298,9.78285937400353,nan,nan,10 +B,base,6,0,0,66.59046309784087,0.09018248419410436,48.97463140430094,nan,nan,12 +B,cand,6,0,0,66.59046309784087,0.09018248419410436,48.9746314043014,nan,nan,13 +D,base,6,0,0,66.6525713314855,0.18129472022607276,13.025658517218114,nan,nan,18 +D,cand,6,0,0,66.65257133148549,0.18129472022607435,13.025658517217748,nan,nan,18 +AV,base,6,0,0,67.35194373052761,0.08900494984123507,117.3649436172547,71.78039606616849,73.73738202681979,13 +AV,cand,6,0,0,67.35194373052761,0.08900494984123507,117.3649436172547,71.78039606616849,73.73738202681979,14 +GMM,base,6,0,0,65.9029268031342,0.26283531223284745,9.207395882911678,70.39539242430716,71.6805908052018,20 +GMM,cand,6,0,0,65.9029268031342,0.26283531223284745,9.207395882911678,70.39539242430716,71.6805908052018,22 +A,base,7,0,0,66.63511688825416,0.2112255392982908,7.22322078889561,nan,nan,11 +A,cand,7,0,0,66.63511688825417,0.21122553929829277,7.223220788895424,nan,nan,11 +B,base,7,0,0,66.41919369392504,0.12321738790001409,29.585437521848817,nan,nan,11 +B,cand,7,0,0,66.41919369392504,0.12321738790001409,29.58543752184871,nan,nan,11 +D,base,7,0,0,66.51403343665893,0.16176357788485182,12.817534457545236,nan,nan,10 +D,cand,7,0,0,66.51403343665893,0.16176357788485202,12.817534457545127,nan,nan,10 +AV,base,7,0,0,67.23939099313016,0.06339764746099465,100.24463843595046,71.82042138046859,74.097918654782,11 +AV,cand,7,0,0,67.23939099313016,0.06339764746099465,100.24463843595046,71.82042138046859,74.097918654782,11 +GMM,base,7,0,0,67.04621989092446,0.6861321698452322,2.6476087750805637,71.37046666531761,75.05017714509734,21 +GMM,cand,7,0,0,67.04621989092446,0.6861321698452322,2.6476087750805637,71.37046666531761,75.05017714509734,23 +A,base,8,0,0,66.29237983067085,0.12570979092075604,20.105998769375873,nan,nan,12 +A,cand,8,0,0,66.29237983067085,0.12570979092075632,20.105998769375212,nan,nan,11 +B,base,8,0,0,66.60194605353115,0.14079802563866317,16.97380599983706,nan,nan,12 +B,cand,8,0,0,66.60194605353115,0.1407980256386634,16.973805999836962,nan,nan,12 +D,base,8,0,0,66.52349302164481,0.196251781034385,6.306231218690171,nan,nan,10 +D,cand,8,0,0,66.52349302164481,0.19625178103438592,6.306231218690134,nan,nan,10 +AV,base,8,0,0,67.25234522058956,0.06077602981464749,113.64347136087768,71.49674975415371,73.6697541266123,10 +AV,cand,8,0,0,67.25234522058956,0.06077602981464749,113.64347136087768,71.49674975415371,73.6697541266123,10 +GMM,base,8,0,0,66.58220944754882,0.3036259854088554,7.4345399191393,73.81687666185837,110.85936803536569,19 +GMM,cand,8,0,0,66.58220944754882,0.3036259854088554,7.4345399191393,73.81687666185837,110.85936803536569,19 +A,base,9,0,0,66.44423693786716,0.17034602378819433,9.469692663089369,nan,nan,10 +A,cand,9,0,0,66.44423693786716,0.1703460237881944,9.469692663089356,nan,nan,11 +B,base,9,0,0,66.74897934329076,0.1295472710569813,20.54385844419758,nan,nan,12 +B,cand,9,0,0,66.74897934329076,0.12954727105698124,20.543858444197543,nan,nan,12 +D,base,9,0,0,66.30990772349732,0.17079132448518053,9.018098003352032,nan,nan,12 +D,cand,9,0,0,66.3099077234973,0.17079132448518095,9.018098003351977,nan,nan,12 +AV,base,9,0,0,67.28496789327899,0.06183215516905664,118.22788561793931,72.16347657149507,75.20035774331528,13 +AV,cand,9,0,0,67.28496789327899,0.06183215516905664,118.22788561793931,72.16347657149507,75.20035774331528,12 +GMM,base,9,0,0,67.62868737491657,0.7888134241467947,1.5538065878469545,70.85403443343945,72.94539033654235,19 +GMM,cand,9,0,0,67.62868737491657,0.7888134241467947,1.5538065878469545,70.85403443343945,72.94539033654235,19 +A,base,10,0,0,66.73779865664594,0.3865673456799509,2.7242070262103266,nan,nan,10 +A,cand,10,0,0,66.73779865664594,0.38656734567995255,2.724207026210302,nan,nan,10 +B,base,10,0,0,66.69194428306453,0.12820661169691897,22.282009640750275,nan,nan,11 +B,cand,10,0,0,66.69194428306453,0.128206611696919,22.282009640750243,nan,nan,12 +D,base,10,0,0,66.59765655158982,0.23044920123674167,5.719494207573311,nan,nan,11 +D,cand,10,0,0,66.59765655158984,0.23044920123675033,5.719494207572841,nan,nan,11 +AV,base,10,0,0,67.26044119320197,0.0777548087347652,103.9590105209902,71.31822146589691,72.46120310312095,11 +AV,cand,10,0,0,67.26044119320197,0.0777548087347652,103.9590105209902,71.31822146589691,72.46120310312095,11 +GMM,base,10,0,0,66.91851164043523,0.7903851016557623,2.280342295497328,69.84995613981911,74.24075747488983,19 +GMM,cand,10,0,0,66.91851164043523,0.7903851016557623,2.280342295497328,69.84995613981911,74.24075747488983,21 +A,base,11,0,0,66.28059504527904,0.16679685773009906,12.30704256236694,nan,nan,12 +A,cand,11,0,0,66.28059504527904,0.1667968577300993,12.30704256236683,nan,nan,12 +B,base,11,0,0,66.85045527492179,0.2083015112907634,12.923674505237997,nan,nan,13 +B,cand,11,0,0,66.85045527492179,0.2083015112907714,12.92367450523755,nan,nan,15 +D,base,11,0,0,66.29103811306861,0.20427807176207496,8.568066147287249,nan,nan,10 +D,cand,11,0,0,66.29103811306861,0.20427807176207238,8.568066147287144,nan,nan,12 +AV,base,11,0,0,67.23661269746695,0.0620020680623597,109.18600569666019,71.54222482840787,73.75266979425724,11 +AV,cand,11,0,0,67.23661269746695,0.0620020680623597,109.18600569666019,71.54222482840787,73.75266979425724,12 +GMM,base,11,0,0,67.08097616329593,0.7116740723176833,7.070830597538317,70.69366140655724,74.14089086886536,19 +GMM,cand,11,0,0,67.08097616329593,0.7116740723176833,7.070830597538317,70.69366140655724,74.14089086886536,18 +A,base,12,0,0,66.68670120277321,0.222252300144368,5.721677325658799,nan,nan,11 +A,cand,12,0,0,66.68670120277321,0.2222523001443685,5.721677325658795,nan,nan,10 +B,base,12,0,0,66.63665842386479,0.125766729916906,32.92328085537348,nan,nan,12 +B,cand,12,0,0,66.63665842386479,0.125766729916906,32.92328085537355,nan,nan,11 +D,base,12,0,0,66.49368855715962,0.1809755473896387,8.405327373365683,nan,nan,11 +D,cand,12,0,0,66.49368855715962,0.1809755473896362,8.405327373365633,nan,nan,10 +AV,base,12,0,0,67.37656996126654,0.07105136921878308,99.67959734499479,71.64795190939881,73.31925032928967,12 +AV,cand,12,0,0,67.37656996126654,0.07105136921878308,99.67959734499479,71.64795190939881,73.31925032928967,14 +GMM,base,12,0,0,66.61763286570293,0.3087873116326968,7.163912227484872,80.23272341983215,109.48117927614477,22 +GMM,cand,12,0,0,66.61763286570293,0.3087873116326968,7.163912227484872,80.23272341983215,109.48117927614477,24 +A,base,13,0,0,66.9166187683917,0.24599876348625738,5.403721448285163,nan,nan,10 +A,cand,13,0,0,66.9166187683917,0.24599876348625715,5.40372144828517,nan,nan,11 +B,base,13,0,0,66.72578446870484,0.11255068414412003,25.261821299878523,nan,nan,11 +B,cand,13,0,0,66.72578446870484,0.11255068414412224,25.261821299878495,nan,nan,12 +D,base,13,0,0,66.6610816635835,0.21237536919054403,6.954225877043609,nan,nan,10 +D,cand,13,0,0,66.66108166358349,0.2123753691905472,6.954225877043253,nan,nan,10 +AV,base,13,0,0,67.34072303671813,0.0671627827717843,122.91241699373363,73.85866849289484,108.90146202230231,12 +AV,cand,13,0,0,67.34072303671813,0.0671627827717843,122.91241699373363,73.85866849289484,108.90146202230231,11 +GMM,base,13,0,0,65.99281562375259,0.339806758361986,6.353387206637028,75.26506721185055,108.48483594363834,18 +GMM,cand,13,0,0,65.99281562375259,0.339806758361986,6.353387206637028,75.26506721185055,108.48483594363834,18 +A,base,14,0,0,67.00345141733965,0.3568054513653756,2.9649254933397162,nan,nan,11 +A,cand,14,0,0,67.00345141733965,0.356805451365376,2.9649254933397113,nan,nan,11 +B,base,14,0,0,66.7350228484434,0.1352062647723547,20.133136544823106,nan,nan,11 +B,cand,14,0,0,66.73502284844342,0.13520626477235353,20.133136544823408,nan,nan,12 +D,base,14,0,0,66.72415729735596,0.22399131608323164,5.627352382464089,nan,nan,11 +D,cand,14,0,0,66.72415729735592,0.2239913160832481,5.627352382463901,nan,nan,11 +AV,base,14,0,0,67.32853461242482,0.06015544042350692,125.91737780814499,71.71512996399832,73.23430978886253,13 +AV,cand,14,0,0,67.32853461242482,0.06015544042350692,125.91737780814499,71.71512996399832,73.23430978886253,12 +GMM,base,14,0,0,66.64936670804869,0.4596816100329851,7.096775975391228,75.63028044405115,109.59729897302178,44 +GMM,cand,14,0,0,66.64936670804869,0.4596816100329851,7.096775975391228,75.63028044405115,109.59729897302178,45 +A,base,15,0,0,66.888868918257,0.18637426063302892,9.510457957333086,nan,nan,12 +A,cand,15,0,0,66.888868918257,0.18637426063303197,9.51045795733255,nan,nan,12 +B,base,15,0,0,66.78688590312944,0.11637185167541003,38.48991205633628,nan,nan,15 +B,cand,15,0,0,66.78688590312944,0.11637185167541003,38.48991205633611,nan,nan,13 +D,base,15,0,0,66.76636366203363,0.1792945794101401,8.040779245888633,nan,nan,11 +D,cand,15,0,0,66.76636366203356,0.17929457941011906,8.040779245890956,nan,nan,12 +AV,base,15,0,0,67.28447518539672,0.06178197288132863,102.0713264976565,71.29040281004546,72.1790242580802,14 +AV,cand,15,0,0,67.28447518539672,0.06178197288132863,102.0713264976565,71.29040281004546,72.1790242580802,15 +GMM,base,15,0,0,66.24942509112664,0.4048418216239623,4.612497624509603,70.84423479861057,74.21540351296834,44 +GMM,cand,15,0,0,66.24942509112664,0.4048418216239623,4.612497624509603,70.84423479861057,74.21540351296834,40 diff --git a/MonteCarloMarginalizeCode/Code/test/expensive_before_merging/integrators/tier3/noloop_probe.py b/MonteCarloMarginalizeCode/Code/test/expensive_before_merging/integrators/tier3/noloop_probe.py new file mode 100644 index 000000000..78c6285d8 --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/test/expensive_before_merging/integrators/tier3/noloop_probe.py @@ -0,0 +1,28 @@ +"""Run the ILE with the NoLoop likelihood wrapped, to PROVE which path executes.""" +import sys, runpy, atexit +import RIFT.likelihood.factored_likelihood as fl + +counts = {} +def wrap(mod, name): + fn = getattr(mod, name, None) + if fn is None: return + counts[name] = 0 + def w(*a, **k): + counts[name] += 1 + if counts[name] == 1: + print("NOLOOP-PROBE: first call to %s time_interp=%r xpy=%s" + % (name, k.get('time_interp'), getattr(k.get('xpy'), '__name__', '?')), + file=sys.stderr, flush=True) + return fn(*a, **k) + setattr(mod, name, w) + +wrap(fl, 'DiscreteFactoredLogLikelihoodViaArrayVectorNoLoop') +wrap(fl, 'DiscreteFactoredLogLikelihoodViaArrayVectorNoLoopOrig') +wrap(fl, 'FactoredLogLikelihoodTimeMarginalized') # the SCALAR loop path + +@atexit.register +def report(): + print("NOLOOP-PROBE COUNTS: %r" % (counts,), file=sys.stderr, flush=True) + +sys.argv = sys.argv[1:] +runpy.run_path(sys.argv[0], run_name="__main__") diff --git a/MonteCarloMarginalizeCode/Code/test/expensive_before_merging/integrators/tier3/tier0_base.txt b/MonteCarloMarginalizeCode/Code/test/expensive_before_merging/integrators/tier3/tier0_base.txt new file mode 100644 index 000000000..6dd18f9d7 --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/test/expensive_before_merging/integrators/tier3/tier0_base.txt @@ -0,0 +1,540 @@ +# RIFT under test: /home/richard.oshaughnessy/rift_O4d_junior_ralph/.claude/worktrees/base-v2/MonteCarloMarginalizeCode/Code/RIFT +# shape_recovery: 32 runs (8 targets x 4 samplers), preset=quick + - No vegas - +no multiprocess + no cupy (mcsamplerGPU) + no cupy (mcsamplerAV) + no cupy (mcsamplerPortfolio) +RIFT portfolio plugins: [] + Adding parameter x0 with limits [-5.0, 5.0] + Adapting x0 + Adding parameter x1 with limits [-5.0, 5.0] + Adapting x1 +10000 405.5078325517685 14.041581271706718 - -1.2552660987134865 0.029326396883198342 +20010 1508.2830433771187 14.041752629611246 - -1.2555517721906473 0.015363106965444705 +30135 2701.8746801371262 14.041887757834852 - -1.2555517721906473 0.011488785722326344 + [AV mc diag] sigma_mc=0.0115 sigma_lnV=0.0158 trunc_p=1.00e-03 khat=-0.897 ESS=5400.4 + mcsampler: Adding parameter x0 with limits [-5.0, 5.0] + mcsampler: Adding parameter x1 with limits [-5.0, 5.0] + mcsamplerEnsemble: setup() called without n_comp; defaulting n_comp=1 (n_comp=None previously disabled GMM training silently; pass n_comp=0 to disable adaptation) + ==> input assumed as lnL + ==> internal calculations and return values are lnI +cumulative eval time: 0.0016255378723144531 +integrator iterations: 5 +Result 182.97944615464746 95.38897215466109 + Adding parameter x0 with limits [-5.0, 5.0] + Adapting x0 + Adding parameter x1 with limits [-5.0, 5.0] + Adapting x1 + [mc diag] khat=0.381 ESS=8107.9 sigma_block=0.0098 (chunks=5) + Adding parameter x0 with limits [-5.0, 5.0] + Adapting x0 + mcsampler: Adding parameter x0 with limits [-5.0, 5.0] + Adding parameter x1 with limits [-5.0, 5.0] + Adapting x1 + mcsampler: Adding parameter x1 with limits [-5.0, 5.0] + PORTFOLIO setup {} + PORTFOLIO setup {} + mcsamplerEnsemble: setup() called without n_comp; defaulting n_comp=1 (n_comp=None previously disabled GMM training silently; pass n_comp=0 to disable adaptation) + {0: [0.5, 404.39057734209877, 203.50536261809594], 1: [0.5, 420.15038011607965, 213.53775579716645]} + {0: [0.4952815170793957, 1164.499581626134, 579.9782598062495], 1: [0.5047184829206044, 1282.1656052154672, 692.481017893753]} + {0: [0.48579915363587445, 1310.4782710549105, 665.8483784650201], 1: [0.5142008463641256, 1540.8733180932725, 837.138598300517]} + {0: [0.47302192316955693, 1394.9737424855948, 691.7852004640791], 1: [0.5269780768304431, 1658.083005665521, 906.1465273931137]} + {0: [0.4653409818653412, 1433.8822041985184, 722.9093823383027], 1: [0.5346590181346589, 1805.483273660012, 1010.2862759180348]} + PORTFOLIO support: escaped_mass=[0. 0.] early=[0. 0.] (hard-edged members [0]; max 3.879e-04, early max 0.000e+00) weight_share=[0.447 0.553] + Adding parameter x0 with limits [-5.0, 5.0] + Adapting x0 + Adding parameter x1 with limits [-5.0, 5.0] + Adapting x1 +10000 171.45924151547894 14.103563175714072 - -2.3025850929940455 0.04440875770272375 +20035 1341.3990029588442 14.105092593009411 - -2.3517296157781 0.01573439771090637 +30040 2666.80574674158 14.105173959560206 - -2.3517296157781 0.011167215287126598 + [AV mc diag] sigma_mc=0.0112 sigma_lnV=0.0301 trunc_p=1.00e-03 khat=-0.968 ESS=5236.5 + mcsampler: Adding parameter x0 with limits [-5.0, 5.0] + mcsampler: Adding parameter x1 with limits [-5.0, 5.0] + mcsamplerEnsemble: setup() called without n_comp; defaulting n_comp=1 (n_comp=None previously disabled GMM training silently; pass n_comp=0 to disable adaptation) + ==> input assumed as lnL + ==> internal calculations and return values are lnI +cumulative eval time: 0.0037729740142822266 +integrator iterations: 10 +Result 183.19254618659724 95.36542399037812 + Adding parameter x0 with limits [-5.0, 5.0] + Adapting x0 + Adding parameter x1 with limits [-5.0, 5.0] + Adapting x1 + [mc diag] khat=1.408 ESS=12965.1 sigma_block=0.0084 (chunks=10) + Adding parameter x0 with limits [-5.0, 5.0] + Adapting x0 + mcsampler: Adding parameter x0 with limits [-5.0, 5.0] + Adding parameter x1 with limits [-5.0, 5.0] + Adapting x1 + mcsampler: Adding parameter x1 with limits [-5.0, 5.0] + PORTFOLIO setup {} + PORTFOLIO setup {} + mcsamplerEnsemble: setup() called without n_comp; defaulting n_comp=1 (n_comp=None previously disabled GMM training silently; pass n_comp=0 to disable adaptation) + {0: [0.5, 177.5448987678759, 89.55207612788095], 1: [0.5, 165.17650151336184, 84.84760653916595]} + {0: [0.5089397043305469, 1162.8956903739847, 600.9915713603721], 1: [0.49106029566945303, 921.6851992333078, 495.37765253249154]} + {0: [0.5329711561745135, 1578.6934963336735, 820.7567435179499], 1: [0.4670288438254865, 1077.8192143794015, 583.6836429823455]} + {0: [0.5628714623240679, 1730.2738782744752, 924.6182159088588], 1: [0.437128537675932, 1104.8907639589308, 604.2954201857742]} + {0: [0.585639839064502, 1920.15910667202, 1002.7763089856462], 1: [0.41436016093549805, 1038.771847543745, 566.511993263526]} + {0: [0.6160134203668732, 2076.440135601993, 1066.0239237213232], 1: [0.3839865796331269, 1069.6298499585373, 593.0061893951683]} + {0: [0.6365795373505092, 2175.9748953564144, 1139.5247998702187], 1: [0.36342046264949085, 1068.228521033336, 596.2946025259992]} + {0: [0.6520913189732968, 2273.0143296400825, 1193.180983138384], 1: [0.34790868102670325, 995.8856719594488, 554.4918898172995]} + {0: [0.6719410866321384, 2401.221817596801, 1242.4273542170629], 1: [0.3280589133678616, 1004.0328828012109, 558.6775171382616]} + {0: [0.6866471755804096, 2502.489572987164, 1301.0892388335583], 1: [0.3133528244195905, 1014.886823891199, 577.1080716255299]} + PORTFOLIO support: escaped_mass=[0.001 0. ] early=[0. 0.] (hard-edged members [0]; max 5.204e-04, early max 0.000e+00) weight_share=[0.625 0.375] + Adding parameter x0 with limits [-5.0, 5.0] + Adapting x0 + Adding parameter x1 with limits [-5.0, 5.0] + Adapting x1 +10000 31.819447074032077 14.207972098313473 - -2.3025850929940455 0.08835771400786861 +20036 244.2117034656364 14.210078425715949 - -2.7220031005580765 0.031917797533072424 +30130 639.375415982063 14.21021726755302 - -2.722263845420707 0.02042000613224233 +40247 1067.9896890154914 14.210218357471094 - -2.722263845420707 0.015949291411617203 +50291 1499.5155333867363 14.210218357471094 - -2.722263845420707 0.013501237259551241 +60396 1942.3478273356657 14.210218357471094 - -2.722263845420707 0.01185664796374099 +70476 2380.662791690032 14.210249149610629 - -2.722263845420707 0.010669749554112522 + [AV mc diag] sigma_mc=0.0107 sigma_lnV=0.0312 trunc_p=1.00e-03 khat=-0.897 ESS=7257.3 + mcsampler: Adding parameter x0 with limits [-5.0, 5.0] + mcsampler: Adding parameter x1 with limits [-5.0, 5.0] + mcsamplerEnsemble: setup() called without n_comp; defaulting n_comp=1 (n_comp=None previously disabled GMM training silently; pass n_comp=0 to disable adaptation) + ==> input assumed as lnL + ==> internal calculations and return values are lnI +cumulative eval time: 0.008828401565551758 +integrator iterations: 10 +Result 184.19700807750687 95.36396115277081 + Adding parameter x0 with limits [-5.0, 5.0] + Adapting x0 + Adding parameter x1 with limits [-5.0, 5.0] + Adapting x1 + [mc diag] khat=0.13 ESS=4371.8 sigma_block=0.0126 (chunks=10) + Adding parameter x0 with limits [-5.0, 5.0] + Adapting x0 + mcsampler: Adding parameter x0 with limits [-5.0, 5.0] + Adding parameter x1 with limits [-5.0, 5.0] + Adapting x1 + mcsampler: Adding parameter x1 with limits [-5.0, 5.0] + PORTFOLIO setup {} + PORTFOLIO setup {} + mcsamplerEnsemble: setup() called without n_comp; defaulting n_comp=1 (n_comp=None previously disabled GMM training silently; pass n_comp=0 to disable adaptation) + {0: [0.5, 51.9723259421829, 14.748448534784451], 1: [0.5, 55.427432218152695, 20.998614742918825]} + {0: [0.49192707463236135, 371.8314195271132, 119.36376127158661], 1: [0.5080729253676386, 332.97792324373694, 121.83103241098968]} + {0: [0.5095981199902603, 715.2987679579478, 243.24537930735127], 1: [0.49040188000973983, 376.99580634900184, 141.86204388517163]} + {0: [0.5811888596830933, 870.7309062246638, 289.44903804658264], 1: [0.4188111403169068, 385.0667030585916, 136.92404015702158]} + {0: [0.635785751111682, 982.0434860206842, 334.3832059784413], 1: [0.36421424888831794, 332.55502092514337, 118.10210511881448]} + {0: [0.6894115938771643, 1126.5011675775108, 367.30881046158436], 1: [0.31058840612283584, 309.75672733452075, 115.24733726537433]} + {0: [0.734473389032334, 1176.3301954909668, 397.90936568218194], 1: [0.26552661096766605, 295.206057259213, 103.69043501081441]} + {0: [0.764314471818733, 1306.9061103706952, 411.8514599317506], 1: [0.23568552818126703, 233.86552042573294, 93.27103159271704]} + {0: [0.8032316949739701, 1375.211639958176, 451.13782989615083], 1: [0.1967683050260299, 229.99107527807186, 87.11315971680541]} + {0: [0.826779361924609, 1447.7486317707805, 470.7680759023719], 1: [0.17322063807539112, 212.722387752275, 72.63656901282289]} + PORTFOLIO support: escaped_mass=[0. 0.] early=[0. 0.] (hard-edged members [0]; max 4.775e-04, early max 0.000e+00) weight_share=[0.705 0.295] + Adding parameter x0 with limits [-5.0, 5.0] + Adapting x0 + Adding parameter x1 with limits [-5.0, 5.0] + Adapting x1 +10000 282.90228061878827 14.069983704468804 - -1.606941032235513 0.03391857697232854 +20005 1162.8213455983655 14.069983704468804 - -1.606941032235513 0.01645787071884805 +30113 2127.8021769238253 14.069983704468804 - -1.606941032235513 0.012202802407077518 + [AV mc diag] sigma_mc=0.0122 sigma_lnV=0.0200 trunc_p=1.00e-03 khat=-0.714 ESS=4692.2 + mcsampler: Adding parameter x0 with limits [-5.0, 5.0] + mcsampler: Adding parameter x1 with limits [-5.0, 5.0] + mcsamplerEnsemble: setup() called without n_comp; defaulting n_comp=1 (n_comp=None previously disabled GMM training silently; pass n_comp=0 to disable adaptation) + ==> input assumed as lnL + ==> internal calculations and return values are lnI +cumulative eval time: 0.0060503482818603516 +integrator iterations: 8 +Result 183.48538908288538 95.39121872772748 + Adding parameter x0 with limits [-5.0, 5.0] + Adapting x0 + Adding parameter x1 with limits [-5.0, 5.0] + Adapting x1 + [mc diag] khat=-0.17 ESS=9268.4 sigma_block=0.0067 (chunks=8) + Adding parameter x0 with limits [-5.0, 5.0] + Adapting x0 + mcsampler: Adding parameter x0 with limits [-5.0, 5.0] + Adding parameter x1 with limits [-5.0, 5.0] + Adapting x1 + mcsampler: Adding parameter x1 with limits [-5.0, 5.0] + PORTFOLIO setup {} + PORTFOLIO setup {} + mcsamplerEnsemble: setup() called without n_comp; defaulting n_comp=1 (n_comp=None previously disabled GMM training silently; pass n_comp=0 to disable adaptation) + {0: [0.5, 296.2610253951537, 139.04537062577708], 1: [0.5, 279.600495815957, 127.87095339115054]} + {0: [0.5071497497009275, 990.3307872048276, 414.86868536147415], 1: [0.4928502502990726, 720.909910850529, 301.34537138166445]} + {0: [0.5423754473951683, 1295.2587020551941, 554.9477043414095], 1: [0.45762455260483176, 777.0551196109792, 331.57444545226883]} + {0: [0.5827238366336385, 1451.8550023157277, 610.4940117862412], 1: [0.41727616336636153, 738.113878870234, 328.4252229978778]} + {0: [0.6214918632533081, 1629.7615647227137, 707.6480668029269], 1: [0.3785081367466921, 682.9422643562978, 308.0735997154563]} + {0: [0.6613531910827484, 1800.7728976142726, 778.6250560470654], 1: [0.33864680891725163, 618.3285033975036, 270.8751999372255]} + {0: [0.7007496821554526, 1940.6756979943145, 836.4344283729793], 1: [0.2992503178445475, 563.1623845668614, 251.22976019545018]} + {0: [0.7354711106840421, 2057.0426762093707, 908.114177346755], 1: [0.2645288893159578, 487.33474807656927, 215.93423013433576]} + PORTFOLIO support: escaped_mass=[0. 0.] early=[0. 0.] (hard-edged members [0]; max 3.778e-04, early max 0.000e+00) weight_share=[0.677 0.323] + Adding parameter x0 with limits [-5.0, 5.0] + Adapting x0 + Adding parameter x1 with limits [-5.0, 5.0] + Adapting x1 + Adding parameter x2 with limits [-5.0, 5.0] + Adapting x2 + Adding parameter x3 with limits [-5.0, 5.0] + Adapting x3 +10000 2.1166036998516646 14.10878870087279 - -2.3025850929940455 0.5587415207624211 +20132 3.2645059053429466 14.10878870087279 - -3.641001373869472 0.3851235949100838 +30252 9.328002403246321 14.141353307335391 - -4.6731176712492175 0.215323815340953 +40431 21.37504601984334 14.14956001586711 - -5.572872434483273 0.1361427727525657 +50631 34.358388363239264 14.167645117262165 - -6.247210283085722 0.09054500478058455 +60807 67.86369255033544 14.173680089014397 - -6.247653447285308 0.06486358236993159 +70835 99.32094617660934 14.173680089014397 - -6.248222923382703 0.051639105049905605 +81038 119.57911197277987 14.180351956366422 - -6.248222923382703 0.04458732795102924 +91510 155.0990039198797 14.180351956366422 - -6.248222923382703 0.039398087349960906 +101875 190.09241163855137 14.180351956366422 - -6.248222923382703 0.036047136109075416 +111963 225.76520348463504 14.180351956366422 - -6.248222923382703 0.03324651341128292 +122623 259.27198929944046 14.180351956366422 - -6.248222923382703 0.03091841392320598 +132991 279.9762144098173 14.183723594101782 - -6.248222923382703 0.029029142917085856 +143001 310.3450237022169 14.183723594101782 - -6.248222923382703 0.027424545989686508 +153704 347.8415391039465 14.183723594101782 - -6.248222923382703 0.02609891814222508 +164114 379.72463439571266 14.183723594101782 - -6.248222923382703 0.025020895626516593 +174904 416.8710093163701 14.183723594101782 - -6.248222923382703 0.023964207037525137 +185083 455.7068490238235 14.183752169903658 - -6.248222923382703 0.02318405380550132 +195478 489.05074998486566 14.183752169903658 - -6.248222923382703 0.022258981314476334 +206296 523.5732322490855 14.183752169903658 - -6.248222923382703 0.02149846880610427 + [AV mc diag] sigma_mc=0.0215 sigma_lnV=0.0575 trunc_p=1.00e-03 khat=-0.166 ESS=1965.4 + mcsampler: Adding parameter x0 with limits [-5.0, 5.0] + mcsampler: Adding parameter x1 with limits [-5.0, 5.0] + mcsampler: Adding parameter x2 with limits [-5.0, 5.0] + mcsampler: Adding parameter x3 with limits [-5.0, 5.0] + mcsamplerEnsemble: setup() called without n_comp; defaulting n_comp=1 (n_comp=None previously disabled GMM training silently; pass n_comp=0 to disable adaptation) + ==> input assumed as lnL + ==> internal calculations and return values are lnI +cumulative eval time: 0.010064363479614258 +integrator iterations: 20 +Result 178.02185556688133 90.81258801139508 + Adding parameter x0 with limits [-5.0, 5.0] + Adapting x0 + Adding parameter x1 with limits [-5.0, 5.0] + Adapting x1 + Adding parameter x2 with limits [-5.0, 5.0] + Adapting x2 + Adding parameter x3 with limits [-5.0, 5.0] + Adapting x3 + [mc diag] khat=0.457 ESS=661.3 sigma_block=0.0202 (chunks=20) + Adding parameter x0 with limits [-5.0, 5.0] + Adapting x0 + mcsampler: Adding parameter x0 with limits [-5.0, 5.0] + Adding parameter x1 with limits [-5.0, 5.0] + Adapting x1 + mcsampler: Adding parameter x1 with limits [-5.0, 5.0] + Adding parameter x2 with limits [-5.0, 5.0] + Adapting x2 + mcsampler: Adding parameter x2 with limits [-5.0, 5.0] + Adding parameter x3 with limits [-5.0, 5.0] + Adapting x3 + mcsampler: Adding parameter x3 with limits [-5.0, 5.0] + PORTFOLIO setup {} + PORTFOLIO setup {} + mcsamplerEnsemble: setup() called without n_comp; defaulting n_comp=1 (n_comp=None previously disabled GMM training silently; pass n_comp=0 to disable adaptation) + {0: [0.5, 1.248295518238638, 1.1254776568671085], 1: [0.5, 2.1816844448208124, 1.5365904502837253]} + {0: [0.339253386004392, 2.0863383845343795, 1.5940715258701235], 1: [0.6607466139956081, 53.58090482074, 23.57512119631764]} + {0: [0.18372789928558023, 5.409084759820267, 3.393640955341682], 1: [0.8162721007144199, 106.13664576295155, 41.15145548144278]} + {0: [0.11620607778951314, 7.9952264673513325, 4.121404135389044], 1: [0.8837939222104869, 132.15944204805925, 48.06684671180827]} + {0: [0.08772788187470719, 25.986585268088895, 10.097772503158266], 1: [0.9122721181252927, 157.89875854390195, 60.52029593346723]} + {0: [0.11628338350900845, 96.69020360256873, 33.65520682335699], 1: [0.8837166164909915, 152.99977689446433, 57.91204287960293]} + {0: [0.2531097596259792, 258.67905895203916, 78.5049828623709], 1: [0.746890240374021, 142.6312475202534, 57.55971235173839]} + {0: [0.4487397859401182, 550.1873432839869, 162.9874371422758], 1: [0.5512602140598818, 108.65845435533113, 41.44042464297316]} + {0: [0.6400381261044226, 944.9198364331634, 276.2342100560345], 1: [0.35996187389557754, 72.77266782391348, 28.761735878796756]} + {0: [0.7811348197272289, 1364.5842051535617, 424.79237492779964], 1: [0.21886518027277116, 45.71722647821901, 14.473663633891162]} + {0: [0.8704973878784255, 1723.631603472961, 537.8398990938981], 1: [0.12950261212157457, 28.757678791607454, 13.831076965402488]} + {0: [0.9227850570833107, 1922.3878904753471, 595.382446897122], 1: [0.07721494291668916, 19.51980423046243, 10.067061280011234]} + {0: [0.9519073529646879, 2217.9695230124858, 682.448378719267], 1: [0.0480926470353122, 16.85241305820879, 10.206703360945713]} + {0: [0.9676013079876755, 2349.837363477613, 756.8019080517107], 1: [0.03239869201232444, 10.954550876231199, 6.132793846440526]} + {0: [0.9768275221594512, 2573.0829541708786, 859.785502221698], 1: [0.02317247784054883, 14.695371082180486, 7.748935742530275]} + {0: [0.9808875946237463, 2678.765514456453, 840.6982380549798], 1: [0.01911240537625367, 10.730006616457821, 6.035851286889501]} + {0: [0.9837329978845024, 2653.714501548123, 860.0494712169331], 1: [0.016267002115497666, 7.101239161492894, 3.9972697322682547]} + {0: [0.9858016042408616, 2891.55528919115, 940.1978391861014], 1: [0.01419839575913841, 7.277040664870868, 4.805576804570387]} + {0: [0.9868937359358537, 3006.085597972147, 1007.846937353592], 1: [0.013106264064146353, 4.857418974428798, 3.1355698104903915]} + {0: [0.9878729209263435, 2923.7569929902147, 954.8498635073959], 1: [0.012127079073656427, 4.96209827142928, 2.7734658774577428]} + PORTFOLIO support: escaped_mass=[0.732 0. ] early=[0. 0.] (hard-edged members [0]; max 7.321e-01, early max 0.000e+00) weight_share=[0.115 0.885] + Adding parameter x0 with limits [-5.0, 5.0] + Adapting x0 + Adding parameter x1 with limits [-5.0, 5.0] + Adapting x1 + Adding parameter x2 with limits [-5.0, 5.0] + Adapting x2 + Adding parameter x3 with limits [-5.0, 5.0] + Adapting x3 +10000 2.8178328241298303 14.106811134643898 - -2.3025850929940455 0.418250025586683 +20044 6.636898307870252 14.106811134643898 - -3.6415257577304656 0.2694494815146293 +30244 18.569424515958797 14.106811134643898 - -4.782558762282528 0.1564679285770689 +40272 31.39390947753484 14.133217768998176 - -5.8575611853115035 0.0950400613972893 +50400 90.75153016851868 14.135386775261454 - -5.874033760938849 0.05430644632152597 +60452 148.50633845870107 14.135881649490756 - -5.874033760938849 0.04278003355929685 +70704 212.60838557132809 14.135881649490756 - -5.874033760938849 0.035828163495813414 +81059 276.7108388335327 14.135881649490756 - -5.874033760938849 0.03122557243214973 +91412 340.4338785177663 14.135881649490756 - -5.874033760938849 0.028282600823514373 +101642 364.0198165794389 14.142809716495856 - -5.874033760938849 0.02601678783062935 +111890 424.8914651771721 14.142809716495856 - -5.874033760938849 0.024176556816571906 +122056 484.99681967482445 14.142809716495856 - -5.874033760938849 0.0226003522331087 +132846 539.2465888770378 14.14352107566793 - -5.874033760938849 0.02131653037709023 +143490 597.0895719339081 14.14352107566793 - -5.874033760938849 0.020273272673902575 +154386 666.0059896139865 14.14352107566793 - -5.874033760938849 0.019326677716182527 +164781 727.8723848155978 14.14352107566793 - -5.874033760938849 0.018476723369639025 +175748 793.8863446881805 14.14352107566793 - -5.874033760938849 0.01772267842643432 +186008 851.2112624839925 14.14352107566793 - -5.874033760938849 0.017076297877889834 +196628 911.9729823975033 14.14352107566793 - -5.874033760938849 0.016499163685447564 +207568 974.5351768847711 14.14352107566793 - -5.874033760938849 0.015937709768956476 + [AV mc diag] sigma_mc=0.0159 sigma_lnV=0.0546 trunc_p=1.00e-03 khat=-0.28 ESS=3553.0 + mcsampler: Adding parameter x0 with limits [-5.0, 5.0] + mcsampler: Adding parameter x1 with limits [-5.0, 5.0] + mcsampler: Adding parameter x2 with limits [-5.0, 5.0] + mcsampler: Adding parameter x3 with limits [-5.0, 5.0] + mcsamplerEnsemble: setup() called without n_comp; defaulting n_comp=1 (n_comp=None previously disabled GMM training silently; pass n_comp=0 to disable adaptation) + ==> input assumed as lnL + ==> internal calculations and return values are lnI +cumulative eval time: 0.0076084136962890625 +integrator iterations: 20 +Result 177.97393602102287 90.83017502558896 + Adding parameter x0 with limits [-5.0, 5.0] + Adapting x0 + Adding parameter x1 with limits [-5.0, 5.0] + Adapting x1 + Adding parameter x2 with limits [-5.0, 5.0] + Adapting x2 + Adding parameter x3 with limits [-5.0, 5.0] + Adapting x3 + [mc diag] khat=0.297 ESS=1007.4 sigma_block=0.0436 (chunks=20) + Adding parameter x0 with limits [-5.0, 5.0] + Adapting x0 + mcsampler: Adding parameter x0 with limits [-5.0, 5.0] + Adding parameter x1 with limits [-5.0, 5.0] + Adapting x1 + mcsampler: Adding parameter x1 with limits [-5.0, 5.0] + Adding parameter x2 with limits [-5.0, 5.0] + Adapting x2 + mcsampler: Adding parameter x2 with limits [-5.0, 5.0] + Adding parameter x3 with limits [-5.0, 5.0] + Adapting x3 + mcsampler: Adding parameter x3 with limits [-5.0, 5.0] + PORTFOLIO setup {} + PORTFOLIO setup {} + mcsamplerEnsemble: setup() called without n_comp; defaulting n_comp=1 (n_comp=None previously disabled GMM training silently; pass n_comp=0 to disable adaptation) + {0: [0.5, 3.2229506800456402, 1.9355493777516504], 1: [0.5, 4.929045834021777, 3.2827185845351945]} + {0: [0.431703836171015, 5.724915900075073, 3.1299777750167848], 1: [0.5682961638289852, 48.72026864358284, 19.710710626418305]} + {0: [0.2641270524992567, 15.203843836619749, 6.616319734180189], 1: [0.7358729475007432, 94.52715481924848, 36.61256976793132]} + {0: [0.2013204303019922, 46.27506611028839, 20.11193104504678], 1: [0.7986795696980078, 121.41226001309323, 36.76455191202447]} + {0: [0.23972334865111125, 172.43762301138156, 52.32666705208731], 1: [0.7602766513488888, 126.99272259967736, 27.916166559691472]} + {0: [0.4081369455614514, 429.1114975766229, 130.42773810903947], 1: [0.5918630544385485, 85.72695630460859, 27.240108860434518]} + {0: [0.6191926759012649, 738.5060624995527, 218.11132567553324], 1: [0.3808073240987352, 35.6840682816515, 11.28663701806526]} + {0: [0.7834454494391462, 1030.499638541244, 280.2195711612294], 1: [0.21655455056085382, 23.824908985569103, 9.017061012829894]} + {0: [0.8766031638161738, 1240.2597705755363, 350.76594557234114], 1: [0.12339683618382631, 13.46033990898554, 4.985243123910596]} + {0: [0.928730415919203, 1492.2969487529986, 438.2176649450233], 1: [0.07126958408079706, 21.433633543062328, 11.357468007228611]} + {0: [0.9529098839255886, 1628.8652135545544, 478.48110676722155], 1: [0.04709011607441142, 9.284164039696401, 5.735858740927055]} + {0: [0.9691031384852762, 1660.7101436048717, 458.0950568820307], 1: [0.030896861514723702, 10.188097385996098, 5.418576348366525]} + {0: [0.9769416450072074, 2048.1855767286247, 589.4461131483129], 1: [0.023058354992792712, 5.759946405628074, 3.2041914110916707]} + {0: [0.9824105067997392, 1967.740894952566, 577.3534414829284], 1: [0.017589493200260764, 4.000175011007909, 2.5852406530441283]} + {0: [0.9855236847978116, 1997.8324407837415, 613.7267064524941], 1: [0.01447631520218847, 5.074231196920942, 2.666087763281335]} + {0: [0.9868198279621364, 2095.0999993465853, 609.9328191157805], 1: [0.013180172037863532, 4.366919336088007, 2.8906413813650795]} + {0: [0.9876769398385246, 2080.9025960127187, 596.9447376851235], 1: [0.012323060161475562, 5.0751937735607, 3.4704717473540763]} + {0: [0.9879308490632657, 2203.0532366966154, 644.3245156773512], 1: [0.012069150936734456, 6.412134299736653, 3.833441151409804]} + {0: [0.9878127487811384, 2274.5530690720357, 679.1439191091749], 1: [0.012187251218861479, 3.430703581190969, 2.0941101816490812]} + {0: [0.9884355468971764, 2543.8823905320114, 745.8699702669833], 1: [0.011564453102823693, 7.44295995122474, 4.066419772321259]} + PORTFOLIO support: escaped_mass=[0.389 0. ] early=[0. 0.] (hard-edged members [0]; max 3.888e-01, early max 0.000e+00) weight_share=[0.428 0.572] + Adding parameter x0 with limits [-5.0, 5.0] + Adapting x0 + Adding parameter x1 with limits [-5.0, 5.0] + Adapting x1 + Adding parameter x2 with limits [-5.0, 5.0] + Adapting x2 + Adding parameter x3 with limits [-5.0, 5.0] + Adapting x3 +10000 4.654321405166974 13.988356785322358 - -2.3025850929940455 0.34580594000190723 +20050 9.258681889522418 14.037186958353475 - -3.5579161895951237 0.18493313267509034 +30167 7.66463844118878 14.151979705175876 - -4.7344150169203045 0.18035327572325632 +40310 20.449456391967576 14.169994578519773 - -4.9101931925237325 0.10289436933352308 +50550 28.586966557435268 14.189128986741126 - -4.910614866575943 0.07673452474672303 +60570 38.93282909432903 14.196954453698215 - -4.910614866575943 0.06467603212059274 +70674 50.911106019982725 14.196954453698215 - -4.910614866575943 0.05818808259943886 +81102 64.59066486677915 14.196954453698215 - -4.910614866575943 0.05187536186667367 +91462 77.57399075182056 14.196954453698215 - -4.910614866575943 0.047908500713776364 +101614 90.03903884897062 14.196954453698215 - -4.910614866575943 0.044319853366687555 +111678 100.53867176841253 14.196954453698215 - -4.910614866575943 0.04125462270603144 +122062 112.50028777844122 14.197980189533084 - -4.910614866575943 0.03945010405629966 +132187 124.37075724935244 14.197980189533084 - -4.910614866575943 0.036660348691862805 +142672 137.87634574629726 14.197980189533084 - -4.910614866575943 0.034707721339066154 +153046 151.50287371352752 14.197980189533084 - -4.910614866575943 0.03295379633099876 +163672 155.88676272372882 14.202439862883038 - -4.910614866575943 0.03168707773711685 +173864 156.57154018585163 14.207692401654008 - -4.910614866575943 0.030585608998772416 +184459 169.9892470184039 14.207692401654008 - -4.910614866575943 0.029467700415981464 +194503 180.83155211926666 14.207692401654008 - -4.910614866575943 0.028051270045501217 +204955 193.37144359736385 14.207692401654008 - -4.910614866575943 0.02736405702203732 + [AV mc diag] sigma_mc=0.0274 sigma_lnV=0.0487 trunc_p=1.00e-03 khat=0.781 ESS=1293.4 + mcsampler: Adding parameter x0 with limits [-5.0, 5.0] + mcsampler: Adding parameter x1 with limits [-5.0, 5.0] + mcsampler: Adding parameter x2 with limits [-5.0, 5.0] + mcsampler: Adding parameter x3 with limits [-5.0, 5.0] + mcsamplerEnsemble: setup() called without n_comp; defaulting n_comp=1 (n_comp=None previously disabled GMM training silently; pass n_comp=0 to disable adaptation) + ==> input assumed as lnL + ==> internal calculations and return values are lnI +cumulative eval time: 0.020084857940673828 +integrator iterations: 20 +Result 178.4055563189518 90.70626477991523 + Adding parameter x0 with limits [-5.0, 5.0] + Adapting x0 + Adding parameter x1 with limits [-5.0, 5.0] + Adapting x1 + Adding parameter x2 with limits [-5.0, 5.0] + Adapting x2 + Adding parameter x3 with limits [-5.0, 5.0] + Adapting x3 + [mc diag] khat=0.784 ESS=397.3 sigma_block=0.0605 (chunks=20) + Adding parameter x0 with limits [-5.0, 5.0] + Adapting x0 + mcsampler: Adding parameter x0 with limits [-5.0, 5.0] + Adding parameter x1 with limits [-5.0, 5.0] + Adapting x1 + mcsampler: Adding parameter x1 with limits [-5.0, 5.0] + Adding parameter x2 with limits [-5.0, 5.0] + Adapting x2 + mcsampler: Adding parameter x2 with limits [-5.0, 5.0] + Adding parameter x3 with limits [-5.0, 5.0] + Adapting x3 + mcsampler: Adding parameter x3 with limits [-5.0, 5.0] + PORTFOLIO setup {} + PORTFOLIO setup {} + mcsamplerEnsemble: setup() called without n_comp; defaulting n_comp=1 (n_comp=None previously disabled GMM training silently; pass n_comp=0 to disable adaptation) + {0: [0.5, 5.3232353535111985, 2.8318706422294753], 1: [0.5, 7.981691692974874, 4.317471741880317]} + {0: [0.4420876871680066, 10.931135166205015, 4.879967388702239], 1: [0.5579123128319935, 13.240803085435248, 3.800406294214491]} + {0: [0.4455339080316621, 42.18070089658118, 13.437826808567078], 1: [0.554466091968338, 11.153366133578782, 4.872062681137628]} + {0: [0.6217521335541101, 78.60114305246026, 15.74936504846441], 1: [0.37824786644588987, 6.091055331206363, 2.546657942302334]} + {0: [0.7765181417778043, 137.50740020760304, 27.272441187136355], 1: [0.2234818582221957, 30.000603602778448, 6.462846998834879]} + {0: [0.7975366169598972, 137.72140528730847, 27.343655795578087], 1: [0.20246338304010286, 27.55189607980161, 8.491320160636752]} + {0: [0.8141992244922207, 179.5400624989656, 30.024113623611782], 1: [0.18580077550777924, 4.389715517884242, 2.5334914766178187]} + {0: [0.893409723915284, 338.02315597536176, 30.05675614163942], 1: [0.10659027608471605, 20.36151057651219, 9.718564888329606]} + {0: [0.9152365353843483, 401.962475369205, 52.268678461794096], 1: [0.08476346461565173, 17.077288575492698, 6.477143282402219]} + {0: [0.9338662051551552, 340.63599126039986, 41.959998209699286], 1: [0.06613379484484477, 24.627452931685536, 10.028928861794949]} + {0: [0.9300867805715395, 317.5759792908852, 45.79134095823356], 1: [0.0699132194284606, 12.715513505250966, 5.4368075590056755]} + {0: [0.9426653378501819, 459.97269189264847, 44.3481510420348], 1: [0.057334662149817944, 3.6926355229120817, 1.962575304661153]} + {0: [0.9636274735422092, 659.8359866135735, 41.87138188250769], 1: [0.03637252645779068, 19.184071103293938, 9.150233895407297]} + {0: [0.9637000393442082, 787.9528114781957, 55.51651334508068], 1: [0.03629996065579181, 17.437972175202052, 8.44415727677497]} + {0: [0.9668875144244687, 1049.4604681584365, 59.07188266331073], 1: [0.03311248557553122, 15.85234529919747, 6.603667098423485]} + {0: [0.9716712447495561, 883.9505135912624, 49.849359825317], 1: [0.028328755250443798, 17.666173694896628, 7.517762959678945]} + {0: [0.9718062897349808, 843.3299515244514, 68.53034907604707], 1: [0.02819371026501918, 33.61279718661753, 17.17612017335167]} + {0: [0.9626392162882289, 797.6482639567147, 62.75511900935909], 1: [0.03736078371177119, 30.429226581281146, 13.69747787725144]} + {0: [0.9588906504722623, 1131.37125177585, 65.94339287016473], 1: [0.04110934952773771, 36.99935527334983, 17.289483896285308]} + {0: [0.9593705831549305, 1380.9077486788628, 102.6535442621201], 1: [0.04062941684506938, 44.223303406917054, 20.903703043292587]} + PORTFOLIO support: escaped_mass=[0.237 0. ] early=[0. 0.] (hard-edged members [0]; max 2.368e-01, early max 0.000e+00) weight_share=[0.577 0.423] + Adding parameter x0 with limits [-5.0, 5.0] + Adapting x0 + Adding parameter x1 with limits [-5.0, 5.0] + Adapting x1 + Adding parameter x2 with limits [-5.0, 5.0] + Adapting x2 + Adding parameter x3 with limits [-5.0, 5.0] + Adapting x3 +10000 4.152611788172848 14.065534562729912 - -2.3025850929940455 0.324217562024852 +20074 10.447034945162336 14.065534562729912 - -3.5403794457954922 0.1897914057866351 +30139 31.236757414389245 14.072191660606862 - -4.611648258790916 0.10004443754782794 +40159 84.65132754220016 14.072191660606862 - -4.612343669111014 0.05694874392687903 +50419 112.89993517566796 14.084321798665568 - -4.612552415589175 0.044389586556079046 +60517 153.48659901479346 14.084321798665568 - -4.612856505602161 0.037733608694803245 +70573 199.8494973376436 14.084321798665568 - -4.612856505602161 0.03267153838280504 +80649 240.51857745523438 14.086245092413563 - -4.612856505602161 0.02977148571448821 +90876 285.73443800293717 14.086245092413563 - -4.612856505602161 0.027069882498919628 +100946 321.3530180388238 14.088182096916443 - -4.612856505602161 0.02517584391507103 +111278 369.6931999026329 14.088182096916443 - -4.612856505602161 0.023481141947943117 +121733 424.6839113982268 14.088182096916443 - -4.612856505602161 0.022246804145413357 +131877 472.24977554020944 14.088182096916443 - -4.612856505602161 0.02100201295018492 +142373 522.4622878245692 14.088468146421286 - -4.612856505602161 0.019946775598064726 +152648 573.8509752023142 14.088468146421286 - -4.612856505602161 0.01893429222749329 +163193 627.3271574368105 14.088468146421286 - -4.612856505602161 0.018155090422616125 +173539 674.2436885584109 14.088468146421286 - -4.612856505602161 0.017491889893707497 +184263 723.1379554072879 14.088468146421286 - -4.612856505602161 0.016869603453262792 +194987 776.6235611299687 14.088468146421286 - -4.612856505602161 0.016221635517802972 +205452 831.0850386974821 14.088468146421286 - -4.612856505602161 0.015699312719243415 + [AV mc diag] sigma_mc=0.0157 sigma_lnV=0.0476 trunc_p=1.00e-03 khat=-0.114 ESS=3679.2 + mcsampler: Adding parameter x0 with limits [-5.0, 5.0] + mcsampler: Adding parameter x1 with limits [-5.0, 5.0] + mcsampler: Adding parameter x2 with limits [-5.0, 5.0] + mcsampler: Adding parameter x3 with limits [-5.0, 5.0] + mcsamplerEnsemble: setup() called without n_comp; defaulting n_comp=1 (n_comp=None previously disabled GMM training silently; pass n_comp=0 to disable adaptation) + ==> input assumed as lnL + ==> internal calculations and return values are lnI +cumulative eval time: 0.020583629608154297 +integrator iterations: 20 +Result 177.55758789352998 90.80067203364804 + Adding parameter x0 with limits [-5.0, 5.0] + Adapting x0 + Adding parameter x1 with limits [-5.0, 5.0] + Adapting x1 + Adding parameter x2 with limits [-5.0, 5.0] + Adapting x2 + Adding parameter x3 with limits [-5.0, 5.0] + Adapting x3 + [mc diag] khat=0.376 ESS=1248.0 sigma_block=0.0198 (chunks=20) + Adding parameter x0 with limits [-5.0, 5.0] + Adapting x0 + mcsampler: Adding parameter x0 with limits [-5.0, 5.0] + Adding parameter x1 with limits [-5.0, 5.0] + Adapting x1 + mcsampler: Adding parameter x1 with limits [-5.0, 5.0] + Adding parameter x2 with limits [-5.0, 5.0] + Adapting x2 + mcsampler: Adding parameter x2 with limits [-5.0, 5.0] + Adding parameter x3 with limits [-5.0, 5.0] + Adapting x3 + mcsampler: Adding parameter x3 with limits [-5.0, 5.0] + PORTFOLIO setup {} + PORTFOLIO setup {} + mcsamplerEnsemble: setup() called without n_comp; defaulting n_comp=1 (n_comp=None previously disabled GMM training silently; pass n_comp=0 to disable adaptation) + {0: [0.5, 6.614685526123144, 3.2104056934069845], 1: [0.5, 4.3605001410842235, 2.8372317126992663]} + {0: [0.5618522253853325, 20.491602004190412, 6.408057773342241], 1: [0.4381477746146675, 46.91531304958152, 16.646305913041175]} + {0: [0.43128228436914007, 49.822878642459045, 18.157017095989794], 1: [0.5687177156308599, 73.73081037414637, 21.238735093054743]} + {0: [0.4173744384588678, 176.14375047750522, 41.565777689052396], 1: [0.5826255615411323, 54.10426018917899, 11.416006484834433]} + {0: [0.5905675124495153, 440.0785573115516, 102.42180553967029], 1: [0.40943248755048467, 69.7378838673712, 24.722971785568703]} + {0: [0.7246574149845203, 681.3868820506339, 169.155304345117], 1: [0.27534258501547976, 27.50836748965617, 8.831514786231121]} + {0: [0.8395685080597443, 896.6674838927986, 193.8014469999679], 1: [0.1604314919402558, 19.67915894650428, 6.790913263219432]} + {0: [0.9051461836058894, 1086.333959081652, 265.61433912282985], 1: [0.09485381639411058, 5.006964473718641, 2.4287423279953577]} + {0: [0.9460222033129541, 1170.497251012354, 262.5516161912825], 1: [0.0539777966870461, 5.2773286896865645, 2.9364114131677104]} + {0: [0.9663754049393039, 1303.092442412304, 287.3181120567953], 1: [0.03362459506069605, 3.853490289686548, 2.450773048081185]} + {0: [0.9772192032228765, 1416.7167754830296, 336.64512298842783], 1: [0.02278079677712362, 7.047518512200104, 4.952298782287762]} + {0: [0.9815961231461817, 1469.1136627394724, 337.8351815472668], 1: [0.018403876853818182, 5.866944859055205, 3.918816917465496]} + {0: [0.9842413021617183, 1586.8128109366335, 405.2503880239854], 1: [0.015758697838281707, 2.5480435600958535, 1.9956201909777809]} + {0: [0.9867043897724639, 1625.1972862369262, 378.9465246045593], 1: [0.013295610227536137, 3.475678289130355, 2.5682353128420434]} + {0: [0.987660538092123, 1778.7187561638418, 416.9417683769747], 1: [0.012339461907877028, 5.47583229156436, 3.130497622139025]} + {0: [0.9876488736779572, 1805.283981990615, 400.28342434168457], 1: [0.01235112632204294, 1.7934259618352553, 1.4008066291680346]} + {0: [0.9886635407370604, 1793.622960997514, 426.7313191854827], 1: [0.01133645926293954, 4.4258270542596305, 2.835231957569954]} + {0: [0.988445368510721, 2051.866127278426, 488.71650815502176], 1: [0.011554631489278936, 5.316905703446095, 3.5817894993707897]} + {0: [0.9882417296143949, 2039.524149922142, 483.31830713134076], 1: [0.011758270385605044, 5.194717875479852, 2.907788041617261]} + {0: [0.9881635657674138, 2062.2691201137536, 451.7688224296245], 1: [0.011836434232586257, 2.8949187368585445, 1.8083690293180652]} + PORTFOLIO support: escaped_mass=[0.322 0. ] early=[0. 0.] (hard-edged members [0]; max 3.218e-01, early max 0.000e+00) weight_share=[0.568 0.432] + +sampler target n_eff n_ESS JSmax |pull| widthdev lnZbias verdict +AV mix_d2_n1_s101 2702 5400 0.0002 0.010 0.002 -0.003 PASS +GMM mix_d2_n1_s101 2050 9797 0.0005 0.023 0.009 -0.006 PASS +AC mix_d2_n1_s101 2058 8108 0.0002 0.010 0.006 -0.000 PASS +portfolio mix_d2_n1_s101 2033 9416 0.0002 0.010 0.004 -0.011 PASS +AV mix_d2_n1_s202 2667 5237 0.0005 0.024 0.011 +0.021 PASS +GMM mix_d2_n1_s202 1674 15814 0.0001 0.007 0.004 -0.006 PASS +AC mix_d2_n1_s202 1657 12965 0.0003 0.010 0.008 +0.006 PASS +portfolio mix_d2_n1_s202 1673 16170 0.0002 0.004 0.001 -0.003 PASS +AV mix_d2_n2_s101 2381 7257 0.0002 0.008 0.003 -0.016 PASS +GMM mix_d2_n2_s101 381 6418 0.0004 0.014 0.009 -0.031 PASS +AC mix_d2_n2_s101 415 4372 0.0006 0.004 0.004 +0.005 PASS +portfolio mix_d2_n2_s101 394 6865 0.0002 0.001 0.004 -0.009 PASS +AV mix_d2_n2_s202 2128 4692 0.0010 0.008 0.003 -0.008 PASS +GMM mix_d2_n2_s202 2206 10289 0.0003 0.009 0.002 -0.001 PASS +AC mix_d2_n2_s202 2218 9268 0.0004 0.005 0.001 +0.001 PASS +portfolio mix_d2_n2_s202 2197 13391 0.0003 0.008 0.002 -0.007 PASS +AV mix_d4_n1_s101 524 1965 0.0018 0.018 0.007 -0.161 PASS +GMM mix_d4_n1_s101 33 732 0.0036 0.029 0.020 +0.023 STARVED [n_eff=33 < 100: shape untestable at this budget] +AC mix_d4_n1_s101 72 661 0.0047 0.075 0.022 +0.052 STARVED [n_eff=72 < 100: shape untestable at this budget] +portfolio mix_d4_n1_s101 39 280 0.0158 0.119 0.037 +0.046 STARVED [n_eff=39 < 100: shape untestable at this budget] +AV mix_d4_n1_s202 975 3553 0.0005 0.015 0.011 -0.265 FAIL [lnZ bias -0.265 > 0.228] +GMM mix_d4_n1_s202 34 795 0.0015 0.028 0.029 +0.041 STARVED [n_eff=34 < 100: shape untestable at this budget] +AC mix_d4_n1_s202 96 1007 0.0025 0.031 0.022 -0.042 STARVED [n_eff=96 < 100: shape untestable at this budget] +portfolio mix_d4_n1_s202 33 324 0.0088 0.072 0.062 +0.009 STARVED [n_eff=33 < 100: shape untestable at this budget] +AV mix_d4_n2_s101 193 1293 0.0025 0.042 0.016 -0.129 PASS +GMM mix_d4_n2_s101 42 404 0.0205 0.123 0.099 -0.083 STARVED [n_eff=42 < 100: shape untestable at this budget] +AC mix_d4_n2_s101 76 397 0.0107 0.036 0.097 -0.041 STARVED [n_eff=76 < 100: shape untestable at this budget] +portfolio mix_d4_n2_s101 76 680 0.0421 0.390 0.275 -0.348 STARVED [n_eff=76 < 100: shape untestable at this budget] +AV mix_d4_n2_s202 831 3679 0.0005 0.008 0.005 -0.020 PASS +GMM mix_d4_n2_s202 60 1134 0.0039 0.020 0.020 +0.012 STARVED [n_eff=60 < 100: shape untestable at this budget] +AC mix_d4_n2_s202 121 1248 0.0030 0.061 0.021 -0.025 PASS +portfolio mix_d4_n2_s202 36 294 0.0105 0.085 0.068 -0.024 STARVED [n_eff=36 < 100: shape untestable at this budget] +# strict failures: 1 warn-only failures: 0 starved (non-blocking): 11 diff --git a/MonteCarloMarginalizeCode/Code/test/expensive_before_merging/integrators/tier3/tier0_cand.txt b/MonteCarloMarginalizeCode/Code/test/expensive_before_merging/integrators/tier3/tier0_cand.txt new file mode 100644 index 000000000..124587bc2 --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/test/expensive_before_merging/integrators/tier3/tier0_cand.txt @@ -0,0 +1,540 @@ +# RIFT under test: /home/richard.oshaughnessy/rift_O4d_junior_ralph/.claude/worktrees/rvs-naming/MonteCarloMarginalizeCode/Code/RIFT +# shape_recovery: 32 runs (8 targets x 4 samplers), preset=quick + - No vegas - +no multiprocess + no cupy (mcsamplerGPU) + no cupy (mcsamplerAV) + no cupy (mcsamplerPortfolio) +RIFT portfolio plugins: [] + Adding parameter x0 with limits [-5.0, 5.0] + Adapting x0 + Adding parameter x1 with limits [-5.0, 5.0] + Adapting x1 +10000 405.5078325517685 14.041581271706718 - -1.2552660987134865 0.029326396883198342 +20010 1508.2830433771187 14.041752629611246 - -1.2555517721906473 0.015363106965444705 +30135 2701.8746801371262 14.041887757834852 - -1.2555517721906473 0.011488785722326344 + [AV mc diag] sigma_mc=0.0115 sigma_lnV=0.0158 trunc_p=1.00e-03 khat=-0.897 ESS=5400.4 + mcsampler: Adding parameter x0 with limits [-5.0, 5.0] + mcsampler: Adding parameter x1 with limits [-5.0, 5.0] + mcsamplerEnsemble: setup() called without n_comp; defaulting n_comp=1 (n_comp=None previously disabled GMM training silently; pass n_comp=0 to disable adaptation) + ==> input assumed as lnL + ==> internal calculations and return values are lnI +cumulative eval time: 0.0014219284057617188 +integrator iterations: 5 +Result 182.97944615464746 95.38897215466109 + Adding parameter x0 with limits [-5.0, 5.0] + Adapting x0 + Adding parameter x1 with limits [-5.0, 5.0] + Adapting x1 + [mc diag] khat=0.381 ESS=8107.9 sigma_block=0.0098 (chunks=5) + Adding parameter x0 with limits [-5.0, 5.0] + Adapting x0 + mcsampler: Adding parameter x0 with limits [-5.0, 5.0] + Adding parameter x1 with limits [-5.0, 5.0] + Adapting x1 + mcsampler: Adding parameter x1 with limits [-5.0, 5.0] + PORTFOLIO setup {} + PORTFOLIO setup {} + mcsamplerEnsemble: setup() called without n_comp; defaulting n_comp=1 (n_comp=None previously disabled GMM training silently; pass n_comp=0 to disable adaptation) + {0: [0.5, 404.39057734209877, 203.50536261809594], 1: [0.5, 420.15038011607965, 213.53775579716645]} + {0: [0.4952815170793957, 1164.499581626134, 579.9782598062495], 1: [0.5047184829206044, 1282.1656052154672, 692.481017893753]} + {0: [0.48579915363587445, 1310.4782710549105, 665.8483784650201], 1: [0.5142008463641256, 1540.8733180932725, 837.138598300517]} + {0: [0.47302192316955693, 1394.9737424855948, 691.7852004640791], 1: [0.5269780768304431, 1658.083005665521, 906.1465273931137]} + {0: [0.4653409818653412, 1433.8822041985184, 722.9093823383027], 1: [0.5346590181346589, 1805.483273660012, 1010.2862759180348]} + PORTFOLIO support: escaped_mass=[0. 0.] early=[0. 0.] (hard-edged members [0]; max 3.879e-04, early max 0.000e+00) weight_share=[0.447 0.553] + Adding parameter x0 with limits [-5.0, 5.0] + Adapting x0 + Adding parameter x1 with limits [-5.0, 5.0] + Adapting x1 +10000 171.45924151547894 14.103563175714072 - -2.3025850929940455 0.04440875770272375 +20035 1341.3990029588442 14.105092593009411 - -2.3517296157781 0.01573439771090637 +30040 2666.80574674158 14.105173959560206 - -2.3517296157781 0.011167215287126598 + [AV mc diag] sigma_mc=0.0112 sigma_lnV=0.0301 trunc_p=1.00e-03 khat=-0.968 ESS=5236.5 + mcsampler: Adding parameter x0 with limits [-5.0, 5.0] + mcsampler: Adding parameter x1 with limits [-5.0, 5.0] + mcsamplerEnsemble: setup() called without n_comp; defaulting n_comp=1 (n_comp=None previously disabled GMM training silently; pass n_comp=0 to disable adaptation) + ==> input assumed as lnL + ==> internal calculations and return values are lnI +cumulative eval time: 0.002388477325439453 +integrator iterations: 10 +Result 183.19254618659724 95.36542399037812 + Adding parameter x0 with limits [-5.0, 5.0] + Adapting x0 + Adding parameter x1 with limits [-5.0, 5.0] + Adapting x1 + [mc diag] khat=1.408 ESS=12965.1 sigma_block=0.0084 (chunks=10) + Adding parameter x0 with limits [-5.0, 5.0] + Adapting x0 + mcsampler: Adding parameter x0 with limits [-5.0, 5.0] + Adding parameter x1 with limits [-5.0, 5.0] + Adapting x1 + mcsampler: Adding parameter x1 with limits [-5.0, 5.0] + PORTFOLIO setup {} + PORTFOLIO setup {} + mcsamplerEnsemble: setup() called without n_comp; defaulting n_comp=1 (n_comp=None previously disabled GMM training silently; pass n_comp=0 to disable adaptation) + {0: [0.5, 177.5448987678759, 89.55207612788095], 1: [0.5, 165.17650151336184, 84.84760653916595]} + {0: [0.5089397043305469, 1162.8956903739847, 600.9915713603721], 1: [0.49106029566945303, 921.6851992333078, 495.37765253249154]} + {0: [0.5329711561745135, 1578.6934963336735, 820.7567435179499], 1: [0.4670288438254865, 1077.8192143794015, 583.6836429823455]} + {0: [0.5628714623240679, 1730.2738782744752, 924.6182159088588], 1: [0.437128537675932, 1104.8907639589308, 604.2954201857742]} + {0: [0.585639839064502, 1920.15910667202, 1002.7763089856462], 1: [0.41436016093549805, 1038.771847543745, 566.511993263526]} + {0: [0.6160134203668732, 2076.440135601993, 1066.0239237213232], 1: [0.3839865796331269, 1069.6298499585373, 593.0061893951683]} + {0: [0.6365795373505092, 2175.9748953564144, 1139.5247998702187], 1: [0.36342046264949085, 1068.228521033336, 596.2946025259992]} + {0: [0.6520913189732968, 2273.0143296400825, 1193.180983138384], 1: [0.34790868102670325, 995.8856719594488, 554.4918898172995]} + {0: [0.6719410866321384, 2401.221817596801, 1242.4273542170629], 1: [0.3280589133678616, 1004.0328828012109, 558.6775171382616]} + {0: [0.6866471755804096, 2502.489572987164, 1301.0892388335583], 1: [0.3133528244195905, 1014.886823891199, 577.1080716255299]} + PORTFOLIO support: escaped_mass=[0.001 0. ] early=[0. 0.] (hard-edged members [0]; max 5.204e-04, early max 0.000e+00) weight_share=[0.625 0.375] + Adding parameter x0 with limits [-5.0, 5.0] + Adapting x0 + Adding parameter x1 with limits [-5.0, 5.0] + Adapting x1 +10000 31.819447074032077 14.207972098313473 - -2.3025850929940455 0.08835771400786861 +20036 244.2117034656364 14.210078425715949 - -2.7220031005580765 0.031917797533072424 +30130 639.375415982063 14.21021726755302 - -2.722263845420707 0.02042000613224233 +40247 1067.9896890154914 14.210218357471094 - -2.722263845420707 0.015949291411617203 +50291 1499.5155333867363 14.210218357471094 - -2.722263845420707 0.013501237259551241 +60396 1942.3478273356657 14.210218357471094 - -2.722263845420707 0.01185664796374099 +70476 2380.662791690032 14.210249149610629 - -2.722263845420707 0.010669749554112522 + [AV mc diag] sigma_mc=0.0107 sigma_lnV=0.0312 trunc_p=1.00e-03 khat=-0.897 ESS=7257.3 + mcsampler: Adding parameter x0 with limits [-5.0, 5.0] + mcsampler: Adding parameter x1 with limits [-5.0, 5.0] + mcsamplerEnsemble: setup() called without n_comp; defaulting n_comp=1 (n_comp=None previously disabled GMM training silently; pass n_comp=0 to disable adaptation) + ==> input assumed as lnL + ==> internal calculations and return values are lnI +cumulative eval time: 0.010751724243164062 +integrator iterations: 10 +Result 184.19700807750687 95.36396115277081 + Adding parameter x0 with limits [-5.0, 5.0] + Adapting x0 + Adding parameter x1 with limits [-5.0, 5.0] + Adapting x1 + [mc diag] khat=0.13 ESS=4371.8 sigma_block=0.0126 (chunks=10) + Adding parameter x0 with limits [-5.0, 5.0] + Adapting x0 + mcsampler: Adding parameter x0 with limits [-5.0, 5.0] + Adding parameter x1 with limits [-5.0, 5.0] + Adapting x1 + mcsampler: Adding parameter x1 with limits [-5.0, 5.0] + PORTFOLIO setup {} + PORTFOLIO setup {} + mcsamplerEnsemble: setup() called without n_comp; defaulting n_comp=1 (n_comp=None previously disabled GMM training silently; pass n_comp=0 to disable adaptation) + {0: [0.5, 51.9723259421829, 14.748448534784451], 1: [0.5, 55.427432218152695, 20.998614742918825]} + {0: [0.49192707463236135, 371.8314195271132, 119.36376127158661], 1: [0.5080729253676386, 332.97792324373694, 121.83103241098968]} + {0: [0.5095981199902603, 715.2987679579478, 243.24537930735127], 1: [0.49040188000973983, 376.99580634900184, 141.86204388517163]} + {0: [0.5811888596830933, 870.7309062246638, 289.44903804658264], 1: [0.4188111403169068, 385.0667030585916, 136.92404015702158]} + {0: [0.635785751111682, 982.0434860206842, 334.3832059784413], 1: [0.36421424888831794, 332.55502092514337, 118.10210511881448]} + {0: [0.6894115938771643, 1126.5011675775108, 367.30881046158436], 1: [0.31058840612283584, 309.75672733452075, 115.24733726537433]} + {0: [0.734473389032334, 1176.3301954909668, 397.90936568218194], 1: [0.26552661096766605, 295.206057259213, 103.69043501081441]} + {0: [0.764314471818733, 1306.9061103706952, 411.8514599317506], 1: [0.23568552818126703, 233.86552042573294, 93.27103159271704]} + {0: [0.8032316949739701, 1375.211639958176, 451.13782989615083], 1: [0.1967683050260299, 229.99107527807186, 87.11315971680541]} + {0: [0.826779361924609, 1447.7486317707805, 470.7680759023719], 1: [0.17322063807539112, 212.722387752275, 72.63656901282289]} + PORTFOLIO support: escaped_mass=[0. 0.] early=[0. 0.] (hard-edged members [0]; max 4.775e-04, early max 0.000e+00) weight_share=[0.705 0.295] + Adding parameter x0 with limits [-5.0, 5.0] + Adapting x0 + Adding parameter x1 with limits [-5.0, 5.0] + Adapting x1 +10000 282.90228061878827 14.069983704468804 - -1.606941032235513 0.03391857697232854 +20005 1162.8213455983655 14.069983704468804 - -1.606941032235513 0.01645787071884805 +30113 2127.8021769238253 14.069983704468804 - -1.606941032235513 0.012202802407077518 + [AV mc diag] sigma_mc=0.0122 sigma_lnV=0.0200 trunc_p=1.00e-03 khat=-0.714 ESS=4692.2 + mcsampler: Adding parameter x0 with limits [-5.0, 5.0] + mcsampler: Adding parameter x1 with limits [-5.0, 5.0] + mcsamplerEnsemble: setup() called without n_comp; defaulting n_comp=1 (n_comp=None previously disabled GMM training silently; pass n_comp=0 to disable adaptation) + ==> input assumed as lnL + ==> internal calculations and return values are lnI +cumulative eval time: 0.008715629577636719 +integrator iterations: 8 +Result 183.48538908288538 95.39121872772748 + Adding parameter x0 with limits [-5.0, 5.0] + Adapting x0 + Adding parameter x1 with limits [-5.0, 5.0] + Adapting x1 + [mc diag] khat=-0.17 ESS=9268.4 sigma_block=0.0067 (chunks=8) + Adding parameter x0 with limits [-5.0, 5.0] + Adapting x0 + mcsampler: Adding parameter x0 with limits [-5.0, 5.0] + Adding parameter x1 with limits [-5.0, 5.0] + Adapting x1 + mcsampler: Adding parameter x1 with limits [-5.0, 5.0] + PORTFOLIO setup {} + PORTFOLIO setup {} + mcsamplerEnsemble: setup() called without n_comp; defaulting n_comp=1 (n_comp=None previously disabled GMM training silently; pass n_comp=0 to disable adaptation) + {0: [0.5, 296.2610253951537, 139.04537062577708], 1: [0.5, 279.600495815957, 127.87095339115054]} + {0: [0.5071497497009275, 990.3307872048276, 414.86868536147415], 1: [0.4928502502990726, 720.909910850529, 301.34537138166445]} + {0: [0.5423754473951683, 1295.2587020551941, 554.9477043414095], 1: [0.45762455260483176, 777.0551196109792, 331.57444545226883]} + {0: [0.5827238366336385, 1451.8550023157277, 610.4940117862412], 1: [0.41727616336636153, 738.113878870234, 328.4252229978778]} + {0: [0.6214918632533081, 1629.7615647227137, 707.6480668029269], 1: [0.3785081367466921, 682.9422643562978, 308.0735997154563]} + {0: [0.6613531910827484, 1800.7728976142726, 778.6250560470654], 1: [0.33864680891725163, 618.3285033975036, 270.8751999372255]} + {0: [0.7007496821554526, 1940.6756979943145, 836.4344283729793], 1: [0.2992503178445475, 563.1623845668614, 251.22976019545018]} + {0: [0.7354711106840421, 2057.0426762093707, 908.114177346755], 1: [0.2645288893159578, 487.33474807656927, 215.93423013433576]} + PORTFOLIO support: escaped_mass=[0. 0.] early=[0. 0.] (hard-edged members [0]; max 3.778e-04, early max 0.000e+00) weight_share=[0.677 0.323] + Adding parameter x0 with limits [-5.0, 5.0] + Adapting x0 + Adding parameter x1 with limits [-5.0, 5.0] + Adapting x1 + Adding parameter x2 with limits [-5.0, 5.0] + Adapting x2 + Adding parameter x3 with limits [-5.0, 5.0] + Adapting x3 +10000 2.1166036998516646 14.10878870087279 - -2.3025850929940455 0.5587415207624211 +20132 3.2645059053429466 14.10878870087279 - -3.641001373869472 0.3851235949100838 +30252 9.328002403246321 14.141353307335391 - -4.6731176712492175 0.215323815340953 +40431 21.37504601984334 14.14956001586711 - -5.572872434483273 0.1361427727525657 +50631 34.358388363239264 14.167645117262165 - -6.247210283085722 0.09054500478058455 +60807 67.86369255033544 14.173680089014397 - -6.247653447285308 0.06486358236993159 +70835 99.32094617660934 14.173680089014397 - -6.248222923382703 0.051639105049905605 +81038 119.57911197277987 14.180351956366422 - -6.248222923382703 0.04458732795102924 +91510 155.0990039198797 14.180351956366422 - -6.248222923382703 0.039398087349960906 +101875 190.09241163855137 14.180351956366422 - -6.248222923382703 0.036047136109075416 +111963 225.76520348463504 14.180351956366422 - -6.248222923382703 0.03324651341128292 +122623 259.27198929944046 14.180351956366422 - -6.248222923382703 0.03091841392320598 +132991 279.9762144098173 14.183723594101782 - -6.248222923382703 0.029029142917085856 +143001 310.3450237022169 14.183723594101782 - -6.248222923382703 0.027424545989686508 +153704 347.8415391039465 14.183723594101782 - -6.248222923382703 0.02609891814222508 +164114 379.72463439571266 14.183723594101782 - -6.248222923382703 0.025020895626516593 +174904 416.8710093163701 14.183723594101782 - -6.248222923382703 0.023964207037525137 +185083 455.7068490238235 14.183752169903658 - -6.248222923382703 0.02318405380550132 +195478 489.05074998486566 14.183752169903658 - -6.248222923382703 0.022258981314476334 +206296 523.5732322490855 14.183752169903658 - -6.248222923382703 0.02149846880610427 + [AV mc diag] sigma_mc=0.0215 sigma_lnV=0.0575 trunc_p=1.00e-03 khat=-0.166 ESS=1965.4 + mcsampler: Adding parameter x0 with limits [-5.0, 5.0] + mcsampler: Adding parameter x1 with limits [-5.0, 5.0] + mcsampler: Adding parameter x2 with limits [-5.0, 5.0] + mcsampler: Adding parameter x3 with limits [-5.0, 5.0] + mcsamplerEnsemble: setup() called without n_comp; defaulting n_comp=1 (n_comp=None previously disabled GMM training silently; pass n_comp=0 to disable adaptation) + ==> input assumed as lnL + ==> internal calculations and return values are lnI +cumulative eval time: 0.008012056350708008 +integrator iterations: 20 +Result 178.02185556688133 90.81258801139508 + Adding parameter x0 with limits [-5.0, 5.0] + Adapting x0 + Adding parameter x1 with limits [-5.0, 5.0] + Adapting x1 + Adding parameter x2 with limits [-5.0, 5.0] + Adapting x2 + Adding parameter x3 with limits [-5.0, 5.0] + Adapting x3 + [mc diag] khat=0.457 ESS=661.3 sigma_block=0.0202 (chunks=20) + Adding parameter x0 with limits [-5.0, 5.0] + Adapting x0 + mcsampler: Adding parameter x0 with limits [-5.0, 5.0] + Adding parameter x1 with limits [-5.0, 5.0] + Adapting x1 + mcsampler: Adding parameter x1 with limits [-5.0, 5.0] + Adding parameter x2 with limits [-5.0, 5.0] + Adapting x2 + mcsampler: Adding parameter x2 with limits [-5.0, 5.0] + Adding parameter x3 with limits [-5.0, 5.0] + Adapting x3 + mcsampler: Adding parameter x3 with limits [-5.0, 5.0] + PORTFOLIO setup {} + PORTFOLIO setup {} + mcsamplerEnsemble: setup() called without n_comp; defaulting n_comp=1 (n_comp=None previously disabled GMM training silently; pass n_comp=0 to disable adaptation) + {0: [0.5, 1.248295518238638, 1.1254776568671085], 1: [0.5, 2.1816844448208124, 1.5365904502837253]} + {0: [0.339253386004392, 2.0863383845343795, 1.5940715258701235], 1: [0.6607466139956081, 53.58090482074, 23.57512119631764]} + {0: [0.18372789928558023, 5.409084759820267, 3.393640955341682], 1: [0.8162721007144199, 106.13664576295155, 41.15145548144278]} + {0: [0.11620607778951314, 7.9952264673513325, 4.121404135389044], 1: [0.8837939222104869, 132.15944204805925, 48.06684671180827]} + {0: [0.08772788187470719, 25.986585268088895, 10.097772503158266], 1: [0.9122721181252927, 157.89875854390195, 60.52029593346723]} + {0: [0.11628338350900845, 96.69020360256873, 33.65520682335699], 1: [0.8837166164909915, 152.99977689446433, 57.91204287960293]} + {0: [0.2531097596259792, 258.67905895203916, 78.5049828623709], 1: [0.746890240374021, 142.6312475202534, 57.55971235173839]} + {0: [0.4487397859401182, 550.1873432839869, 162.9874371422758], 1: [0.5512602140598818, 108.65845435533113, 41.44042464297316]} + {0: [0.6400381261044226, 944.9198364331634, 276.2342100560345], 1: [0.35996187389557754, 72.77266782391348, 28.761735878796756]} + {0: [0.7811348197272289, 1364.5842051535617, 424.79237492779964], 1: [0.21886518027277116, 45.71722647821901, 14.473663633891162]} + {0: [0.8704973878784255, 1723.631603472961, 537.8398990938981], 1: [0.12950261212157457, 28.757678791607454, 13.831076965402488]} + {0: [0.9227850570833107, 1922.3878904753471, 595.382446897122], 1: [0.07721494291668916, 19.51980423046243, 10.067061280011234]} + {0: [0.9519073529646879, 2217.9695230124858, 682.448378719267], 1: [0.0480926470353122, 16.85241305820879, 10.206703360945713]} + {0: [0.9676013079876755, 2349.837363477613, 756.8019080517107], 1: [0.03239869201232444, 10.954550876231199, 6.132793846440526]} + {0: [0.9768275221594512, 2573.0829541708786, 859.785502221698], 1: [0.02317247784054883, 14.695371082180486, 7.748935742530275]} + {0: [0.9808875946237463, 2678.765514456453, 840.6982380549798], 1: [0.01911240537625367, 10.730006616457821, 6.035851286889501]} + {0: [0.9837329978845024, 2653.714501548123, 860.0494712169331], 1: [0.016267002115497666, 7.101239161492894, 3.9972697322682547]} + {0: [0.9858016042408616, 2891.55528919115, 940.1978391861014], 1: [0.01419839575913841, 7.277040664870868, 4.805576804570387]} + {0: [0.9868937359358537, 3006.085597972147, 1007.846937353592], 1: [0.013106264064146353, 4.857418974428798, 3.1355698104903915]} + {0: [0.9878729209263435, 2923.7569929902147, 954.8498635073959], 1: [0.012127079073656427, 4.96209827142928, 2.7734658774577428]} + PORTFOLIO support: escaped_mass=[0.732 0. ] early=[0. 0.] (hard-edged members [0]; max 7.321e-01, early max 0.000e+00) weight_share=[0.115 0.885] + Adding parameter x0 with limits [-5.0, 5.0] + Adapting x0 + Adding parameter x1 with limits [-5.0, 5.0] + Adapting x1 + Adding parameter x2 with limits [-5.0, 5.0] + Adapting x2 + Adding parameter x3 with limits [-5.0, 5.0] + Adapting x3 +10000 2.8178328241298303 14.106811134643898 - -2.3025850929940455 0.418250025586683 +20044 6.636898307870252 14.106811134643898 - -3.6415257577304656 0.2694494815146293 +30244 18.569424515958797 14.106811134643898 - -4.782558762282528 0.1564679285770689 +40272 31.39390947753484 14.133217768998176 - -5.8575611853115035 0.0950400613972893 +50400 90.75153016851868 14.135386775261454 - -5.874033760938849 0.05430644632152597 +60452 148.50633845870107 14.135881649490756 - -5.874033760938849 0.04278003355929685 +70704 212.60838557132809 14.135881649490756 - -5.874033760938849 0.035828163495813414 +81059 276.7108388335327 14.135881649490756 - -5.874033760938849 0.03122557243214973 +91412 340.4338785177663 14.135881649490756 - -5.874033760938849 0.028282600823514373 +101642 364.0198165794389 14.142809716495856 - -5.874033760938849 0.02601678783062935 +111890 424.8914651771721 14.142809716495856 - -5.874033760938849 0.024176556816571906 +122056 484.99681967482445 14.142809716495856 - -5.874033760938849 0.0226003522331087 +132846 539.2465888770378 14.14352107566793 - -5.874033760938849 0.02131653037709023 +143490 597.0895719339081 14.14352107566793 - -5.874033760938849 0.020273272673902575 +154386 666.0059896139865 14.14352107566793 - -5.874033760938849 0.019326677716182527 +164781 727.8723848155978 14.14352107566793 - -5.874033760938849 0.018476723369639025 +175748 793.8863446881805 14.14352107566793 - -5.874033760938849 0.01772267842643432 +186008 851.2112624839925 14.14352107566793 - -5.874033760938849 0.017076297877889834 +196628 911.9729823975033 14.14352107566793 - -5.874033760938849 0.016499163685447564 +207568 974.5351768847711 14.14352107566793 - -5.874033760938849 0.015937709768956476 + [AV mc diag] sigma_mc=0.0159 sigma_lnV=0.0546 trunc_p=1.00e-03 khat=-0.28 ESS=3553.0 + mcsampler: Adding parameter x0 with limits [-5.0, 5.0] + mcsampler: Adding parameter x1 with limits [-5.0, 5.0] + mcsampler: Adding parameter x2 with limits [-5.0, 5.0] + mcsampler: Adding parameter x3 with limits [-5.0, 5.0] + mcsamplerEnsemble: setup() called without n_comp; defaulting n_comp=1 (n_comp=None previously disabled GMM training silently; pass n_comp=0 to disable adaptation) + ==> input assumed as lnL + ==> internal calculations and return values are lnI +cumulative eval time: 0.011408805847167969 +integrator iterations: 20 +Result 177.97393602102287 90.83017502558896 + Adding parameter x0 with limits [-5.0, 5.0] + Adapting x0 + Adding parameter x1 with limits [-5.0, 5.0] + Adapting x1 + Adding parameter x2 with limits [-5.0, 5.0] + Adapting x2 + Adding parameter x3 with limits [-5.0, 5.0] + Adapting x3 + [mc diag] khat=0.297 ESS=1007.4 sigma_block=0.0436 (chunks=20) + Adding parameter x0 with limits [-5.0, 5.0] + Adapting x0 + mcsampler: Adding parameter x0 with limits [-5.0, 5.0] + Adding parameter x1 with limits [-5.0, 5.0] + Adapting x1 + mcsampler: Adding parameter x1 with limits [-5.0, 5.0] + Adding parameter x2 with limits [-5.0, 5.0] + Adapting x2 + mcsampler: Adding parameter x2 with limits [-5.0, 5.0] + Adding parameter x3 with limits [-5.0, 5.0] + Adapting x3 + mcsampler: Adding parameter x3 with limits [-5.0, 5.0] + PORTFOLIO setup {} + PORTFOLIO setup {} + mcsamplerEnsemble: setup() called without n_comp; defaulting n_comp=1 (n_comp=None previously disabled GMM training silently; pass n_comp=0 to disable adaptation) + {0: [0.5, 3.2229506800456402, 1.9355493777516504], 1: [0.5, 4.929045834021777, 3.2827185845351945]} + {0: [0.431703836171015, 5.724915900075073, 3.1299777750167848], 1: [0.5682961638289852, 48.72026864358284, 19.710710626418305]} + {0: [0.2641270524992567, 15.203843836619749, 6.616319734180189], 1: [0.7358729475007432, 94.52715481924848, 36.61256976793132]} + {0: [0.2013204303019922, 46.27506611028839, 20.11193104504678], 1: [0.7986795696980078, 121.41226001309323, 36.76455191202447]} + {0: [0.23972334865111125, 172.43762301138156, 52.32666705208731], 1: [0.7602766513488888, 126.99272259967736, 27.916166559691472]} + {0: [0.4081369455614514, 429.1114975766229, 130.42773810903947], 1: [0.5918630544385485, 85.72695630460859, 27.240108860434518]} + {0: [0.6191926759012649, 738.5060624995527, 218.11132567553324], 1: [0.3808073240987352, 35.6840682816515, 11.28663701806526]} + {0: [0.7834454494391462, 1030.499638541244, 280.2195711612294], 1: [0.21655455056085382, 23.824908985569103, 9.017061012829894]} + {0: [0.8766031638161738, 1240.2597705755363, 350.76594557234114], 1: [0.12339683618382631, 13.46033990898554, 4.985243123910596]} + {0: [0.928730415919203, 1492.2969487529986, 438.2176649450233], 1: [0.07126958408079706, 21.433633543062328, 11.357468007228611]} + {0: [0.9529098839255886, 1628.8652135545544, 478.48110676722155], 1: [0.04709011607441142, 9.284164039696401, 5.735858740927055]} + {0: [0.9691031384852762, 1660.7101436048717, 458.0950568820307], 1: [0.030896861514723702, 10.188097385996098, 5.418576348366525]} + {0: [0.9769416450072074, 2048.1855767286247, 589.4461131483129], 1: [0.023058354992792712, 5.759946405628074, 3.2041914110916707]} + {0: [0.9824105067997392, 1967.740894952566, 577.3534414829284], 1: [0.017589493200260764, 4.000175011007909, 2.5852406530441283]} + {0: [0.9855236847978116, 1997.8324407837415, 613.7267064524941], 1: [0.01447631520218847, 5.074231196920942, 2.666087763281335]} + {0: [0.9868198279621364, 2095.0999993465853, 609.9328191157805], 1: [0.013180172037863532, 4.366919336088007, 2.8906413813650795]} + {0: [0.9876769398385246, 2080.9025960127187, 596.9447376851235], 1: [0.012323060161475562, 5.0751937735607, 3.4704717473540763]} + {0: [0.9879308490632657, 2203.0532366966154, 644.3245156773512], 1: [0.012069150936734456, 6.412134299736653, 3.833441151409804]} + {0: [0.9878127487811384, 2274.5530690720357, 679.1439191091749], 1: [0.012187251218861479, 3.430703581190969, 2.0941101816490812]} + {0: [0.9884355468971764, 2543.8823905320114, 745.8699702669833], 1: [0.011564453102823693, 7.44295995122474, 4.066419772321259]} + PORTFOLIO support: escaped_mass=[0.389 0. ] early=[0. 0.] (hard-edged members [0]; max 3.888e-01, early max 0.000e+00) weight_share=[0.428 0.572] + Adding parameter x0 with limits [-5.0, 5.0] + Adapting x0 + Adding parameter x1 with limits [-5.0, 5.0] + Adapting x1 + Adding parameter x2 with limits [-5.0, 5.0] + Adapting x2 + Adding parameter x3 with limits [-5.0, 5.0] + Adapting x3 +10000 4.654321405166974 13.988356785322358 - -2.3025850929940455 0.34580594000190723 +20050 9.258681889522418 14.037186958353475 - -3.5579161895951237 0.18493313267509034 +30167 7.66463844118878 14.151979705175876 - -4.7344150169203045 0.18035327572325632 +40310 20.449456391967576 14.169994578519773 - -4.9101931925237325 0.10289436933352308 +50550 28.586966557435268 14.189128986741126 - -4.910614866575943 0.07673452474672303 +60570 38.93282909432903 14.196954453698215 - -4.910614866575943 0.06467603212059274 +70674 50.911106019982725 14.196954453698215 - -4.910614866575943 0.05818808259943886 +81102 64.59066486677915 14.196954453698215 - -4.910614866575943 0.05187536186667367 +91462 77.57399075182056 14.196954453698215 - -4.910614866575943 0.047908500713776364 +101614 90.03903884897062 14.196954453698215 - -4.910614866575943 0.044319853366687555 +111678 100.53867176841253 14.196954453698215 - -4.910614866575943 0.04125462270603144 +122062 112.50028777844122 14.197980189533084 - -4.910614866575943 0.03945010405629966 +132187 124.37075724935244 14.197980189533084 - -4.910614866575943 0.036660348691862805 +142672 137.87634574629726 14.197980189533084 - -4.910614866575943 0.034707721339066154 +153046 151.50287371352752 14.197980189533084 - -4.910614866575943 0.03295379633099876 +163672 155.88676272372882 14.202439862883038 - -4.910614866575943 0.03168707773711685 +173864 156.57154018585163 14.207692401654008 - -4.910614866575943 0.030585608998772416 +184459 169.9892470184039 14.207692401654008 - -4.910614866575943 0.029467700415981464 +194503 180.83155211926666 14.207692401654008 - -4.910614866575943 0.028051270045501217 +204955 193.37144359736385 14.207692401654008 - -4.910614866575943 0.02736405702203732 + [AV mc diag] sigma_mc=0.0274 sigma_lnV=0.0487 trunc_p=1.00e-03 khat=0.781 ESS=1293.4 + mcsampler: Adding parameter x0 with limits [-5.0, 5.0] + mcsampler: Adding parameter x1 with limits [-5.0, 5.0] + mcsampler: Adding parameter x2 with limits [-5.0, 5.0] + mcsampler: Adding parameter x3 with limits [-5.0, 5.0] + mcsamplerEnsemble: setup() called without n_comp; defaulting n_comp=1 (n_comp=None previously disabled GMM training silently; pass n_comp=0 to disable adaptation) + ==> input assumed as lnL + ==> internal calculations and return values are lnI +cumulative eval time: 0.021167993545532227 +integrator iterations: 20 +Result 178.4055563189518 90.70626477991523 + Adding parameter x0 with limits [-5.0, 5.0] + Adapting x0 + Adding parameter x1 with limits [-5.0, 5.0] + Adapting x1 + Adding parameter x2 with limits [-5.0, 5.0] + Adapting x2 + Adding parameter x3 with limits [-5.0, 5.0] + Adapting x3 + [mc diag] khat=0.784 ESS=397.3 sigma_block=0.0605 (chunks=20) + Adding parameter x0 with limits [-5.0, 5.0] + Adapting x0 + mcsampler: Adding parameter x0 with limits [-5.0, 5.0] + Adding parameter x1 with limits [-5.0, 5.0] + Adapting x1 + mcsampler: Adding parameter x1 with limits [-5.0, 5.0] + Adding parameter x2 with limits [-5.0, 5.0] + Adapting x2 + mcsampler: Adding parameter x2 with limits [-5.0, 5.0] + Adding parameter x3 with limits [-5.0, 5.0] + Adapting x3 + mcsampler: Adding parameter x3 with limits [-5.0, 5.0] + PORTFOLIO setup {} + PORTFOLIO setup {} + mcsamplerEnsemble: setup() called without n_comp; defaulting n_comp=1 (n_comp=None previously disabled GMM training silently; pass n_comp=0 to disable adaptation) + {0: [0.5, 5.3232353535111985, 2.8318706422294753], 1: [0.5, 7.981691692974874, 4.317471741880317]} + {0: [0.4420876871680066, 10.931135166205015, 4.879967388702239], 1: [0.5579123128319935, 13.240803085435248, 3.800406294214491]} + {0: [0.4455339080316621, 42.18070089658118, 13.437826808567078], 1: [0.554466091968338, 11.153366133578782, 4.872062681137628]} + {0: [0.6217521335541101, 78.60114305246026, 15.74936504846441], 1: [0.37824786644588987, 6.091055331206363, 2.546657942302334]} + {0: [0.7765181417778043, 137.50740020760304, 27.272441187136355], 1: [0.2234818582221957, 30.000603602778448, 6.462846998834879]} + {0: [0.7975366169598972, 137.72140528730847, 27.343655795578087], 1: [0.20246338304010286, 27.55189607980161, 8.491320160636752]} + {0: [0.8141992244922207, 179.5400624989656, 30.024113623611782], 1: [0.18580077550777924, 4.389715517884242, 2.5334914766178187]} + {0: [0.893409723915284, 338.02315597536176, 30.05675614163942], 1: [0.10659027608471605, 20.36151057651219, 9.718564888329606]} + {0: [0.9152365353843483, 401.962475369205, 52.268678461794096], 1: [0.08476346461565173, 17.077288575492698, 6.477143282402219]} + {0: [0.9338662051551552, 340.63599126039986, 41.959998209699286], 1: [0.06613379484484477, 24.627452931685536, 10.028928861794949]} + {0: [0.9300867805715395, 317.5759792908852, 45.79134095823356], 1: [0.0699132194284606, 12.715513505250966, 5.4368075590056755]} + {0: [0.9426653378501819, 459.97269189264847, 44.3481510420348], 1: [0.057334662149817944, 3.6926355229120817, 1.962575304661153]} + {0: [0.9636274735422092, 659.8359866135735, 41.87138188250769], 1: [0.03637252645779068, 19.184071103293938, 9.150233895407297]} + {0: [0.9637000393442082, 787.9528114781957, 55.51651334508068], 1: [0.03629996065579181, 17.437972175202052, 8.44415727677497]} + {0: [0.9668875144244687, 1049.4604681584365, 59.07188266331073], 1: [0.03311248557553122, 15.85234529919747, 6.603667098423485]} + {0: [0.9716712447495561, 883.9505135912624, 49.849359825317], 1: [0.028328755250443798, 17.666173694896628, 7.517762959678945]} + {0: [0.9718062897349808, 843.3299515244514, 68.53034907604707], 1: [0.02819371026501918, 33.61279718661753, 17.17612017335167]} + {0: [0.9626392162882289, 797.6482639567147, 62.75511900935909], 1: [0.03736078371177119, 30.429226581281146, 13.69747787725144]} + {0: [0.9588906504722623, 1131.37125177585, 65.94339287016473], 1: [0.04110934952773771, 36.99935527334983, 17.289483896285308]} + {0: [0.9593705831549305, 1380.9077486788628, 102.6535442621201], 1: [0.04062941684506938, 44.223303406917054, 20.903703043292587]} + PORTFOLIO support: escaped_mass=[0.237 0. ] early=[0. 0.] (hard-edged members [0]; max 2.368e-01, early max 0.000e+00) weight_share=[0.577 0.423] + Adding parameter x0 with limits [-5.0, 5.0] + Adapting x0 + Adding parameter x1 with limits [-5.0, 5.0] + Adapting x1 + Adding parameter x2 with limits [-5.0, 5.0] + Adapting x2 + Adding parameter x3 with limits [-5.0, 5.0] + Adapting x3 +10000 4.152611788172848 14.065534562729912 - -2.3025850929940455 0.324217562024852 +20074 10.447034945162336 14.065534562729912 - -3.5403794457954922 0.1897914057866351 +30139 31.236757414389245 14.072191660606862 - -4.611648258790916 0.10004443754782794 +40159 84.65132754220016 14.072191660606862 - -4.612343669111014 0.05694874392687903 +50419 112.89993517566796 14.084321798665568 - -4.612552415589175 0.044389586556079046 +60517 153.48659901479346 14.084321798665568 - -4.612856505602161 0.037733608694803245 +70573 199.8494973376436 14.084321798665568 - -4.612856505602161 0.03267153838280504 +80649 240.51857745523438 14.086245092413563 - -4.612856505602161 0.02977148571448821 +90876 285.73443800293717 14.086245092413563 - -4.612856505602161 0.027069882498919628 +100946 321.3530180388238 14.088182096916443 - -4.612856505602161 0.02517584391507103 +111278 369.6931999026329 14.088182096916443 - -4.612856505602161 0.023481141947943117 +121733 424.6839113982268 14.088182096916443 - -4.612856505602161 0.022246804145413357 +131877 472.24977554020944 14.088182096916443 - -4.612856505602161 0.02100201295018492 +142373 522.4622878245692 14.088468146421286 - -4.612856505602161 0.019946775598064726 +152648 573.8509752023142 14.088468146421286 - -4.612856505602161 0.01893429222749329 +163193 627.3271574368105 14.088468146421286 - -4.612856505602161 0.018155090422616125 +173539 674.2436885584109 14.088468146421286 - -4.612856505602161 0.017491889893707497 +184263 723.1379554072879 14.088468146421286 - -4.612856505602161 0.016869603453262792 +194987 776.6235611299687 14.088468146421286 - -4.612856505602161 0.016221635517802972 +205452 831.0850386974821 14.088468146421286 - -4.612856505602161 0.015699312719243415 + [AV mc diag] sigma_mc=0.0157 sigma_lnV=0.0476 trunc_p=1.00e-03 khat=-0.114 ESS=3679.2 + mcsampler: Adding parameter x0 with limits [-5.0, 5.0] + mcsampler: Adding parameter x1 with limits [-5.0, 5.0] + mcsampler: Adding parameter x2 with limits [-5.0, 5.0] + mcsampler: Adding parameter x3 with limits [-5.0, 5.0] + mcsamplerEnsemble: setup() called without n_comp; defaulting n_comp=1 (n_comp=None previously disabled GMM training silently; pass n_comp=0 to disable adaptation) + ==> input assumed as lnL + ==> internal calculations and return values are lnI +cumulative eval time: 0.022528648376464844 +integrator iterations: 20 +Result 177.55758789352998 90.80067203364804 + Adding parameter x0 with limits [-5.0, 5.0] + Adapting x0 + Adding parameter x1 with limits [-5.0, 5.0] + Adapting x1 + Adding parameter x2 with limits [-5.0, 5.0] + Adapting x2 + Adding parameter x3 with limits [-5.0, 5.0] + Adapting x3 + [mc diag] khat=0.376 ESS=1248.0 sigma_block=0.0198 (chunks=20) + Adding parameter x0 with limits [-5.0, 5.0] + Adapting x0 + mcsampler: Adding parameter x0 with limits [-5.0, 5.0] + Adding parameter x1 with limits [-5.0, 5.0] + Adapting x1 + mcsampler: Adding parameter x1 with limits [-5.0, 5.0] + Adding parameter x2 with limits [-5.0, 5.0] + Adapting x2 + mcsampler: Adding parameter x2 with limits [-5.0, 5.0] + Adding parameter x3 with limits [-5.0, 5.0] + Adapting x3 + mcsampler: Adding parameter x3 with limits [-5.0, 5.0] + PORTFOLIO setup {} + PORTFOLIO setup {} + mcsamplerEnsemble: setup() called without n_comp; defaulting n_comp=1 (n_comp=None previously disabled GMM training silently; pass n_comp=0 to disable adaptation) + {0: [0.5, 6.614685526123144, 3.2104056934069845], 1: [0.5, 4.3605001410842235, 2.8372317126992663]} + {0: [0.5618522253853325, 20.491602004190412, 6.408057773342241], 1: [0.4381477746146675, 46.91531304958152, 16.646305913041175]} + {0: [0.43128228436914007, 49.822878642459045, 18.157017095989794], 1: [0.5687177156308599, 73.73081037414637, 21.238735093054743]} + {0: [0.4173744384588678, 176.14375047750522, 41.565777689052396], 1: [0.5826255615411323, 54.10426018917899, 11.416006484834433]} + {0: [0.5905675124495153, 440.0785573115516, 102.42180553967029], 1: [0.40943248755048467, 69.7378838673712, 24.722971785568703]} + {0: [0.7246574149845203, 681.3868820506339, 169.155304345117], 1: [0.27534258501547976, 27.50836748965617, 8.831514786231121]} + {0: [0.8395685080597443, 896.6674838927986, 193.8014469999679], 1: [0.1604314919402558, 19.67915894650428, 6.790913263219432]} + {0: [0.9051461836058894, 1086.333959081652, 265.61433912282985], 1: [0.09485381639411058, 5.006964473718641, 2.4287423279953577]} + {0: [0.9460222033129541, 1170.497251012354, 262.5516161912825], 1: [0.0539777966870461, 5.2773286896865645, 2.9364114131677104]} + {0: [0.9663754049393039, 1303.092442412304, 287.3181120567953], 1: [0.03362459506069605, 3.853490289686548, 2.450773048081185]} + {0: [0.9772192032228765, 1416.7167754830296, 336.64512298842783], 1: [0.02278079677712362, 7.047518512200104, 4.952298782287762]} + {0: [0.9815961231461817, 1469.1136627394724, 337.8351815472668], 1: [0.018403876853818182, 5.866944859055205, 3.918816917465496]} + {0: [0.9842413021617183, 1586.8128109366335, 405.2503880239854], 1: [0.015758697838281707, 2.5480435600958535, 1.9956201909777809]} + {0: [0.9867043897724639, 1625.1972862369262, 378.9465246045593], 1: [0.013295610227536137, 3.475678289130355, 2.5682353128420434]} + {0: [0.987660538092123, 1778.7187561638418, 416.9417683769747], 1: [0.012339461907877028, 5.47583229156436, 3.130497622139025]} + {0: [0.9876488736779572, 1805.283981990615, 400.28342434168457], 1: [0.01235112632204294, 1.7934259618352553, 1.4008066291680346]} + {0: [0.9886635407370604, 1793.622960997514, 426.7313191854827], 1: [0.01133645926293954, 4.4258270542596305, 2.835231957569954]} + {0: [0.988445368510721, 2051.866127278426, 488.71650815502176], 1: [0.011554631489278936, 5.316905703446095, 3.5817894993707897]} + {0: [0.9882417296143949, 2039.524149922142, 483.31830713134076], 1: [0.011758270385605044, 5.194717875479852, 2.907788041617261]} + {0: [0.9881635657674138, 2062.2691201137536, 451.7688224296245], 1: [0.011836434232586257, 2.8949187368585445, 1.8083690293180652]} + PORTFOLIO support: escaped_mass=[0.322 0. ] early=[0. 0.] (hard-edged members [0]; max 3.218e-01, early max 0.000e+00) weight_share=[0.568 0.432] + +sampler target n_eff n_ESS JSmax |pull| widthdev lnZbias verdict +AV mix_d2_n1_s101 2702 5400 0.0002 0.010 0.002 -0.003 PASS +GMM mix_d2_n1_s101 2050 9797 0.0005 0.023 0.009 -0.006 PASS +AC mix_d2_n1_s101 2058 8108 0.0002 0.010 0.006 -0.000 PASS +portfolio mix_d2_n1_s101 2033 9416 0.0002 0.010 0.004 -0.011 PASS +AV mix_d2_n1_s202 2667 5237 0.0005 0.024 0.011 +0.021 PASS +GMM mix_d2_n1_s202 1674 15814 0.0001 0.007 0.004 -0.006 PASS +AC mix_d2_n1_s202 1657 12965 0.0003 0.010 0.008 +0.006 PASS +portfolio mix_d2_n1_s202 1673 16170 0.0002 0.004 0.001 -0.003 PASS +AV mix_d2_n2_s101 2381 7257 0.0002 0.008 0.003 -0.016 PASS +GMM mix_d2_n2_s101 381 6418 0.0004 0.014 0.009 -0.031 PASS +AC mix_d2_n2_s101 415 4372 0.0006 0.004 0.004 +0.005 PASS +portfolio mix_d2_n2_s101 394 6865 0.0002 0.001 0.004 -0.009 PASS +AV mix_d2_n2_s202 2128 4692 0.0010 0.008 0.003 -0.008 PASS +GMM mix_d2_n2_s202 2206 10289 0.0003 0.009 0.002 -0.001 PASS +AC mix_d2_n2_s202 2218 9268 0.0004 0.005 0.001 +0.001 PASS +portfolio mix_d2_n2_s202 2197 13391 0.0003 0.008 0.002 -0.007 PASS +AV mix_d4_n1_s101 524 1965 0.0018 0.018 0.007 -0.161 PASS +GMM mix_d4_n1_s101 33 732 0.0036 0.029 0.020 +0.023 STARVED [n_eff=33 < 100: shape untestable at this budget] +AC mix_d4_n1_s101 72 661 0.0047 0.075 0.022 +0.052 STARVED [n_eff=72 < 100: shape untestable at this budget] +portfolio mix_d4_n1_s101 39 280 0.0158 0.119 0.037 +0.046 STARVED [n_eff=39 < 100: shape untestable at this budget] +AV mix_d4_n1_s202 975 3553 0.0005 0.015 0.011 -0.265 FAIL [lnZ bias -0.265 > 0.228] +GMM mix_d4_n1_s202 34 795 0.0015 0.028 0.029 +0.041 STARVED [n_eff=34 < 100: shape untestable at this budget] +AC mix_d4_n1_s202 96 1007 0.0025 0.031 0.022 -0.042 STARVED [n_eff=96 < 100: shape untestable at this budget] +portfolio mix_d4_n1_s202 33 324 0.0088 0.072 0.062 +0.009 STARVED [n_eff=33 < 100: shape untestable at this budget] +AV mix_d4_n2_s101 193 1293 0.0025 0.042 0.016 -0.129 PASS +GMM mix_d4_n2_s101 42 404 0.0205 0.123 0.099 -0.083 STARVED [n_eff=42 < 100: shape untestable at this budget] +AC mix_d4_n2_s101 76 397 0.0107 0.036 0.097 -0.041 STARVED [n_eff=76 < 100: shape untestable at this budget] +portfolio mix_d4_n2_s101 76 680 0.0421 0.390 0.275 -0.348 STARVED [n_eff=76 < 100: shape untestable at this budget] +AV mix_d4_n2_s202 831 3679 0.0005 0.008 0.005 -0.020 PASS +GMM mix_d4_n2_s202 60 1134 0.0039 0.020 0.020 +0.012 STARVED [n_eff=60 < 100: shape untestable at this budget] +AC mix_d4_n2_s202 121 1248 0.0030 0.061 0.021 -0.025 PASS +portfolio mix_d4_n2_s202 36 294 0.0105 0.085 0.068 -0.024 STARVED [n_eff=36 < 100: shape untestable at this budget] +# strict failures: 1 warn-only failures: 0 starved (non-blocking): 11 diff --git a/MonteCarloMarginalizeCode/Code/test/expensive_before_merging/integrators/tier3/tier3_ens2.sh b/MonteCarloMarginalizeCode/Code/test/expensive_before_merging/integrators/tier3/tier3_ens2.sh new file mode 100644 index 000000000..cdbb9b193 --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/test/expensive_before_merging/integrators/tier3/tier3_ens2.sh @@ -0,0 +1,61 @@ +#!/bin/bash +# Tier 3 ensemble v2. GPU ILE is NOT deterministic at fixed --seed, so this compares +# DISTRIBUTIONS. Arms interleaved within each replicate so machine drift hits both equally. +# All configs carry --vectorized --gpu => DiscreteFactoredLogLikelihoodViaArrayVectorNoLoop +# with xpy=cupy (proved by noloop_probe.py, 20 calls, scalar path 0). +T=/local/richard.oshaughnessy/tier3; D=$T/ILE-GPU-Paper/demos +RP=/cvmfs/software.igwn.org/conda/envs/igwn/bin +W=/home/richard.oshaughnessy/rift_O4d_junior_ralph/.claude/worktrees +export OMP_NUM_THREADS=1 MKL_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 CUDA_VISIBLE_DEVICES=1 + +COMMON="--n-chunk 10000 --time-marginalization --sim-xml $D/overlap-grid.xml.gz --reference-freq 100.0 --adapt-weight-exponent 0.1 --event-time 1000000014.236547946 --save-P 0.1 --cache-file $D/zero_noise.cache --fmin-template 10 --n-max 200000 --fmax 1700.0 --save-deltalnL inf --l-max 2 --n-eff 30 --approximant SEOBNRv4 --adapt-floor-level 0.1 --d-max 1000 --psd-file H1=$D/HLV-ILIGO_PSD.xml.gz --psd-file L1=$D/HLV-ILIGO_PSD.xml.gz --channel-name H1=FAKE-STRAIN --channel-name L1=FAKE-STRAIN --inclination-cosine-sampler --declination-cosine-sampler --data-start-time 1000000008 --data-end-time 1000000016 --inv-spec-trunc-time 0 --no-adapt-after-first --no-adapt-distance --srate 4096 --vectorized --gpu --n-events-to-analyze 1 --fairdraw-extrinsic-output" +REP="--mc-error-replicas 3 --mc-error-sigma-trigger 0.0" +DG="--export-marginal-distance-grid --internal-use-lnL" + +cfg_opts () { + case $1 in + A) echo "" ;; # GPU linear backend, plain + B) echo "$REP" ;; # linear backend + replica POOLING + D) echo "--interpolate-time True" ;; # cubic NoLoop time interpolation + AV) echo "--sampler-method AV $DG $REP" ;; # lnL family + pooling + .dgrid export + GMM) echo "--sampler-method GMM $DG $REP" ;; + esac +} + +CSV=$T/ensemble2.csv +echo "cfg,arm,rep,rc,failed,lnL,sigma_lnL,neff,dgrid_lnL_mean,dgrid_lnL_max,secs" > $CSV +: > $T/ens2_progress.txt + +N=${1:-30} +for i in $(seq 1 $N); do + for cfg in A B D AV GMM; do + for arm in base cand; do + [ "$arm" = base ] && code=$W/tier3-base/MonteCarloMarginalizeCode/Code || code=$W/rvs-naming/MonteCarloMarginalizeCode/Code + o=$T/ens2/${cfg}_${arm}_$i; rm -rf $o; mkdir -p $o; cd $o + t0=$SECONDS + PATH=$code/bin:$RP:$PATH PYTHONPATH=$code timeout 900 $RP/python \ + $code/bin/integrate_likelihood_extrinsic_batchmode $COMMON $(cfg_opts $cfg) \ + --seed $((7000+i)) --output-file o > $o/ile.log 2>&1 + rc=$?; dt=$((SECONDS-t0)) + fa=$(grep -c 'FAILED ANALYSIS' $o/ile.log) + vals=$($RP/python -c " +import json,os +import numpy as np +try: + d=json.load(open('$o/o_0_integrator_status.json')) + a=[d.get('lnL'),d.get('sigma_lnL'),d.get('neff')] +except Exception: a=[float('nan')]*3 +g=[float('nan')]*2 +if os.path.exists('$o/o_0_.dgrid'): + try: + x=np.loadtxt('$o/o_0_.dgrid') + g=[float(np.mean(x[:,0])), float(np.max(x[:,0]))] + except Exception: pass +print(','.join(repr(v) for v in a+g))") + echo "$cfg,$arm,$i,$rc,$fa,$vals,$dt" >> $CSV + [ "$rc" = 0 -a "$fa" = 0 ] && rm -f $o/*.dat + done + done + echo "replicate $i done ($(date +%H:%M:%S))" >> $T/ens2_progress.txt +done +echo "ENSEMBLE2 COMPLETE" >> $T/ens2_progress.txt diff --git a/MonteCarloMarginalizeCode/Code/test/expensive_before_merging/integrators/tier3/tier3_ens3.sh b/MonteCarloMarginalizeCode/Code/test/expensive_before_merging/integrators/tier3/tier3_ens3.sh new file mode 100644 index 000000000..9023a9928 --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/test/expensive_before_merging/integrators/tier3/tier3_ens3.sh @@ -0,0 +1,61 @@ +#!/bin/bash +# Tier 3 ensemble v2. GPU ILE is NOT deterministic at fixed --seed, so this compares +# DISTRIBUTIONS. Arms interleaved within each replicate so machine drift hits both equally. +# All configs carry --vectorized --gpu => DiscreteFactoredLogLikelihoodViaArrayVectorNoLoop +# with xpy=cupy (proved by noloop_probe.py, 20 calls, scalar path 0). +T=/local/richard.oshaughnessy/tier3; D=$T/ILE-GPU-Paper/demos +RP=/cvmfs/software.igwn.org/conda/envs/igwn/bin +W=/home/richard.oshaughnessy/rift_O4d_junior_ralph/.claude/worktrees +export OMP_NUM_THREADS=1 MKL_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 CUDA_VISIBLE_DEVICES=1 + +COMMON="--n-chunk 10000 --time-marginalization --sim-xml $D/overlap-grid.xml.gz --reference-freq 100.0 --adapt-weight-exponent 0.1 --event-time 1000000014.236547946 --save-P 0.1 --cache-file $D/zero_noise.cache --fmin-template 10 --n-max 200000 --fmax 1700.0 --save-deltalnL inf --l-max 2 --n-eff 30 --approximant SEOBNRv4 --adapt-floor-level 0.1 --d-max 1000 --psd-file H1=$D/HLV-ILIGO_PSD.xml.gz --psd-file L1=$D/HLV-ILIGO_PSD.xml.gz --channel-name H1=FAKE-STRAIN --channel-name L1=FAKE-STRAIN --inclination-cosine-sampler --declination-cosine-sampler --data-start-time 1000000008 --data-end-time 1000000016 --inv-spec-trunc-time 0 --no-adapt-after-first --no-adapt-distance --srate 4096 --vectorized --gpu --n-events-to-analyze 1 --fairdraw-extrinsic-output" +REP="--mc-error-replicas 3 --mc-error-sigma-trigger 0.0" +DG="--export-marginal-distance-grid --internal-use-lnL" + +cfg_opts () { + case $1 in + A) echo "" ;; # GPU linear backend, plain + B) echo "$REP" ;; # linear backend + replica POOLING + D) echo "--interpolate-time True" ;; # cubic NoLoop time interpolation + AV) echo "--sampler-method AV $DG $REP" ;; # lnL family + pooling + .dgrid export + GMM) echo "--sampler-method GMM $DG $REP" ;; + esac +} + +CSV=$T/ensemble3.csv +echo "cfg,arm,rep,rc,failed,lnL,sigma_lnL,neff,dgrid_lnL_mean,dgrid_lnL_max,secs" > $CSV +: > $T/ens3_progress.txt + +N=${1:-30} +for i in $(seq 1 $N); do + for cfg in A B D AV GMM; do + for arm in base cand; do + [ "$arm" = base ] && code=$W/base-v2/MonteCarloMarginalizeCode/Code || code=$W/rvs-naming/MonteCarloMarginalizeCode/Code + o=$T/ens3/${cfg}_${arm}_$i; rm -rf $o; mkdir -p $o; cd $o + t0=$SECONDS + PATH=$code/bin:$RP:$PATH PYTHONPATH=$code timeout 900 $RP/python \ + $code/bin/integrate_likelihood_extrinsic_batchmode $COMMON $(cfg_opts $cfg) \ + --seed $((7000+i)) --output-file o > $o/ile.log 2>&1 + rc=$?; dt=$((SECONDS-t0)) + fa=$(grep -c 'FAILED ANALYSIS' $o/ile.log) + vals=$($RP/python -c " +import json,os +import numpy as np +try: + d=json.load(open('$o/o_0_integrator_status.json')) + a=[d.get('lnL'),d.get('sigma_lnL'),d.get('neff')] +except Exception: a=[float('nan')]*3 +g=[float('nan')]*2 +if os.path.exists('$o/o_0_.dgrid'): + try: + x=np.loadtxt('$o/o_0_.dgrid') + g=[float(np.mean(x[:,0])), float(np.max(x[:,0]))] + except Exception: pass +print(','.join(repr(v) for v in a+g))") + echo "$cfg,$arm,$i,$rc,$fa,$vals,$dt" >> $CSV + [ "$rc" = 0 -a "$fa" = 0 ] && rm -f $o/*.dat + done + done + echo "replicate $i done ($(date +%H:%M:%S))" >> $T/ens3_progress.txt +done +echo "ENSEMBLE3 COMPLETE" >> $T/ens3_progress.txt diff --git a/MonteCarloMarginalizeCode/Code/test/integrators/test_replica_pooling.py b/MonteCarloMarginalizeCode/Code/test/integrators/test_replica_pooling.py index b1e1d30eb..d49f31ec4 100644 --- a/MonteCarloMarginalizeCode/Code/test/integrators/test_replica_pooling.py +++ b/MonteCarloMarginalizeCode/Code/test/integrators/test_replica_pooling.py @@ -60,6 +60,44 @@ def test_pooling_reproduces_the_combined_evidence(): assert len(pooled['x']) == 12000 +def test_pooling_preserves_the_layout_of_a_combined_parameter(): + """A combined parameter is stored (ndim, N) under a TUPLE key -- the row axis is the SECOND + one, which is why `_rvs_len` reads shape[-1] there. + + Ravelling every column and concatenating on axis 0 turned that into one 1-D column of length + ndim*sum(N) while the scalar columns had sum(N) rows, so --mc-error-replicas handed the + exporter a malformed record: it unpacks the combined sky column as + + samples["latitude"], samples["longitude"] = samples[("declination", "right_ascension")] + + which then unpacks a 1-D array of 2*sum(N) values -- an abort, or two wrong columns. + """ + rng = numpy.random.RandomState(41) + sky = ("declination", "right_ascension") + reps = [] + for n, lnZ in ((300, 0.0), (200, 0.2)): + r = _replica(rng, n, lnZ, 1.0) + r[sky] = numpy.vstack([rng.uniform(-1.0, 1.0, size=n), rng.uniform(0.0, 6.0, size=n)]) + reps.append(r) + + pooled = DRV._pool_replica_rvs(reps, _S(), rep_lnZ=[0.0, 0.2]) + assert pooled[sky].shape == (2, 500), ( + "combined parameter pooled to shape {} rather than (ndim, sum(N))".format( + pooled[sky].shape)) + # the combined column must agree with the SCALAR columns about how many rows there are + assert len(pooled['log_integrand']) == 500 + assert DRV._rvs_len(pooled) == 500 + # and the exporter's unpack must give back each block's rows, in block order + lat, lon = pooled[sky] + assert numpy.allclose(lat, numpy.concatenate([reps[0][sky][0], reps[1][sky][0]])) + assert numpy.allclose(lon, numpy.concatenate([reps[0][sky][1], reps[1][sky][1]])) + + # the flat-block path rewrites joint_s_prior; the combined parameter must ride through it + # unchanged in layout as well + flat = DRV._pool_replica_rvs(reps, _S(), rep_lnZ=[0.0, 0.2], already_resampled=[True, False]) + assert flat[sky].shape == (2, 500) + + def test_max_neff_selection_would_export_the_collapsed_replica(): """Why selection by n_eff is the wrong rule: n_eff measures CONCENTRATION, not coverage, so a mode-collapsed replica scores highest and would be the one exported alongside a combined diff --git a/MonteCarloMarginalizeCode/Code/test/integrators/test_seeding_public_paths.py b/MonteCarloMarginalizeCode/Code/test/integrators/test_seeding_public_paths.py new file mode 100644 index 000000000..ab523e223 --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/test/integrators/test_seeding_public_paths.py @@ -0,0 +1,334 @@ +#!/usr/bin/env python +""" +--seed reproducibility, asserted through the PRODUCTION CALL PATHS. + +Companion to test_seeding_reproducibility.py, which pins the seeding HELPERS +(seed_everything, derived_rng, next_derived_rng). Those helper tests are not +enough, and the gap is not academic: with the helpers in place but the call +sites reverted to np.random.RandomState(seed) / np.random.default_rng(), the +whole of test/integrators/ stays green. A merge, a revert or a refactor could +therefore put the defect back with CI reporting nothing. + +So this file never calls a helper. It drives the public entry points a RIFT +driver actually calls -- MCSampler.bootstrap_from_samples / _from_gaussian / +_from_gaussian_mixture, build_warm_seed, ResamplingOracle.draw_simplified, and +the calmarg cal-realization draws -- and asserts on what they produce. + +Each entry point is checked for four properties, because different mutations +break different ones: + + 1. same seed -> identical output (the reproducibility fix) + 2. other seed -> different output (seeded, not frozen) + 3. successive calls in ONE run differ (not "seeded and self-correlated": + every intrinsic point must not + share one cloud) + 4. an explicit seed= argument still wins (the API promise is not taken over) +""" +import os +import subprocess +import sys +import tempfile + +import numpy as np +import pytest + +from RIFT.integrators import mcsamplerAdaptiveVolume as mcsamplerAV +from RIFT.integrators import seeding + + +NAMES = ["a", "b", "c", "d"] +NDIM = len(NAMES) +LO = np.zeros(NDIM) +HI = np.ones(NDIM) + + +@pytest.fixture(autouse=True) +def _restore_module_state(): + prior_seed = seeding._seed_used + prior_counters = dict(seeding._stream_counters) + yield + seeding._seed_used = prior_seed + seeding._stream_counters.clear() + seeding._stream_counters.update(prior_counters) + + +def _sampler(n_chunk=2000): + """Bound to the active backend exactly as the ILE driver does.""" + s = mcsamplerAV.MCSampler(n_chunk=n_chunk) + s.xpy = mcsamplerAV.xpy_default + s.identity_convert = mcsamplerAV.identity_convert + for name in NAMES: + s.add_parameter(name, pdf=None, left_limit=0.0, right_limit=1.0, + prior_pdf=lambda x: np.ones(np.shape(x)), + adaptive_sampling=True) + return s + + +def _spy_cloud(sampler): + """Capture the point cloud the warm start actually hands to the grid builder. + + Asserting on the returned _warm grid would be a weaker test: the grid is a + lossy function of the cloud, so two different cover clouds can bin to the + same live volume and a mutation would slip through. The cloud is the thing + the RNG produces, so that is what we compare. + """ + seen = [] + original = sampler._build_grid_from_points + + def _capture(X, *a, **kw): + seen.append(np.array(X, dtype=float, copy=True)) + return original(X, *a, **kw) + + sampler._build_grid_from_points = _capture + return seen + + +def _core_cloud(n=400, spread=0.02, seed=3): + """A concentrated, FULL-RANK seed cloud: cover_frac is what we are testing, + so the core must not be the thing that triggers the puff path.""" + return np.clip(0.5 + spread * np.random.RandomState(seed).randn(n, NDIM), 0.0, 1.0) + + +# --------------------------------------------------------------------------- +# bootstrap_from_samples -- the live defect: cover_frac defaults to 0.5 in all +# three driver warm-start options, so this cloud is drawn on every warm start. +# --------------------------------------------------------------------------- + +def _from_samples(core, seed=None): + s = _sampler() + seen = _spy_cloud(s) + s.bootstrap_from_samples(core, cover_frac=0.5, seed=seed) + assert seen, "bootstrap_from_samples did not reach the grid builder" + return seen[-1] + + +def test_bootstrap_from_samples_cover_cloud_reproduces_under_the_same_seed(): + core = _core_cloud() + seeding.seed_everything(101, verbose=False) + a1, a2 = _from_samples(core), _from_samples(core) + seeding.seed_everything(101, verbose=False) + b1, b2 = _from_samples(core), _from_samples(core) + seeding.seed_everything(202, verbose=False) + c1 = _from_samples(core) + + assert a1.shape == b1.shape + assert (a1 == b1).all(), "same --seed gave a different cover cloud" + assert (a2 == b2).all(), "the SECOND warm start of the run did not reproduce" + assert not (a1 == c1).all(), "a different --seed gave the identical cover cloud" + assert not (a1 == a2).all(), ( + "two warm starts in one run share a cover cloud; every intrinsic point " + "would be seeded with the same uniform points") + + +def test_bootstrap_from_samples_honours_an_explicit_seed(): + core = _core_cloud() + seeding.seed_everything(101, verbose=False) + a = _from_samples(core, seed=7) + seeding.seed_everything(202, verbose=False) + b = _from_samples(core, seed=7) + assert (a == b).all(), "an explicit seed= must not be overridden by --seed" + + +# --------------------------------------------------------------------------- +# bootstrap_from_gaussian / _from_gaussian_mixture -- the Fisher/flow oracle +# seeds. Same shape, and they also exercise multivariate_normal / multinomial, +# which the derived path serves from a Generator rather than a RandomState. +# --------------------------------------------------------------------------- + +def _from_gaussian(seed=None): + s = _sampler() + seen = _spy_cloud(s) + s.bootstrap_from_gaussian(0.5 * np.ones(NDIM), 0.01 * np.eye(NDIM), + n=500, seed=seed) + assert seen + return seen[-1] + + +def _from_mixture(seed=None): + s = _sampler() + seen = _spy_cloud(s) + s.bootstrap_from_gaussian_mixture( + [0.3 * np.ones(NDIM), 0.7 * np.ones(NDIM)], + [0.01 * np.eye(NDIM), 0.01 * np.eye(NDIM)], + n=500, seed=seed) + assert seen + return seen[-1] + + +@pytest.mark.parametrize("draw", [_from_gaussian, _from_mixture]) +def test_gaussian_warm_starts_reproduce_under_the_same_seed(draw): + seeding.seed_everything(101, verbose=False) + a1, a2 = draw(), draw() + seeding.seed_everything(101, verbose=False) + b1, b2 = draw(), draw() + seeding.seed_everything(202, verbose=False) + c1 = draw() + + assert (a1 == b1).all() and (a2 == b2).all(), "same --seed gave a different seed cloud" + assert not (a1 == c1).all(), "a different --seed gave the identical seed cloud" + assert not (a1 == a2).all(), "successive warm starts share one cloud" + + +@pytest.mark.parametrize("draw", [_from_gaussian, _from_mixture]) +def test_gaussian_warm_starts_honour_an_explicit_seed(draw): + seeding.seed_everything(101, verbose=False) + a = draw(seed=7) + seeding.seed_everything(202, verbose=False) + b = draw(seed=7) + assert (a == b).all() + + +# --------------------------------------------------------------------------- +# build_warm_seed -- the L0 rescue / sequential warm-start puff. This one was +# RandomState(0): never irreproducible, but --seed-INERT and self-correlated, +# so a replicate-seed study had its rescue arm frozen identically across arms. +# --------------------------------------------------------------------------- + +def _rank_deficient_pass(n=40): + """Points confined to a 1-D line: rank-deficient, so the puff path runs.""" + t = np.linspace(0.4, 0.6, n) + X = np.tile(0.5, (n, NDIM)) + X[:, 0] = t + lnL = 100.0 - 1e-3 * (t - 0.5) ** 2 + return X, lnL + + +def _puff(seed=None): + X, lnL = _rank_deficient_pass() + out, info = mcsamplerAV.build_warm_seed(X, lnL, LO, HI, list(range(NDIM)), + deltalnL=15.0, n_puff=300, seed=seed) + assert info.get('puffed'), "the puff path did not run; this test proves nothing" + return np.asarray(out, dtype=float) + + +def test_build_warm_seed_puff_depends_on_the_run_seed(): + seeding.seed_everything(101, verbose=False) + a1, a2 = _puff(), _puff() + seeding.seed_everything(101, verbose=False) + b1, b2 = _puff(), _puff() + seeding.seed_everything(202, verbose=False) + c1 = _puff() + + assert (a1 == b1).all() and (a2 == b2).all(), "same --seed gave a different puff" + assert not (a1 == c1).all(), ( + "the puff is the same under --seed 101 and --seed 202; a replicate-seed " + "study would have its rescue arm frozen across arms") + assert not (a1 == a2).all(), ( + "every intrinsic point of the run is puffed with the same deviates") + + +def test_build_warm_seed_honours_an_explicit_seed(): + seeding.seed_everything(101, verbose=False) + a = _puff(seed=7) + seeding.seed_everything(202, verbose=False) + b = _puff(seed=7) + assert (a == b).all() + + +# --------------------------------------------------------------------------- +# ResamplingOracle -- the skymap oracle (--skymap-file, default sampler). The +# RNG here was always seeded; what was not was WHICH block of the seeded stream +# reached which parameter, because the parameter list came out of a set of +# STRINGS and str hashing is salted per process. So this one can only be +# tested across processes: PYTHONHASHSEED has to actually differ. +# --------------------------------------------------------------------------- + +_ORACLE_PROBE = r""" +import io, contextlib +import numpy as np +from RIFT.integrators.seeding import seed_everything +from RIFT.integrators.unreliable_oracle.resampling import ResamplingOracle + +names = ["right_ascension", "declination", "distance", "psi", "phi_orb", "incl", "t_ref"] +o = ResamplingOracle() +for p in names: + o.add_parameter(p, pdf=None, left_limit=0.0, right_limit=1000.0) +ref = np.random.RandomState(0).uniform(size=(500, 2)) +with contextlib.redirect_stdout(io.StringIO()): + o.setup(reference_samples=ref, reference_params=["right_ascension", "declination"]) +seed_everything(101, verbose=False) +_, _, rv = o.draw_simplified(64) +print(",".join(o.other_params)) +print(" ".join("%.12g" % v for v in rv[:, o.params_ordered.index("distance")])) +""" + + +def _oracle_draw(hashseed): + env = dict(os.environ) + env["PYTHONHASHSEED"] = str(hashseed) + env["PYTHONPATH"] = os.pathsep.join(sys.path) + out = subprocess.check_output([sys.executable, "-c", _ORACLE_PROBE], + env=env, stderr=subprocess.DEVNULL) + order, draws = out.decode().strip().splitlines()[-2:] + return order, draws + + +def test_skymap_oracle_draws_do_not_depend_on_string_hash_salt(): + """Reachable from --skymap-file with the DEFAULT sampler, and it trains the + sampling prior (and seeds the AV live volume), so it reaches lnZ.""" + orders, draws = zip(*[_oracle_draw(h) for h in (1, 2, 3, 4, 5)]) + assert len(set(orders)) == 1, ( + "the uniform-fill parameter order still varies with PYTHONHASHSEED: %r" % (set(orders),)) + assert len(set(draws)) == 1, ( + "same --seed, different PYTHONHASHSEED, different draws -- %d distinct " + "results across 5 processes" % len(set(draws))) + + +def test_skymap_oracle_fill_order_follows_params_ordered(): + """Pins the property rather than the symptom: a future refactor that + reintroduces any unordered container fails here without needing 5 subprocesses.""" + import io + import contextlib + from RIFT.integrators.unreliable_oracle.resampling import ResamplingOracle + names = ["right_ascension", "declination", "distance", "psi", "phi_orb"] + o = ResamplingOracle() + for p in names: + o.add_parameter(p, pdf=None, left_limit=0.0, right_limit=1.0) + with contextlib.redirect_stdout(io.StringIO()): + o.setup(reference_samples=np.zeros((10, 2)), + reference_params=["right_ascension", "declination"]) + expect = [p for p in o.params_ordered if p not in set(o.valid_params)] + assert o.other_params == expect, "%r != %r" % (o.other_params, expect) + + +# --------------------------------------------------------------------------- +# calmarg cal realizations. The ILE driver always passes an explicit rng, so +# the rng=None fallback is a guard rather than a live defect -- but a guard with +# no test is how the hole comes back when a caller is added. +# --------------------------------------------------------------------------- + +def _envelope_file(path): + """Minimal calibration envelope: freq median_mag median_phase 16_* 84_*.""" + f = np.linspace(5.0, 2000.0, 40) + dat = np.column_stack([f, np.ones_like(f), np.zeros_like(f), + 0.95 * np.ones_like(f), -0.05 * np.ones_like(f), + 1.05 * np.ones_like(f), 0.05 * np.ones_like(f)]) + np.savetxt(path, dat) + + +def _prior_nodes(): + import RIFT.calmarg.generate_realizations as genr + with tempfile.TemporaryDirectory() as d: + _envelope_file(os.path.join(d, "H1.txt")) + ret = genr.draw_prior_realizations_with_nodes( + d, ["H1"], 4.0, 1.0 / 4096, 20.0, 1000.0, 4, 8, rng=None) + return np.asarray(ret["nodes"], dtype=float) + + +def test_calmarg_prior_node_draws_reproduce_when_the_caller_omits_rng(): + seeding.seed_everything(101, verbose=False) + a1, a2 = _prior_nodes(), _prior_nodes() + seeding.seed_everything(101, verbose=False) + b1, b2 = _prior_nodes(), _prior_nodes() + seeding.seed_everything(202, verbose=False) + c1 = _prior_nodes() + + assert (a1 == b1).all() and (a2 == b2).all(), "same --seed gave different cal nodes" + assert not (a1 == c1).any(), "a different --seed gave the identical cal nodes" + assert not (a1 == a2).any(), ( + "a second cal draw in one run repeats the first; growing the cal set " + "would append copies of draws already in it") + + +if __name__ == "__main__": + raise SystemExit(pytest.main([__file__, "-v"])) diff --git a/MonteCarloMarginalizeCode/Code/test/integrators/test_seeding_reproducibility.py b/MonteCarloMarginalizeCode/Code/test/integrators/test_seeding_reproducibility.py new file mode 100644 index 000000000..37ec3f181 --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/test/integrators/test_seeding_reproducibility.py @@ -0,0 +1,390 @@ +#!/usr/bin/env python +""" +Regression tests for --seed reproducibility, especially on GPU. + +Background (the bug these tests lock down): the ILE drivers implemented --seed +as a bare ``numpy.random.seed(opts.seed)``. The samplers, however, draw their +variates through the *array backend* they were configured with -- ``self.xpy`` +on an instance, ``xpy_default`` at module scope -- and that backend is cupy +whenever the job runs on a GPU. cupy keeps its own global generator per +device, which numpy.random.seed does not touch, so a GPU run was irreproducible +even when the user explicitly asked for a seed. + +Two byte-identical invocations of the ILE demo with --seed 101 returned +lnL = 75.857 (n_eff 1.02) and lnL = 71.687 (n_eff 2.04) -- a 4.17 nat spread. +Beyond being unbisectable, that silently invalidates any paired / +replicate-seed comparison design run on GPU, because the "same seed" arms are +not in fact paired. + +Seeding the RNGs turned out to be necessary but not sufficient. The adapted +sampling histogram (RIFT.likelihood.vectorized_general_tools.histogram) is +built with a weighted cupy.bincount, which accumulates through float atomicAdd; +the summation order is set by GPU thread scheduling, so the adapted CDF -- and +therefore every draw taken through it -- differed at the ULP level between +otherwise identical runs. seed_everything therefore also switches that one +reduction to a scheduler-independent summation order. + +The GPU-specific tests skip cleanly on a CPU-only machine; the rest of the file +exercises the parts that can be checked without a device. +""" + +import numpy as np +import pytest + +from RIFT.integrators import seeding +from RIFT.likelihood import vectorized_general_tools as vgt + + +try: + import cupy + cupy.array(0) # fails if cuda/cupy is not actually usable + HAS_GPU = True +except Exception: + HAS_GPU = False + +requires_gpu = pytest.mark.skipif(not HAS_GPU, reason="no usable cupy/GPU") + + +@pytest.fixture(autouse=True) +def _restore_module_state(): + """seed_everything mutates process-global state; put it back afterwards.""" + prior_det = vgt.DETERMINISTIC_REDUCTIONS + prior_seed = seeding._seed_used + prior_counters = dict(seeding._stream_counters) + yield + vgt.DETERMINISTIC_REDUCTIONS = prior_det + seeding._seed_used = prior_seed + seeding._stream_counters.clear() + seeding._stream_counters.update(prior_counters) + + +def test_seed_everything_reports_numpy_and_python(): + status = seeding.seed_everything(101, verbose=False) + assert status['numpy'] == 'seeded' + assert status['python'] == 'seeded' + assert seeding.get_seed() == 101 + + +def test_seed_everything_enables_deterministic_reductions(): + """The whole point: asking for a seed must also close the atomics hole.""" + vgt.DETERMINISTIC_REDUCTIONS = False + status = seeding.seed_everything(101, verbose=False) + assert status['gpu_reductions'] == 'deterministic' + assert vgt.DETERMINISTIC_REDUCTIONS is True + + +def test_seed_everything_absent_backend_is_not_an_error(): + """A CPU-only install has no cupy; that must be reported, not raised.""" + status = seeding.seed_everything(7, verbose=False) + for backend in ('cupy', 'torch'): + assert (status[backend] == 'seeded' + or status[backend] == 'absent' + or status[backend].startswith('failed:')), status[backend] + + +def test_derived_rng_is_reproducible_under_the_same_seed(): + """default_rng() takes OS entropy, so paths that build their own Generator + (the calibration error probe, the adaptive cal draw growth) escaped --seed + entirely and could change n_cal / the cal realizations run to run.""" + seeding.seed_everything(101, verbose=False) + a = seeding.derived_rng('calmarg.error_probe').standard_normal(64) + seeding.seed_everything(101, verbose=False) + b = seeding.derived_rng('calmarg.error_probe').standard_normal(64) + seeding.seed_everything(202, verbose=False) + c = seeding.derived_rng('calmarg.error_probe').standard_normal(64) + + assert (a == b).all(), "same seed did not reproduce the derived stream" + assert not (a == c).all(), "different seeds gave an identical stream" + + +def test_derived_rng_streams_do_not_collide(): + """Reproducible must not mean 'everyone draws the same numbers': distinct + call sites, distinct counters, and the seed's own default_rng(seed) stream + all have to stay independent, or growing the cal draw set would just append + copies of draws already in it.""" + seeding.seed_everything(101, verbose=False) + probe0 = seeding.derived_rng('calmarg.error_probe', 0).standard_normal(64) + probe1 = seeding.derived_rng('calmarg.error_probe', 1).standard_normal(64) + extra0 = seeding.derived_rng('calmarg.extra_draws', 0).standard_normal(64) + plain = np.random.default_rng(101).standard_normal(64) + + for lhs, rhs, what in ((probe0, probe1, "counters"), + (probe0, extra0, "stream names"), + (probe0, plain, "derived vs default_rng(seed)")): + assert not (lhs == rhs).any(), "%s share draws" % what + + +def test_derived_rng_is_unseeded_when_the_run_was_not_seeded(): + """No --seed must still mean fresh entropy, not a fixed fallback stream.""" + seeding._seed_used = None + a = seeding.derived_rng('calmarg.error_probe').standard_normal(64) + b = seeding.derived_rng('calmarg.error_probe').standard_normal(64) + assert not (a == b).any() + + +def test_derived_rng_stream_label_is_stable_across_processes(): + """The label must not come from hash(): str hashing is salted per process, + so a 'stable' identifier built that way would silently drift between the + two runs the user is trying to compare.""" + seeding.seed_everything(101, verbose=False) + got = seeding.derived_rng('calmarg.error_probe', 3).standard_normal(8) + import zlib + expect = np.random.default_rng( + [101, zlib.crc32(b'calmarg.error_probe'), 3]).standard_normal(8) + assert (got == expect).all() + + +def test_next_derived_rng_advances_so_repeated_calls_do_not_share_draws(): + """A call site inside a loop (one warm start per intrinsic point, one bootstrap + per integral) must not hand back the same numbers every time. Reproducible and + self-correlated is WORSE than unseeded: it would give every intrinsic point the + identical uniform coverage cloud.""" + seeding.seed_everything(101, verbose=False) + a = seeding.next_derived_rng('unit.test').standard_normal(64) + b = seeding.next_derived_rng('unit.test').standard_normal(64) + assert not (a == b).any(), "successive calls to one stream share draws" + # and they are the counter-0/counter-1 streams, i.e. still derived, not entropy + seeding.seed_everything(101, verbose=False) + assert (a == seeding.derived_rng('unit.test', 0).standard_normal(64)).all() + assert (b == seeding.derived_rng('unit.test', 1).standard_normal(64)).all() + + +def test_next_derived_rng_repeats_the_whole_sequence_under_the_same_seed(): + """What --seed actually promises: two identical INVOCATIONS agree. Re-seeding + restarts the counters, so run 2 replays run 1's sequence.""" + seeding.seed_everything(101, verbose=False) + run1 = [seeding.next_derived_rng('unit.test').standard_normal(16) for _ in range(3)] + seeding.seed_everything(101, verbose=False) + run2 = [seeding.next_derived_rng('unit.test').standard_normal(16) for _ in range(3)] + seeding.seed_everything(202, verbose=False) + run3 = [seeding.next_derived_rng('unit.test').standard_normal(16) for _ in range(3)] + + for x, y in zip(run1, run2): + assert (x == y).all(), "same seed did not replay the sequence" + for x, z in zip(run1, run3): + assert not (x == z).any(), "different seeds gave an identical sequence" + + +def test_next_derived_rng_is_unseeded_when_the_run_was_not_seeded(): + """No --seed must still mean fresh entropy, not a fixed fallback sequence.""" + seeding._seed_used = None + seeding._stream_counters.clear() + a = seeding.next_derived_rng('unit.test').standard_normal(64) + seeding._stream_counters.clear() + b = seeding.next_derived_rng('unit.test').standard_normal(64) + assert not (a == b).any() + + +def test_av_warm_start_cover_cloud_is_reproducible_under_seed(): + """The one live likelihood-feeding hole this pass closes. + + The bootstrap_from_* family drew its uniform coverage cloud from + RandomState(None) -- fresh OS entropy, unreachable by seed_everything -- and + the driver's warm-start options default cover_frac to 0.5, so the cloud IS + drawn. It shapes the AV live volume, hence the draws, hence lnZ: two runs + with the same --seed built different live volumes. + """ + from RIFT.integrators import mcsamplerAdaptiveVolume as av + + def draw(): + rng = av._warm_seed_rng(None, 'av.bootstrap_from_samples.cover') + return rng.uniform(np.zeros(4), np.ones(4), size=(32, 4)) + + seeding.seed_everything(101, verbose=False) + a1, a2 = draw(), draw() + seeding.seed_everything(101, verbose=False) + b1, b2 = draw(), draw() + seeding.seed_everything(202, verbose=False) + c1, _ = draw(), draw() + + assert (a1 == b1).all() and (a2 == b2).all(), "same seed gave a different cover cloud" + assert not (a1 == c1).any(), "different seeds gave the same cover cloud" + assert not (a1 == a2).any(), "successive warm starts share one cover cloud" + + +def test_av_warm_start_explicit_seed_still_wins(): + """An explicit integer seed is an API promise of its own; deriving from --seed + must not take it over.""" + from RIFT.integrators import mcsamplerAdaptiveVolume as av + seeding.seed_everything(101, verbose=False) + got = av._warm_seed_rng(7, 'av.bootstrap_from_samples.cover').uniform(0, 1, 16) + expect = np.random.RandomState(7).uniform(0, 1, 16) + assert (got == expect).all() + + +def test_bootstrap_lnZ_quantiles_is_reproducible_and_leaves_numpy_alone(): + """The lnZ_ci90 diagnostic is reporting-only, so it gets a stream of its own: + reproducible under --seed, and NOT drawn from numpy's global RNG -- the samplers + draw from that, so spending draws here would move lnL, which a diagnostic is + never allowed to do.""" + from RIFT.integrators.statutils import bootstrap_lnZ_quantiles + + lw = np.log(np.random.RandomState(0).exponential(1.0, 500)) + + def run(): + np.random.seed(3) + before = np.random.random(4) # position in the global stream + q = bootstrap_lnZ_quantiles(lw) + after = np.random.random(4) # must be unaffected by the bootstrap + return q, before, after + + seeding.seed_everything(101, verbose=False) + qa, ba, aa = run() + seeding.seed_everything(101, verbose=False) + qb, bb, ab = run() + seeding.seed_everything(202, verbose=False) + qc, _, _ = run() + + assert qa is not None + assert (qa == qb).all(), "same seed gave a different bootstrap interval" + assert not (qa == qc).any(), "different seeds gave an identical bootstrap interval" + assert (ba == bb).all() and (aa == ab).all() + # the global stream must be exactly where it would be with no bootstrap at all + np.random.seed(3) + np.random.random(4) + assert (aa == np.random.random(4)).all(), "the diagnostic consumed numpy's global RNG" + + +def test_calmarg_rng_fallback_is_derived_not_entropy(): + """The ILE driver always passes an explicit rng to the cal draw helpers, so this + is a guard, not a live defect: a NEW caller that forgets must not silently + reintroduce an unseeded likelihood.""" + from RIFT.calmarg.generate_realizations import _default_cal_rng + + seeding.seed_everything(101, verbose=False) + a = _default_cal_rng('unit.cal').standard_normal(32) + seeding.seed_everything(101, verbose=False) + b = _default_cal_rng('unit.cal').standard_normal(32) + seeding.seed_everything(202, verbose=False) + c = _default_cal_rng('unit.cal').standard_normal(32) + assert (a == b).all() + assert not (a == c).any() + + +def test_deterministic_histogram_agrees_with_atomic_branch(): + """The reproducible branch must be the same histogram, not a different one.""" + rng = np.random.RandomState(0) + samples = rng.rand(50000) + weights = rng.exponential(1.0, 50000) + + vgt.DETERMINISTIC_REDUCTIONS = False + h_atomic = vgt.histogram(samples, 100, xpy=np, weights=weights) + vgt.DETERMINISTIC_REDUCTIONS = True + h_det = vgt.histogram(samples, 100, xpy=np, weights=weights) + + assert h_det.shape == h_atomic.shape == (100,) + np.testing.assert_allclose(h_det, h_atomic, rtol=1e-10) + + +def test_deterministic_histogram_accuracy_on_peaked_weights(): + """Pin the known accuracy cost of prefix-sum differencing. + + A bin total is the difference of two partial sums both of order the grand + total, so a bin's relative error is amplified by (total / bin). With + exp(lnL)-peaked weights that measured 5e-11 vs an exact rational reference + (per-bin atomics manage 3e-14). That is fine for a proposal density, but it + should not be allowed to get quietly worse. + """ + from fractions import Fraction + + n_bins = 100 + rng = np.random.RandomState(5) + idx = rng.randint(0, n_bins, 100000).astype(np.int32) + wts = np.exp(rng.normal(0, 8, 100000)) # spans ~decades, like exp(lnL) + + acc = [Fraction(0)] * n_bins + for i, w in zip(idx, wts): + acc[int(i)] += Fraction(float(w)) + ref = np.array([float(a) for a in acc]) + + vgt.DETERMINISTIC_REDUCTIONS = True + got = vgt._bincount_weighted(idx, wts, n_bins, np) + + rel = np.abs(got - ref) / np.abs(ref) + assert rel.max() < 1e-9, "deterministic bincount accuracy regressed: %g" % rel.max() + + +def test_deterministic_histogram_handles_the_unweighted_branch(): + """Unweighted calls pass a read-only broadcast_to view; it must be reorderable.""" + rng = np.random.RandomState(0) + samples = rng.rand(5000) + vgt.DETERMINISTIC_REDUCTIONS = True + h = vgt.histogram(samples, 50, xpy=np) + assert h.shape == (50,) + np.testing.assert_allclose(h.sum(), 50.0, rtol=1e-10) + + +@requires_gpu +def test_cupy_weighted_bincount_is_nondeterministic(): + """Documents WHY the deterministic branch exists. + + If cupy ever makes weighted bincount deterministic this test starts + failing, which is the signal to revisit -- not a reason to delete the + deterministic branch, since RIFT must work with older cupy too. + """ + rng = np.random.RandomState(0) + idx = cupy.asarray(rng.randint(0, 100, 200000).astype(np.int32)) + wts = cupy.asarray(rng.exponential(1.0, 200000)) + ref = cupy.asnumpy(cupy.bincount(idx, minlength=100, weights=wts)) + differs = any( + not (cupy.asnumpy(cupy.bincount(idx, minlength=100, weights=wts)) == ref).all() + for _ in range(32) + ) + assert differs, "cupy weighted bincount now looks deterministic on this build" + + +@requires_gpu +def test_deterministic_gpu_histogram_is_bit_reproducible(): + """The fix, at the level of the reduction it repairs.""" + rng = np.random.RandomState(0) + samples = cupy.asarray(rng.rand(200000)) + weights = cupy.asarray(rng.exponential(1.0, 200000)) + + vgt.DETERMINISTIC_REDUCTIONS = True + ref = cupy.asnumpy(vgt.histogram(samples, 100, xpy=cupy, weights=weights)) + for _ in range(8): + again = cupy.asnumpy(vgt.histogram(samples, 100, xpy=cupy, weights=weights)) + assert (again == ref).all(), "deterministic GPU histogram is not bit-stable" + + +@requires_gpu +def test_deterministic_gpu_histogram_handles_the_unweighted_branch(): + """cupy.broadcast_to is also read-only and zero-stride; the deterministic + path reorders the weights, so this branch must still work on device.""" + rng = np.random.RandomState(0) + samples = cupy.asarray(rng.rand(20000)) + vgt.DETERMINISTIC_REDUCTIONS = True + h = cupy.asnumpy(vgt.histogram(samples, 50, xpy=cupy)) + assert h.shape == (50,) + np.testing.assert_allclose(h.sum(), 50.0, rtol=1e-10) + + +@requires_gpu +def test_gpu_sampler_draws_are_reproducible_under_seed_everything(): + """End-to-end at the draw level: same seed -> same cupy stream, and a + different seed must still give a different stream (seeded, not frozen).""" + seeding.seed_everything(101, verbose=False) + a = cupy.asnumpy(cupy.random.uniform(0.0, 1.0, 10000)) + seeding.seed_everything(101, verbose=False) + b = cupy.asnumpy(cupy.random.uniform(0.0, 1.0, 10000)) + seeding.seed_everything(202, verbose=False) + c = cupy.asnumpy(cupy.random.uniform(0.0, 1.0, 10000)) + + assert (a == b).all(), "same seed did not reproduce the cupy stream" + assert not (a == c).all(), "different seeds gave an identical stream" + + +@requires_gpu +def test_numpy_seed_alone_does_not_reproduce_the_gpu_stream(): + """The original defect, stated as a test: numpy.random.seed is not enough.""" + np.random.seed(101) + a = cupy.asnumpy(cupy.random.uniform(0.0, 1.0, 10000)) + np.random.seed(101) + b = cupy.asnumpy(cupy.random.uniform(0.0, 1.0, 10000)) + assert not (a == b).all(), ( + "numpy.random.seed now appears to seed cupy too; if so the driver's " + "old behaviour was sufficient and this file needs revisiting") + + +if __name__ == "__main__": + raise SystemExit(pytest.main([__file__, "-v"])) diff --git a/MonteCarloMarginalizeCode/Code/test/jax/debug_jax_vs_cupy_inj.py b/MonteCarloMarginalizeCode/Code/test/jax/debug_jax_vs_cupy_inj.py index 3ac880125..2839ffa5c 100644 --- a/MonteCarloMarginalizeCode/Code/test/jax/debug_jax_vs_cupy_inj.py +++ b/MonteCarloMarginalizeCode/Code/test/jax/debug_jax_vs_cupy_inj.py @@ -18,6 +18,7 @@ os.path.expanduser("~/RIFT_roboto_paper/analyses/slowrot_finite-size")) sys.path.insert(0, _FSLIB) import slowrot_fs_lib as fslib +import RIFT.likelihood.factored_likelihood as flib import RIFT.likelihood.factored_likelihood_freqresponse as flfr import RIFT.likelihood.slowrot_freqresponse as sfr import RIFT.lalsimutils as lsu @@ -48,7 +49,7 @@ def main(): Psig = fslib._base_params(src, dist, deltaT, deltaF) pk = fslib._pack_finite(fslib.EVENT_TIME, t_window, Psig, dd, pd, arm, src.fmax, QMAX) for iwh in (0.03, 0.06): - Nw = int(iwh / deltaT); tvals = np.arange(-Nw, Nw) * deltaT + tvals = flib.marginalization_time_grid(iwh, deltaT) # cupy/numpy NoLoop at truth (nearest + cubic) Pv = Psig.manual_copy() Pv.phi = np.array([rt]); Pv.theta = np.array([dt_]); Pv.psi = np.array([pt]) diff --git a/MonteCarloMarginalizeCode/Code/test/jax/demo_real_data.py b/MonteCarloMarginalizeCode/Code/test/jax/demo_real_data.py index 974b32d99..04ebbeb74 100644 --- a/MonteCarloMarginalizeCode/Code/test/jax/demo_real_data.py +++ b/MonteCarloMarginalizeCode/Code/test/jax/demo_real_data.py @@ -232,7 +232,11 @@ def task_equality(params, data_dir, frame_dir, opts): Pv.dist = distMpc * PC * 1e6 Pv.tref = float(fid) Pv.deltaT = 1.0 / opts.srate - tvals = np.linspace(-iwh, iwh, int(2 * iwh / Pv.deltaT)) + # Same grid on both sides -- see test_jax_endtoend: an independently + # built linspace grid starts a fraction of a sample away from the + # arange(-Nw,Nw)*deltaT grid inside ``data`` and desynchronises the + # per-detector integer window offsets. + tvals = np.asarray(data.tvals) lnL_ref = FL.DiscreteFactoredLogLikelihoodViaArrayVectorNoLoop( tvals, Pv, ln, rh, cu, cv, ep, Lmax=opts.l_max, xpy=np) diff --git a/MonteCarloMarginalizeCode/Code/test/jax/test_jax_endtoend.py b/MonteCarloMarginalizeCode/Code/test/jax/test_jax_endtoend.py index 2a6bb0808..9dcb4738d 100644 --- a/MonteCarloMarginalizeCode/Code/test/jax/test_jax_endtoend.py +++ b/MonteCarloMarginalizeCode/Code/test/jax/test_jax_endtoend.py @@ -110,8 +110,32 @@ def main(): S = 40 Pvec, distMpc = build_Pvec(P, S, fiducial_epoch, P.deltaT) - tvals = np.linspace(-integration_window_half, integration_window_half, - int(2 * integration_window_half / P.deltaT)) + # Compare like with like: hand the numpy reference the SAME time grid the + # JAX data object was built with. Both paths consume only tvals[0] and + # len(tvals) -- each steps by P.deltaT and integrates with dx=deltaT -- so a + # *sub-sample* difference in tvals[0] rounds ifirst to a DIFFERENT integer + # sample for a sky-dependent subset of samples, and a different subset per + # detector, which misaligns the coherent network sum by one sample. Building + # an independent grid here therefore reported a ~67.8 nat "mismatch" that was + # an artifact OF THIS HARNESS. + # + # As of issue #146 the same 67.8 nats is no longer ALSO a live disagreement + # between the two production drivers: both + # bin/integrate_likelihood_extrinsic_batchmode (all ten window-grid sites) and + # the jax_ile wrapper now call factored_likelihood.marginalization_time_grid(). + # test/jax/test_tvals_grid_convention.py is what asserts that, at five sample + # rates including 16384; this test still holds the grid fixed and tests only + # that the two LIKELIHOODS agree, at 4096. + tvals = np.asarray(data.tvals) + # Pin the builder's convention by VALUE, independently reconstructed. (An + # `assert len(tvals) == data.npts` would be a tautology -- core.py sets + # npts = len(tvals) -- and would not have caught the original defect + # either, since both grids had length 614 and differed only in offset.) + _npts = int(2 * integration_window_half / P.deltaT) + np.testing.assert_allclose(tvals, (np.arange(_npts) - _npts // 2) * P.deltaT, + rtol=0, atol=0, + err_msg="build_data_from_precompute tvals convention changed; " + "a linspace grid here silently misaligns ifirst") lnL_ref = FL.DiscreteFactoredLogLikelihoodViaArrayVectorNoLoop( tvals, Pvec, lookupNKDict, rholmsArrayDict, ctUArrayDict, ctVArrayDict, @@ -175,5 +199,12 @@ def main(): print("\nEND-TO-END TEST PASSED") +def test_endtoend(): + """pytest entry point. Without this the file defines no test_* function and + `pytest test/jax/` collects ZERO items from it and exits 5 ("no tests ran"), + which reads as green -- which is how this test stayed broken for a month.""" + main() + + if __name__ == "__main__": main() diff --git a/MonteCarloMarginalizeCode/Code/test/jax/test_jax_slowrot.py b/MonteCarloMarginalizeCode/Code/test/jax/test_jax_slowrot.py index 4448c5704..fcc6d379b 100644 --- a/MonteCarloMarginalizeCode/Code/test/jax/test_jax_slowrot.py +++ b/MonteCarloMarginalizeCode/Code/test/jax/test_jax_slowrot.py @@ -7,10 +7,21 @@ (a) JAX interp="nearest" reproduces the cupy/numpy NoLoop references DiscreteFactoredLogLikelihoodViaArrayVectorNoLoopWithRotation (rotation) DiscreteFactoredLogLikelihoodFreqResponseNoLoop (freqresponse) - on the SAME packed data, to ~1e-13. + on the SAME packed data, to ~1e-13. Rotation runs at BOTH p_max=0 (Path A) and + p_max=1 (Path B) -- see check_rotation() for why Path B is a distinct code path + for the arrival-time post-phase and not just a wider bank. p_max=2 is NOT run: the + bank carries |ntilde| <= 2 + p_max (issue #142), so it would be 27 bands / 729 U/V + cross terms in the precompute (vs 14 / 196 at p_max=1 and 5 / 25 at p_max=0), which + roughly triples this file's runtime for no branch p_max=1 does not already exercise + -- the same duplicate-m scatter-add and within-p V reflection. (b) interp="linear" gradient (distance-marginalized, smooth) vs finite diff ~1e-6. (c) jit / vmap / grad / hessian all execute and stay finite. +Agreement with the NoLoop (gate a) is NECESSARY BUT NOT SUFFICIENT for the rotation path: a +likelihood that drops the arrival-time post-phase from BOTH terms is perfectly self-consistent +and still ~95 nats wrong. The VALUE is pinned separately, by the Cauchy-Schwarz / explicit-model +ladder in test/jax/test_jax_slowrot_cauchy_schwarz.py. + Run: PYTHONPATH=<...>/Code taskset -c 0-3 python test/jax/test_jax_slowrot.py """ @@ -83,12 +94,29 @@ def _finite_diff_grad(fn, x0, h=1e-4): return g -def check_rotation(): - print("\n=== ROTATION (Path A, p_max=0) ===") +def check_rotation(p_max=0): + """Gate (a) for the rotation bank at the given ``p_max``. + + p_max=0 is Path A (amplitude drift only, a=(0,n)); p_max>=1 is Path B, which adds the + delay-derivative bands a=(p,n). Path B is not a cosmetic extension of this port: several + ``p`` then share the same sidereal harmonic ``n``, so the post-phase buckets + (m = n_a' - n_a) collect (a,a') pairs from DIFFERENT p -- 4-20 pairs per bucket at + p_max=1 vs 1-5 at p_max=0 -- and the V-term reflection (p,n)->(p,-n) has to resolve + within p. Neither branch is exercised at p_max=0. + """ + print("\n=== ROTATION (Path %s, p_max=%d) ===" % ("A" if p_max == 0 else "B", p_max)) ri, ct, ctV, rho, meta = flwr.PrecomputeLikelihoodTermsWithRotation( event_time, t_window, Psig, data_dict, psd_dict, Lmax, fmax, - harmonics=HARM, p_max=0, f_sidereal=flwr.F_SIDEREAL, analyticPSD_Q=True, + harmonics=HARM, p_max=p_max, f_sidereal=flwr.F_SIDEREAL, analyticPSD_Q=True, verbose=False, quiet=True, skip_interpolation=True) + # NOT `len(HARM)`: the precompute widens the requested harmonics to + # |ntilde| <= 2 + p_max, because that is what rotation_coefficients actually populates + # (issue #142). So HARM=(-2..2) gives 5 bands per p at p_max=0 but 7 at p_max=1. + # Asserting len(HARM) here hard-coded the TRUNCATED bank and had to be corrected. + n_bands = 2 * flwr.required_harmonic_width(p_max) + 1 + assert len(meta['harmonics']) == n_bands, \ + "harmonics not widened to 2+p_max: %s" % (meta['harmonics'],) + assert len(meta['a_list']) == (p_max + 1) * n_bands, "unexpected a_list size" lk, rbn, ubn, vbn, ep = flwr.pack_rotation_arrays(meta, rho, ct, ctV) Pv = _P_vec() lnL_ref = flwr.DiscreteFactoredLogLikelihoodViaArrayVectorNoLoopWithRotation( @@ -102,11 +130,28 @@ def check_rotation(): err = np.max(np.abs(lnL_ref[fin] - lnL_jax[fin])) rel = np.max(np.abs(lnL_ref[fin] - lnL_jax[fin]) / (1 + np.abs(lnL_ref[fin]))) print("(a) nearest vs numpy NoLoop-with-rotation: max|abs| = %.3e max|rel| = %.3e" - " (%d samples)" % (err, rel, fin.sum())) - assert rel < 1e-10, "rotation nearest mismatch (rel) %g" % rel + " (%d samples, A=%d bands)" % (err, rel, fin.sum(), len(meta['a_list']))) + # Both sides apply the arrival-time post-phase C~_a = C_a exp(i n_a Omega (t - tref)) + # (factored_likelihood_with_rotation.rotation_post_phase) to the data term AND the model + # norm, and the JAX accumulator uses the same arrival samples the gather uses, so this is + # an exact algebraic identity -- only floating-point reassociation separates them. + ROT_TOL = 1e-10 + assert rel < ROT_TOL, "rotation nearest mismatch (rel) %g at p_max=%d" % (rel, p_max) return data +def test_rotation_path_a(): + # check_ad as well as check_rotation: the __main__ block below runs both, and a + # pytest entry point that ran only half of it would leave the AD/jit/vmap/hessian + # gates uncollected -- green in CI, exercised only when someone runs the file by + # hand. See .travis/test-jax.sh. + check_ad(check_rotation(p_max=0), "rotation p_max=0") + + +def test_rotation_path_b(): + check_ad(check_rotation(p_max=1), "rotation p_max=1") + + def check_freqresponse(): print("\n=== FREQRESPONSE (Path D, Qmax=%d, L=%.0f m) ===" % (Qmax, L_CE)) bk = flfr.PrecomputeLikelihoodTermsFreqResponse( @@ -134,6 +179,10 @@ def check_freqresponse(): return data +def test_freqresponse(): + check_ad(check_freqresponse(), "freqresponse") + + def check_ad(data, tag): print("--- AD checks (%s) ---" % tag) # (c) jit + vmap of the fixed-distance likelihood @@ -164,8 +213,11 @@ def check_ad(data, tag): if __name__ == "__main__": - d_rot = check_rotation() - check_ad(d_rot, "rotation") - d_fr = check_freqresponse() - check_ad(d_fr, "freqresponse") + # Call the pytest entry points, not the check_* helpers, so the __main__ path and + # the collected path cannot drift apart. + test_rotation_path_a() + test_rotation_path_b() + test_freqresponse() print("\nSLOWROT + FREQRESPONSE JAX VALIDATION PASSED") + print(" (agreement with the NoLoop is necessary, not sufficient: the rotation VALUE is") + print(" pinned by test/jax/test_jax_slowrot_cauchy_schwarz.py.)") diff --git a/MonteCarloMarginalizeCode/Code/test/jax/test_jax_slowrot_cauchy_schwarz.py b/MonteCarloMarginalizeCode/Code/test/jax/test_jax_slowrot_cauchy_schwarz.py new file mode 100644 index 000000000..492f52587 --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/test/jax/test_jax_slowrot_cauchy_schwarz.py @@ -0,0 +1,525 @@ +"""test_jax_slowrot_cauchy_schwarz : the JAX rotation likelihood must be a real - (1/2). + +The JAX twin of ``RIFT/likelihood/test_slowrot_cauchy_schwarz.py``, which guards the numpy/cupy +NoLoop. Read that file first -- the physics and the reason the arrival offset must be nonzero are +documented there and are not repeated here. + +WHY THIS FILE EXISTS SEPARATELY FROM test_jax_slowrot.py. That file's gate (a) checks the JAX +path AGREES with the NoLoop. Necessary, not sufficient: a likelihood that drops the arrival-time +post-phase from BOTH terms is self-consistent, satisfies Cauchy-Schwarz, and is badly wrong. +Agreement pins the two implementations to each other; only a bound and an independently +constructed model pin the VALUE. + +Four checks, in order (the later ones are worthless without the earlier ones): + + (A) TEETH. With the modulation switched off (f_sidereal=0) against the SAME rotating data the + deficit must be LARGE, or this configuration does not exercise rotation and (B),(C) would + pass on an untested code path. (A) compares the evaluator against ITSELF at f_sidereal=0, + so it guards the CONFIGURATION, not the post-phase -- a defect common to both arms cancels. + (B) THE BOUND. No sampled lnL(t) may exceed (1/2). The data IS the exact model at the + p_max under test (see data_for), so at the true arrival sample lnL sits ON the bound. + NOTE THE SIGN: (B) PRINTS a deficit, 0.5 - max lnL, and ASSERTS on the overshoot, + max lnL - 0.5. They are negatives of each other; a violation is a POSITIVE overshoot. + (C) THE MECHANISM. lnL(t) must equal a directly constructed - (1/2) for the model + the likelihood implies, built explicitly in the time domain. (B) can only detect a + violation; (C) pins the value. Its reference shifts the MODULATED template circularly and + repairs the phase with rotation_post_phase, because that is what the bank does; modulating + on the unrolled grid instead disagrees on the samples that wrap the segment boundary. + (D) a cross-check of the JAX lnL(t) against the numpy NoLoop on the same bank. + +Both rungs run: p_max=0 (Path A) and p_max=1 (Path B). Path B is a distinct code path, not a +wider bank -- several ``p`` share a sidereal harmonic ``n``, so the post-phase buckets +``m = n_a' - n_a`` collect (a,a') pairs from DIFFERENT p, and the V-term reflection +``(p,n)->(p,-n)`` has to resolve within p. p_max=2 is not run by default: it is a 27-band bank +whose 729 U/V cross terms dominate the precompute and it adds no new branch. config_for() +RAISES for it rather than guessing a rate: give it a CONFIG entry, with the measurement +justifying whatever tolerance it needs, before calling run_ladder(p_max=2). + +THE ARRIVAL OFFSET MUST BE NONZERO. The post-phase is exp(i n Omega (t - tref)); at t = tref it +is the identity and a broken implementation passes every check. The data is therefore placed at +the detector's true geometric arrival time. + +Path B runs at a higher rotation rate than Path A, and that is (A)'s requirement alone: the static +deficit grows with Omega, and at Path A's rate it falls below MIN_STATIC_DEFICIT. + +DO NOT "fix" a failure here by widening TOL_BOUND, TOL_DIRECT_* or MIN_STATIC_DEFICIT. Every +gate has orders of margin over what it catches; a failure is a defect, not a tolerance being +tight. + +TWO THINGS THIS LADDER DELIBERATELY DOES NOT CLAIM. It does not claim the p-expansion CONVERGES +here -- it does not, and that is fine, because the data is built as the exact model at the p_max +under test, so what is validated is that the evaluator computes lnL for the model the bank +implies. And it does not claim the bank's CIRCULARLY shifted model matches a physically modulated +one; they differ on the wrapped samples, which is a property of FFT-correlation banks that a +Path-B production run inherits, and no assert here covers it. + +PORTABILITY -- READ BEFORE FILING A FINDING ON A DIGIT PRINTED BY THIS FILE. Numbers here are +bit-stable within a host but NOT across CPU families: cells built from a near-total cancellation +of large numbers keep only a couple of significant figures, and those figures differ between +Intel and AMD. Do NOT "fix" a cell because your host differs, and do not derive an argument from +a digit that is not stable. THERE IS NO SHORTCUT FOR CLASSIFYING A NEW CELL -- measure it on both +families. A digit-count rule of the form 16 - log10(operand/result) was tried and REFUTED: it +over-predicts stability and cannot separate cells that split from cells that do not. + +The spread is harmless ONLY BECAUSE no gate here is a tolerance on one of these numbers -- +every assert compares against a TOL_* constant, not against a recorded digit. Pinning any +host-split cell as an expected value would make the spread live and this suite host-dependent. +If you must pin one, use a tolerance that survives both families, or state the host. + +DO NOT ATTACH A MECHANISM TO A MEASURED TABLE WITHOUT CHECKING IT AT MORE THAN ONE ROW. Two +explanations for the split were adopted on partial evidence and later withdrawn; the disconfirming +row was already in the table both times. + +Evidence, sweeps, mutation tables and measured impact: PRs #117 and #163, and +RIFT_roboto_paper analyses/slowrot_bound_violation/ + analyses/slowrot_nyquist_bin/NOTE.md. + +Run: JAX_PLATFORMS=cpu PYTHONPATH=/MonteCarloMarginalizeCode/Code \\ + python test/jax/test_jax_slowrot_cauchy_schwarz.py +""" +from __future__ import print_function, division +import numpy as np + +import jax +jax.config.update("jax_enable_x64", True) + +import lal +import lalsimulation as lalsim +import RIFT.lalsimutils as lsu +import RIFT.likelihood.factored_likelihood as fl +import RIFT.likelihood.factored_likelihood_with_rotation as flwr +import RIFT.likelihood.slowrot_response as srr + +from RIFT.likelihood.jax_ile.banded import build_rotation_data +# _accumulate_unit is the (private) kernel that produces the per-time-bin kappa and rho^2. +# The public entry points marginalize over t, which would smear exactly the arrival-time +# dependence this file is about; every sampled lnL_t below is a genuine lnL for ONE arrival +# time, which is what makes (B) tolerance-free. +from RIFT.likelihood.jax_ile.core import _accumulate_unit + +fmin = 30.; event_time = 1e9; t_window = 0.1; Lmax = 2 +deltaT = 1. / 4096.; seglen = 4.; deltaF = 1. / seglen +fNyq = 1. / 2. / deltaT; N = int(round(seglen / deltaT)) +det = 'H1' +HARM = (-2, -1, 0, 1, 2) +psd_dict = {det: lalsim.SimNoisePSDaLIGOZeroDetHighPower} + + +def _harm_for(p_max): + """The harmonic set the PRECOMPUTE will actually carry for this p_max. + + rotation_coefficients emits keys (p, n+m) with |m| <= 1, so the coefficient index widens + by one per derivative order, and PrecomputeLikelihoodTermsWithRotation widens a too-narrow + `harmonics` to |n| <= 2 + p_max rather than silently dropping bands (#142/#143). Derive + the set from that same helper: assuming HARM here instead would put the data, the bank and + the explicit reference model on THREE different harmonic sets at p_max >= 1. + """ + return flwr.widen_harmonics_for_p_max(HARM, p_max)[0] +RA, DEC, PSI, INCL, PHIREF = 1.0, 0.2, 0.5, 0.7, 0.9 +DLOUD = fl.distMpcRef * 1e6 * lsu.lsu_PC / 30. # loud, so lnL sits near the bound + +# ---------------------------------------------------------------- per-rung configuration +# The two knobs the rung's conditioning turns on: the rotation rate (through INFL, the factor +# by which the sidereal rate is inflated so that Omega*T_segment matches a long signal) and the +# upper end of the band. They are PER p_max because the p >= 1 rungs need a different balance +# from Path A -- see CONFIG and config_for below. +INFL_DEFAULT = 5400. / seglen # Omega * T_segment as for a 90-minute signal +FMAX_DEFAULT = 1700. + + +class Config(object): + """One rung's (INFL, fmax), plus everything derived from them. + + Everything that does NOT depend on these two knobs -- the waveform modes, hY_data, hY_ref + and its FD derivatives -- stays at module level and is shared across configurations, so a + sweep over (INFL, fmax) does not regenerate waveforms. + """ + + def __init__(self, infl=INFL_DEFAULT, fmax=FMAX_DEFAULT): + self.infl = float(infl) + self.fmax = float(fmax) + # The 5-harmonic ANTENNA expansion is exact at any Omega, so inflating Omega costs no + # accuracy at p_max=0. The DELAY expansion is a Taylor series and does not share that + # property: see _delay_expansion_ratio. + self.omega = flwr.OMEGA_EARTH * self.infl + self.fsid = self.omega / (2.0 * np.pi) + self.ipc = lsu.ComplexIP(fmin, self.fmax, fNyq, deltaF, psd_dict[det], True, False, 0.) + self._data_cache = {} + + def __repr__(self): + return "Config(INFL=%.1f, fmax=%.0f, Omega*T_seg=%.3f rad)" % ( + self.infl, self.fmax, self.omega * seglen) + + +# The configuration each rung runs at. An unlisted p_max >= 1 RAISES in config_for() below; +# the bare-Config() fallback is reachable only for p_max < 1 and not in CONFIG -- i.e. +# negative, or a non-integer below 1 -- since 0 is a key here. Neither occurs in practice. +# +# Path B runs FASTER than Path A, at Omega*T_segment for a 6-hour signal rather than a +# 90-minute one, and that is (A)'s requirement, not (B)'s or (C)'s. With the model +# non-truncated (#142/#143) the static approximation is good to 0.39 nats at the 90-minute +# rate -- below MIN_STATIC_DEFICIT, i.e. the rung would not be exercising rotation. The +# deficit grows FASTER THAN LINEARLY but slower than Omega^2 over this range (measured: +# 0.0046 / 0.107 / 0.389 / 1.296 / 3.923 nats at INFL = 135 / 675 / 1350 / 2700 / 5400 -- +# that is 10.1x for the last 4x, i.e. ~Omega^1.66, where Omega^2 would predict 16x; this +# comment said "like Omega^2" against that same list). So 4x the rate buys 10x the teeth. +# Nothing else +# pays for it: (B) and (C) are at machine precision across that whole range once the two +# defects issue #159 turned up are fixed (see PRs #117 and #163). +CONFIG = { + 0: Config(), + 1: Config(infl=21600. / seglen), +} + + +def config_for(p_max): + """Rotation rate for this rung. REFUSES an unlisted p_max >= 1 rather than guessing. + + The old fallback handed any unlisted p_max the Path-A default, the rate this file argues is + too slow for p >= 1, so run_ladder(p_max=2) silently ran at a rate its own asserts reject. + + p_max=2 is unsupported because (D), JAX against the numpy NoLoop, exceeds TOL_NOLOOP at + every configuration tried, and TOL_NOLOOP is absolute-only. Supporting the rung means + giving (D) the `abs OR rel` shape (C) already has -- a change to what the test ASSERTS, + not a tolerance bump, and not a loosening of TOL_NOLOOP. Add a CONFIG entry only together + with that change and the measurements justifying it. + + DO NOT WRITE A MECHANISM FOR (D)'s SIZE HERE. Three attempts were made and all three were + refuted by measuring a second configuration. Raising the rate does move (D); it does not + move it far enough. + + Measurements: PR #163, and RIFT_roboto_paper analyses/slowrot_bound_violation/. + """ + if p_max in CONFIG: + return CONFIG[p_max] + if p_max >= 1: + raise ValueError( + "no CONFIG entry for p_max=%r: this ladder's rate is chosen per rung, and the " + "old fallback silently used the Path-A rate (INFL=1350), which p >= 1 asserts " + "reject. See this function's docstring for the p_max=2 measurements." % (p_max,)) + return Config() + +TOL_BOUND = 1e-6 # nats above (1/2) that we call a violation +TOL_DIRECT_ABS = 1e-6 # nats of disagreement with the explicit model +TOL_DIRECT_REL = 1e-6 # ... or, as a backstop, of 0.5. A BACKSTOP, not slack + # bought to make p_max=1 pass: both rungs clear the ABSOLUTE arm. +TOL_NOLOOP = 1e-8 # nats of disagreement with the numpy NoLoop lnL(t) +MIN_STATIC_DEFICIT = 1.0 # (A): rotation must be worth at least this much here +NPTS_SCAN = 164 # +-20 ms +SCAN_HALF = 10 # (C) samples either side of the arrival sample + +TVALS = -0.02 + np.arange(NPTS_SCAN) * deltaT + + +def _ifft_arr(hf): + n = hf.data.length; dt = 1. / (n * hf.deltaF) + ts = lal.CreateCOMPLEX16TimeSeries("h", hf.epoch, 0., dt, lal.DimensionlessUnit, n) + lal.COMPLEX16FreqTimeFFT(ts, hf, lal.CreateReverseCOMPLEX16FFTPlan(n, 0)) + return np.array(ts.data.data) + + +def _to_fd(arr, epoch, dt, n): + ts = lal.CreateCOMPLEX16TimeSeries("h", epoch, 0., dt, lal.DimensionlessUnit, n) + ts.data.data[:] = arr[:n] + hf = lal.CreateCOMPLEX16FrequencySeries("hf", epoch, 0., 1. / dt / n, lsu.lsu_HertzUnit, n) + lal.COMPLEX16TimeFreqFFT(hf, ts, lal.CreateForwardCOMPLEX16FFTPlan(n, 0)) + return hf + + +Psig = lsu.ChooseWaveformParams( + fmin=fmin, radec=True, incl=INCL, phiref=PHIREF, theta=DEC, phi=RA, psi=PSI, + m1=30 * lal.MSUN_SI, m2=25 * lal.MSUN_SI, detector=det, dist=200e6 * lal.PC_SI, + deltaT=deltaT, tref=event_time, deltaF=deltaF) + +lald = lalsim.DetectorPrefixToLALDetector(det) +DELAY = float(lal.TimeDelayFromEarthCenter(np.asarray(lald.location), RA, DEC, + lal.LIGOTimeGPS(event_time))) +K_ARR = int(round(DELAY / deltaT)) # arrival sample offset from tref +assert K_ARR > 0, ("this test needs the signal placed at a POSITIVE arrival offset (see the " + "module docstring): the post-phase is the identity at zero offset, and a " + "negative one wraps the inspiral onset. Geometric delay here is %g s." % DELAY) + +# ---------------------------------------------------------------- data: the exact Path-A model, +# placed at the detector's geometric arrival time. +Pm = Psig.manual_copy(); Pm.dist = DLOUD +hlms_d, _ = fl.internal_hlm_generator(Pm, Lmax, verbose=False, quiet=True) +lm0 = list(hlms_d.keys())[0] +epoch_intr = float(hlms_d[lm0].epoch) +u_grid = epoch_intr + np.arange(N) * deltaT # data-grid intrinsic time = t' - tref +hY_data = np.zeros(N, dtype=complex) +for lm in hlms_d: + hY_data += _ifft_arr(hlms_d[lm]) * lal.SpinWeightedSphericalHarmonic(INCL, -PHIREF, -2, + lm[0], lm[1]) +g_ev = float(lal.GreenwichMeanSiderealTime(lal.LIGOTimeGPS(event_time))) - RA +Atil = {n: v * np.exp(1j * n * g_ev) + for n, v in srr.antenna_harmonics(lald.response, DEC, PSI).items()} +INV_DIST = fl.distMpcRef / (DLOUD / (lsu.lsu_PC * 1e6)) + + +def _path_a_data(cfg): + """The exact Path-A model F(u) * roll(hY, K_ARR) at this configuration's Omega.""" + F_of_u = sum(Atil[n] * np.exp(1j * n * cfg.omega * u_grid) for n in Atil) + return _to_fd(np.real(F_of_u * np.roll(hY_data, K_ARR)), + lal.LIGOTimeGPS(epoch_intr + event_time), deltaT, N) + + +def delay_expansion_ratio(cfg): + """max |2 pi f delta_tau| over the band: the p-expansion's convergence parameter. + + The p >= 1 bands are the Taylor series of h(t - delta_tau(t)) in the delay DRIFT + delta_tau(t) = tau(t) - tau(tref), so the p-th band is smaller than the p-1'th by roughly + this factor. Above 1 the series diverges at the top of the band, and every construction + that rebuilds the model from it -- including (C)'s explicit reference -- inherits that. + + It is a max over the whole u_grid evaluated at fmax, so it is an UPPER BOUND at the band + edge. What it licenses is refusing p >= 3, where the reconstruction blows up. IT IS NOT + THE REASON THE RUNG STOPS AT p_max = 1 -- at the shipped rate p = 2 is the most + perturbative order of all, so this metric says nothing against it; p_max = 2 is + unsupported for reasons that are config_for's business, not this metric's. + + Its TREND across rates is the informative part; a single value is a band-edge bound and + says nothing on its own about which p you can afford. + + Measured norms per p_max: PR #163, and RIFT_roboto_paper analyses/slowrot_bound_violation/. + """ + Bd = srr.delay_harmonics(lald.location, DEC) + Btil = {m: Bd[m] * np.exp(1j * m * g_ev) for m in Bd} + D = dict(Btil) + D[0] = D[0] - np.real(sum(Btil.values())) + dtau = sum(D[m] * np.exp(1j * m * cfg.omega * u_grid) for m in D) + return 2.0 * np.pi * cfg.fmax * float(np.max(np.abs(np.real(dtau)))) + + +def _Pv(): + Pv = Psig.manual_copy() + for key, v in [('phi', RA), ('theta', DEC), ('incl', INCL), ('phiref', PHIREF), + ('psi', PSI), ('dist', DLOUD)]: + setattr(Pv, key, np.ones(1) * v) + Pv.tref = event_time; Pv.deltaT = deltaT + return Pv + + +def rotation_lnL_t(f_sidereal, p_max, cfg): + """(jax lnL(t), numpy NoLoop lnL(t), arrival sample offsets, a_list) on one shared bank.""" + P = Psig.manual_copy() + data_dict = data_for(p_max, cfg)[1] + bank = flwr.PrecomputeLikelihoodTermsWithRotation( + event_time, t_window, P, data_dict, psd_dict, Lmax, cfg.fmax, harmonics=HARM, + p_max=p_max, f_sidereal=f_sidereal, analyticPSD_Q=True, verbose=False, quiet=True, + skip_interpolation=True) + meta = bank[4] + _harm = _harm_for(p_max) + assert len(meta['a_list']) == (p_max + 1) * len(_harm), ( + "unexpected a_list size: %d bands for p_max=%d over %d harmonics" + % (len(meta['a_list']), p_max, len(_harm))) + lk, rho_b, U_b, V_b, epd = flwr.pack_rotation_arrays(meta, bank[3], bank[1], bank[2]) + Pv = _Pv() + + lnL_ref = flwr.DiscreteFactoredLogLikelihoodViaArrayVectorNoLoopWithRotation( + TVALS, Pv, meta, lk, rho_b, U_b, V_b, epd, Lmax=Lmax, array_output=True)[0] + + jdata = build_rotation_data(meta, lk, rho_b, U_b, V_b, epd, deltaT, TVALS) + kappa, rho_sq = _accumulate_unit( + jdata, Pv.phi, Pv.theta, Pv.psi, Pv.incl, Pv.phiref, "nearest", False) + lnL_jax = np.asarray(kappa.real * INV_DIST - 0.5 * rho_sq * INV_DIST ** 2)[0] + + # Reproduce the shared indexing so we know which arrival sample each output is. + off = float(Pv.tref - float(epd[det])) + ifirst = int(np.round((off + DELAY + TVALS[0]) / deltaT)) + kvals = ifirst + np.arange(NPTS_SCAN) - int(round(off / deltaT)) + return lnL_jax, np.asarray(lnL_ref), kvals, list(meta['a_list']) + + +# ---------------------------------------------------------------- the explicit model for (C) +# The model the likelihood implies, built explicitly on the data grid: +# +# h(u) = invDist * Re[ sum_a C~_a(t) chi_a(u - t) ], chi_a(u) = e^{i n_a Omega u} hY^(p_a)(u) +# +# with C~_a = C_a e^{i n_a Omega k dt} the arrival-time post-phase at arrival sample k +# (rotation_post_phase). +# +# THE SHIFT IS APPLIED TO THE MODULATED TEMPLATE, and that is not interchangeable with the +# obvious-looking alternative. Analytically the post-phase cancels the shift inside the +# modulation -- C~_{(p,n)} e^{i n Omega (u - k dt)} = C_{(p,n)} e^{i n Omega u} -- so one is +# tempted to modulate on the UNROLLED grid and write +# h(u) = invDist Re[ sum_p G_p(u) roll(hY^(p), k) ], G_p(u) = sum_n C_{(p,n)} e^{i n Omega u}. +# But the shift here is CIRCULAR, and e^{i n Omega u} is not periodic on the segment, so the +# two forms differ by e^{i n Omega T_seg} on exactly the k samples that wrap the boundary. +# At p_max=0 that costs nothing -- hY^(0) is machine zero over the last K_ARR samples +# (1.2e-16 of its peak) -- but hY^(1) is NOT: the FD derivative leaves 5.9e-04 of its peak +# there, and the wrapped mismatch then shows up as ~1e-02 nats of disagreement with the +# bank, which computes the shift by FFT correlation and is circular in exactly this sense. +# See issue #159. The post-phase is still applied EXPLICITLY below, so (C) keeps its teeth +# against a dropped rotation_post_phase (mutation numbers: PRs #117 and #163). +# +# At p_max=0 the sum reduces to F(u)*roll(hY,k), the numpy twin's construction (G_0 == F), +# and data_for() asserts that equality at 1e-12. +# +# G_p reuses flwr.rotation_coefficients and the FD derivative weight rather than re-deriving +# them: what (C) is pinning is the arrival-time post-phase and the band contraction, not the +# response algebra (test_jax_slowrot_coeffs, 2e-16) or the FD derivative (test_slowrot_fd_ops). +Pref = Psig.manual_copy() +Pref.dist = fl.distMpcRef * 1e6 * lsu.lsu_PC +Pref.deltaF = deltaF +hlms_r, _ = fl.internal_hlm_generator(Pref, Lmax, verbose=False, quiet=True) +Ylm_r = fl.ComputeYlms(Lmax, INCL, -PHIREF, selected_modes=list(hlms_r.keys())) +hY_ref = np.zeros(N, dtype=complex) +for lm in hlms_r: + hY_ref += Ylm_r[lm] * _ifft_arr(hlms_r[lm]) +data_epoch = lal.LIGOTimeGPS(epoch_intr + event_time) +_hY_ref_fd = _to_fd(hY_ref, data_epoch, deltaT, N) +_FVALS = flwr.evaluate_fvals_from_length(N, _hY_ref_fd.deltaF) + + +def _hY_deriv(p): + """p-th time derivative of hY_ref on the data grid (FD weight, RIFT fvals packing).""" + if p == 0: + return hY_ref + hfp = lal.CreateCOMPLEX16FrequencySeries( + "hfp", _hY_ref_fd.epoch, 0., _hY_ref_fd.deltaF, lsu.lsu_HertzUnit, N) + hfp.data.data[:] = _hY_ref_fd.data.data * flwr.time_derivative_weight(_FVALS, p) + return _ifft_arr(hfp) + + +def _explicit_model_fd(k, p_max, a_list, cfg): + """FD of h(u) above, for arrival sample k, at fiducial distance scaled by INV_DIST. + + ``a_list`` is the bank's band list and the sum is RESTRICTED to it. Since #142/#143 the + precompute WIDENS a too-narrow harmonic set to |n| <= 2 + p_max, so for a bank built that + way the restriction is a no-op and nothing is dropped -- keep it anyway, because it is what + makes this reference track the bank rather than assume it, and a bank built with + widen_harmonics=False genuinely is a truncated model that this sum must match. + + Historical note, because the number is instructive: before #142 the bank had no band for + the |n| = 3 coefficients at p_max=1, both evaluators silently dropped them, and summing the + full coefficient dict here instead of restricting to a_list disagreed by 2.2e+05 nats at + this configuration -- the dropped bands were the same order as the ones kept, because at + INFL=1350, the rate this rung then ran at, the first-order delay term dominates. + """ + C = flwr.rotation_coefficients(det, RA, DEC, PSI, event_time, p_max) # {(p,n): C_a} + keep = set((int(p), int(n)) for (p, n) in a_list) + h_td = np.zeros(N, dtype=complex) + for p in range(p_max + 1): + hp = _hY_deriv(p) + for (pa, na), c in C.items(): + if pa != p or (pa, na) not in keep: + continue + chi_a = np.exp(1j * na * cfg.omega * u_grid) * hp # chi_a(u) + post = np.exp(1j * na * cfg.omega * k * deltaT) # rotation_post_phase + h_td = h_td + c * post * np.roll(chi_a, k) # C~_a chi_a(u - k dt) + return _to_fd(np.real(h_td) * INV_DIST, data_epoch, deltaT, N) + + +def data_for(p_max, cfg): + """(data, data_dict, 0.5, a_list) with the data EQUAL to the exact model at this p_max. + + That is what makes (B) maximally tight: with the data equal to the model the likelihood can + represent, lnL at the true arrival sample sits exactly ON (1/2), leaving no slack for an + inconsistency to hide in. A p_max=0 dataset used against a p_max=1 bank would instead leave + the p>=1 bands fitting nothing, and (B) would pass with 1e5 nats of margin. + + p_max=0 uses the INDEPENDENT construction above (srr.antenna_harmonics -> F(u) -> Re[F*roll]), + which shares nothing with rotation_coefficients; the assert below pins the two together at + p_max=0 so the p>=1 datasets inherit that provenance. + """ + if p_max not in cfg._data_cache: + a_list = flwr._elementary_index_set(_harm_for(p_max), p_max) + if p_max == 0: + d = _path_a_data(cfg) + chk = _explicit_model_fd(K_ARR, 0, a_list, cfg) + dd = np.max(np.abs(chk.data.data - d.data.data)) + ref = np.max(np.abs(d.data.data)) + assert dd <= 1e-12 * ref, ( + "the explicit model and the independent antenna_harmonics data construction " + "disagree at p_max=0 by %g (rel %g) -- (C)'s reference is not the Path-A model" + % (dd, dd / ref)) + else: + d = _explicit_model_fd(K_ARR, p_max, a_list, cfg) + cfg._data_cache[p_max] = (d, {det: d}, 0.5 * cfg.ipc.ip(d, d).real, a_list) + return cfg._data_cache[p_max] + + +def run_ladder(p_max=0, cfg=None, verbose=True): + """The (A)-(D) ladder at one p_max. Returns a dict of the measured numbers.""" + if cfg is None: + cfg = config_for(p_max) + tag = "Path %s, p_max=%d" % ("A" if p_max == 0 else "B", p_max) + data, _dd, HALF_DD, _al = data_for(p_max, cfg) + if verbose: + print("\n=== JAX SLOWROT CAUCHY-SCHWARZ (%s, A=%d bands, 0.5=%.6f) ===" + % (tag, len(_al), HALF_DD)) + print(" %s arrival offset %+d samples (%+.2f ms) max|2 pi f dtau| = %.3f" + % (cfg, K_ARR, 1e3 * K_ARR * deltaT, delay_expansion_ratio(cfg))) + + # ------------------------------------------------------------ (A) teeth + lnL_static, _, _, _ = rotation_lnL_t(0.0, p_max, cfg) + static_deficit = HALF_DD - float(np.max(lnL_static)) + print("(A) rotation OFF vs rotating data: deficit = %.4f nats" % static_deficit) + assert static_deficit > MIN_STATIC_DEFICIT, ( + "this configuration does not exercise rotation (static deficit %g <= %g), so the " + "bound and direct-model checks below would be vacuous" + % (static_deficit, MIN_STATIC_DEFICIT)) + + # ------------------------------------------------------------ (B) the bound + lnL_rot, lnL_noloop, kvals, a_list = rotation_lnL_t(cfg.fsid, p_max, cfg) + overshoot = float(np.max(lnL_rot)) - HALF_DD + jpeak = int(np.argmax(lnL_rot)) + print("(B) rotation ON : max lnL = %.6f at k=%+d deficit = %+.6e" + % (np.max(lnL_rot), kvals[jpeak], HALF_DD - np.max(lnL_rot))) + assert kvals[jpeak] == K_ARR, ( + "lnL peaks at arrival sample %d, not the %d the data was built at -- the test is no " + "longer sitting on the bound and (B) has lost its teeth" % (kvals[jpeak], K_ARR)) + assert overshoot <= TOL_BOUND, ( + "Cauchy-Schwarz VIOLATED: max JAX lnL exceeds 0.5 by %g nats. lnL = - " + "(1/2) cannot exceed (1/2) for any h, so term1 and term2 are being " + "evaluated for different templates -- see rotation_post_phase() and " + "core._accumulate_unit_banded." % overshoot) + + # ------------------------------------------------------------ (C) the mechanism + # (C) scans only NON-NEGATIVE arrival offsets: a circular shift to earlier times wraps real + # signal across the segment boundary, where the FFT correlation the precompute uses and an + # explicit time-domain roll legitimately disagree. See the numpy twin's docstring. + worst = 0.0; worst_ref = 0.0; n_cmp = 0; scale = 0.0 + for j in range(max(0, jpeak - SCAN_HALF), min(NPTS_SCAN, jpeak + SCAN_HALF + 1)): + k = int(kvals[j]) + if k < 0: + continue + hf = _explicit_model_fd(k, p_max, a_list, cfg) + hh = cfg.ipc.ip(hf, hf).real + lnL_direct = cfg.ipc.ip(hf, data).real - 0.5 * hh + worst = max(worst, abs(lnL_direct - lnL_rot[j])) + worst_ref = max(worst_ref, abs(lnL_direct - lnL_noloop[j])) + scale = max(scale, 0.5 * hh); n_cmp += 1 + print("(C) vs explicit time-domain model over %d samples about the peak: max|d lnL| = %.3e" + " (rel to 0.5=%.3e: %.2e; numpy NoLoop vs the same reference: %.3e)" + % (n_cmp, worst, scale, worst / scale, worst_ref)) + + # ------------------------------------------------------------ (D) vs the numpy NoLoop + d_noloop = float(np.max(np.abs(lnL_rot - lnL_noloop))) + print("(D) vs numpy NoLoop lnL(t) over the whole %d-sample scan: max|d lnL| = %.3e" + % (NPTS_SCAN, d_noloop)) + + assert n_cmp >= SCAN_HALF, "too few comparable samples (%d) for (C) to mean anything" % n_cmp + assert worst < TOL_DIRECT_ABS or worst / scale < TOL_DIRECT_REL, ( + "JAX rotation likelihood disagrees with the explicit - (1/2) for the model " + "it implies by %g nats (%.2e of 0.5) at p_max=%d" % (worst, worst / scale, p_max)) + assert d_noloop < TOL_NOLOOP, "JAX vs NoLoop lnL(t) disagree by %g nats" % d_noloop + + return dict(p_max=p_max, infl=cfg.infl, fmax=cfg.fmax, half_dd=HALF_DD, + static_deficit=static_deficit, max_lnL=float(np.max(lnL_rot)), + overshoot=overshoot, direct=worst, direct_rel=worst / scale, + noloop=d_noloop, expansion_ratio=delay_expansion_ratio(cfg)) + + +# pytest collects these; running the file as a script executes the same thing (see __main__). +def test_cauchy_schwarz_path_a(): + run_ladder(p_max=0) + + +def test_cauchy_schwarz_path_b(): + run_ladder(p_max=1) + + +if __name__ == "__main__": + run_ladder(p_max=0) + run_ladder(p_max=1) + print("\nALL JAX SLOWROT CAUCHY-SCHWARZ CHECKS PASSED") diff --git a/MonteCarloMarginalizeCode/Code/test/jax/test_jax_slowrot_wrapper.py b/MonteCarloMarginalizeCode/Code/test/jax/test_jax_slowrot_wrapper.py index 5b3b8a14d..3c9f9dad2 100644 --- a/MonteCarloMarginalizeCode/Code/test/jax/test_jax_slowrot_wrapper.py +++ b/MonteCarloMarginalizeCode/Code/test/jax/test_jax_slowrot_wrapper.py @@ -69,8 +69,14 @@ def _run(builder, tag, **kw): return data -if __name__ == "__main__": +# pytest entry point -- see the note in test_jax_slowrot.py. Without it this +# file collects zero items and pytest exits 5, which reads as green. +def test_one_call_builders(): _run(build_rotation_data_from_precompute, "rotation", p_max=0) _run(build_freqresponse_data_from_precompute, "freqresponse", Qmax=4, L_arm=40000.0) + + +if __name__ == "__main__": + test_one_call_builders() print("ONE-CALL BUILDER SMOKE TEST PASSED") diff --git a/MonteCarloMarginalizeCode/Code/test/jax/test_network_coords.py b/MonteCarloMarginalizeCode/Code/test/jax/test_network_coords.py index 4ba53c1be..11a0db122 100644 --- a/MonteCarloMarginalizeCode/Code/test/jax/test_network_coords.py +++ b/MonteCarloMarginalizeCode/Code/test/jax/test_network_coords.py @@ -109,5 +109,11 @@ def main(): "band in theta_n, spread over phi_n.") +# pytest entry point -- see the note in test_jax_slowrot.py. Without it this +# file collects zero items and pytest exits 5, which reads as green. +def test_network_fold(): + main() + + if __name__ == "__main__": main() diff --git a/MonteCarloMarginalizeCode/Code/test/jax/test_nuts_phimarg.py b/MonteCarloMarginalizeCode/Code/test/jax/test_nuts_phimarg.py index 0105878bd..28c413683 100644 --- a/MonteCarloMarginalizeCode/Code/test/jax/test_nuts_phimarg.py +++ b/MonteCarloMarginalizeCode/Code/test/jax/test_nuts_phimarg.py @@ -118,5 +118,12 @@ def main(): return 0 if ok else 1 +# pytest entry point -- see the note in test_jax_slowrot.py. main() reports via +# its return code, so the assertion has to be on that; a bare main() call would +# pass even when the run FAILED. +def test_nuts_phimarg_analytic(): + assert main() == 0, "fisher_nuts_sample_phimarg failed its analytic-target gates" + + if __name__ == "__main__": sys.exit(main()) diff --git a/MonteCarloMarginalizeCode/Code/test/jax/test_tvals_grid_convention.py b/MonteCarloMarginalizeCode/Code/test/jax/test_tvals_grid_convention.py new file mode 100644 index 000000000..65c0ae00a --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/test/jax/test_tvals_grid_convention.py @@ -0,0 +1,259 @@ +#!/usr/bin/env python +"""Both extrinsic drivers must build the SAME time-marginalization grid. + +Issue #146: ``bin/integrate_likelihood_extrinsic_batchmode`` built +``linspace(-t_ref_wind, t_ref_wind, int(2*t_ref_wind/deltaT))`` at ten sites while +``RIFT/likelihood/jax_ile/wrapper.py`` (and hence +``bin/integrate_likelihood_extrinsic_jax``) built ``arange(-Nw, Nw)*deltaT``. Both +likelihoods consume ONLY ``tvals[0]`` and ``len(tvals)`` -- each steps by ``deltaT`` +and integrates with ``dx=deltaT`` regardless of the grid's own spacing -- so the two +grids differed in ORIGIN (0.2 samples at iwh=0.075 s, srate 4096), enough to round +``ifirst`` to a different integer sample and, since ``t_det`` carries the +per-detector delay, a different subset PER DETECTOR: up to 67.8 nats per sample. +They also differed in LENGTH, because ``2*int(x) != int(2*x)``, at srate 1024, 2048 +and 16384 -- 16384 being the low-mass production rate. + +WHY THIS FILE IS SHAPED THE WAY IT IS +------------------------------------- +The obvious test -- call the shared helper twice and compare -- is TAUTOLOGICAL: it +passes whether or not the drivers use the helper, which is the entire defect. So +these tests read the ACTUAL DRIVER SOURCE, extract every window-grid construction by +AST, and evaluate the extracted expressions. A driver that reverts one site to +``linspace`` fails ``test_all_driver_grid_sites_agree_by_value``; a driver that adds +an eleventh site by hand fails ``test_no_handrolled_window_grid_remains``. + +The sample rates deliberately include 16384. ``test_jax_endtoend.py`` runs at 4096, +one of only two rates where the two old conventions' LENGTHS coincidentally agreed, +so it structurally could not catch this even after #144. +""" + +import ast +import os +import re + +import numpy as np +import pytest + +import RIFT.likelihood.factored_likelihood as factored_likelihood + +_HERE = os.path.dirname(os.path.abspath(__file__)) +_CODE = os.path.abspath(os.path.join(_HERE, os.pardir, os.pardir)) +_BATCHMODE = os.path.join(_CODE, 'bin', 'integrate_likelihood_extrinsic_batchmode') +_WRAPPER = os.path.join(_CODE, 'RIFT', 'likelihood', 'jax_ile', 'wrapper.py') +_JAXDRIVER = os.path.join(_CODE, 'bin', 'integrate_likelihood_extrinsic_jax') + +# Distinguishes a window grid from the many other linspace/arange calls in these +# files (distance grids, index ranges, the dense resampling grid). +_WINDOW_NAME = re.compile(r'\b(?:t_ref_wind|integration_window_half)\b') +_LEGACY_NW = re.compile(r'-\s*Nw\b') + +# Sample rates to check. 16384 is the low-mass production rate and one of the three +# where the two pre-#146 conventions produced DIFFERENT LENGTHS (152/153, 306/307, +# 2456/2457); 4096 and 8192 are the two where they happened to agree. +SRATES = (1024, 2048, 4096, 8192, 16384) +IWH = 0.075 # --data-integration-window-half default, seconds + +# The convention, written out independently of the implementation: npts, and the +# first and last grid sample as an EXACT rational multiple of deltaT. If someone +# changes marginalization_time_grid(), these literals are what they have to argue +# with. (npts = int(2*iwh/deltaT); first = -(npts//2); last = first + npts - 1.) +EXPECTED = { + 1024: (153, -76, 76), + 2048: (307, -153, 153), + 4096: (614, -307, 306), + 8192: (1228, -614, 613), + 16384: (2457, -1228, 1228), +} + + +def _grid_call_sites(path): + """Every window-grid construction in `path`, as (lineno, source_text) pairs. + + Matched by AST from the real file (the driver is a script and is never imported + here). THREE spellings are recognised, on purpose: + + * ``marginalization_time_grid(...)`` -- the shared helper, what must be there; + * ``linspace(...)`` mentioning the window half-width -- batchmode's ten + pre-#146 sites; + * ``arange(-Nw, Nw)*deltaT`` -- the wrapper's three pre-#146 sites. + + Recognising the legacy spellings is what stops the comparison below from being a + helper-presence check: run these tests against a pre-#146 tree and they extract + the OLD grids from both drivers and fail on the actual 67.8-nat divergence, + rather than passing vacuously because both sides now call one function. + """ + with open(path) as f: + src = f.read() + tree = ast.parse(src, filename=path) + out = [] + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + fn = node.func + name = fn.attr if isinstance(fn, ast.Attribute) else getattr(fn, 'id', None) + if name not in ('marginalization_time_grid', 'linspace', 'arange'): + continue + text = ast.get_source_segment(src, node) + assert text is not None, "could not recover source for call at line %d" % node.lineno + if name == 'marginalization_time_grid': + pass + elif name == 'linspace' and _WINDOW_NAME.search(text): + pass # legacy batchmode spelling + elif name == 'arange' and _LEGACY_NW.search(text): + text += ' * deltaT' # legacy wrapper spelling: arange(-Nw,Nw) is scaled + else: + continue # an unrelated linspace/arange (distance grids, indices, ...) + out.append((node.lineno, text)) + return out + + +class _P(object): + """Stand-in for the driver's global ChooseWaveformParams: only deltaT is read.""" + def __init__(self, deltaT): + self.deltaT = deltaT + + +def _eval_site(text, srate): + """Evaluate one extracted grid expression at `srate`, as the driver would.""" + deltaT = 1.0 / srate + ns = { + 'np': np, 'numpy': np, 'xpy_default': np, + 'factored_likelihood': factored_likelihood, + 'marginalization_time_grid': factored_likelihood.marginalization_time_grid, + # batchmode names + 't_ref_wind': IWH, 'P': _P(deltaT), + # wrapper names + 'integration_window_half': IWH, 'deltaT': deltaT, + # The pre-#146 wrapper spelling was `Nw = int(iwh/deltaT); arange(-Nw,Nw)*deltaT`, + # with Nw bound on the line above the call. Bind it here so an un-migrated tree + # is EVALUATED and fails on the grid values, rather than escaping the comparison. + 'Nw': int(IWH / deltaT), + } + return np.asarray(eval(compile(ast.Expression(ast.parse(text, mode='eval').body), + '', 'eval'), ns)) + + +def test_extractor_actually_finds_the_sites(): + """Guard the guard: a broken extractor would make every test below vacuous.""" + bm = _grid_call_sites(_BATCHMODE) + wr = _grid_call_sites(_WRAPPER) + assert len(bm) >= 10, ( + "expected at least the 10 known window-grid sites in %s, found %d -- either " + "sites were removed or the AST extractor broke" % (_BATCHMODE, len(bm))) + assert len(wr) >= 3, ( + "expected at least the 3 known window-grid sites in %s, found %d" % (_WRAPPER, len(wr))) + + +@pytest.mark.parametrize('srate', SRATES) +def test_all_driver_grid_sites_agree_by_value(srate): + """THE test for #146: every grid either driver builds is bit-identical. + + Before #146 this failed at every one of these rates: differing origin at all + five, and differing length at 1024, 2048 and 16384. + """ + bm = [('batchmode', l, t) for (l, t) in _grid_call_sites(_BATCHMODE)] + wr = [('wrapper', l, t) for (l, t) in _grid_call_sites(_WRAPPER)] + # Without this, the test degenerates: if one file contributed ZERO sites the loop + # below would compare the other file against itself and pass, which is the single + # -path-conjunct failure shape this whole file exists to avoid. It is asserted + # here, not only in test_extractor_actually_finds_the_sites, so that THIS test + # cannot pass vacuously on its own. + assert bm and wr, ( + "cross-driver comparison needs sites from BOTH files; got %d from batchmode " + "and %d from the wrapper" % (len(bm), len(wr))) + sites = bm + wr + ref_tag, ref_line, ref_text = sites[0] + ref = _eval_site(ref_text, srate) + for tag, line, text in sites[1:]: + got = _eval_site(text, srate) + assert got.shape == ref.shape, ( + "srate %d: %s:%d builds %d grid points, %s:%d builds %d" + % (srate, tag, line, got.size, ref_tag, ref_line, ref.size)) + assert np.array_equal(got, ref), ( + "srate %d: %s:%d differs from %s:%d by up to %g s (%g samples)" + % (srate, tag, line, ref_tag, ref_line, + np.max(np.abs(got - ref)), np.max(np.abs(got - ref)) * srate)) + + +@pytest.mark.parametrize('srate', SRATES) +def test_grid_matches_the_pinned_convention(srate): + """The shared helper's own values, against hand-written expectations.""" + deltaT = 1.0 / srate + npts_expect, first_expect, last_expect = EXPECTED[srate] + tvals = factored_likelihood.marginalization_time_grid(IWH, deltaT) + + assert tvals.size == npts_expect, ( + "srate %d: npts %d, expected int(2*%g/deltaT) = %d" + % (srate, tvals.size, IWH, npts_expect)) + # Compare as integer sample indices: exact, and independent of float formatting. + assert tvals[0] == first_expect * deltaT + assert tvals[-1] == last_expect * deltaT + # Spacing EXACTLY deltaT -- the property that makes tvals[k] a truthful label + # for the sample the likelihood actually reads. Not approximately: exactly. + assert np.array_equal(np.diff(tvals), np.full(tvals.size - 1, deltaT)) + # The fiducial epoch is on the grid. + assert (tvals == 0.0).sum() == 1 + # And the window stays inside the requested half-width. + assert np.abs(tvals).max() <= IWH + + +def test_jax_driver_takes_the_wrapper_default_grid(): + """`integrate_likelihood_extrinsic_jax` must NOT build or pass its own grid. + + The cross-driver test above compares batchmode against ``jax_ile/wrapper.py``. That + is only a valid proxy for "the two DRIVERS agree" while the JAX driver actually + inherits the wrapper's default -- i.e. calls ``build_data_from_precompute`` with no + ``tvals=``. If someone gives that driver its own grid, the wrapper comparison keeps + passing while the drivers diverge again, which is precisely the #146 shape. + """ + with open(_JAXDRIVER) as f: + src = f.read() + tree = ast.parse(src, filename=_JAXDRIVER) + builders = [] + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + fn = node.func + name = fn.attr if isinstance(fn, ast.Attribute) else getattr(fn, 'id', None) + if name and name.startswith('build_') and name.endswith('_from_precompute'): + builders.append(node) + assert builders, ( + "no build_*_from_precompute call found in %s -- the driver was restructured and " + "this pin no longer checks anything" % os.path.basename(_JAXDRIVER)) + for node in builders: + passed = [kw.arg for kw in node.keywords if kw.arg == 'tvals'] + assert not passed, ( + "%s:%d passes its own tvals= to the builder; it must inherit the shared " + "default so the two drivers cannot drift apart again (issue #146)" + % (os.path.basename(_JAXDRIVER), node.lineno)) + + +def test_no_handrolled_window_grid_remains(): + """No file may rebuild this grid by hand; #146 was ten copies drifting apart. + + ``#`` comments are skipped -- the historical notes left in place deliberately + quote the old forms, and a test that forbade naming them would forbid explaining + them. Docstrings are NOT skipped, deliberately: a docstring that still describes + the grid as ``arange(-Nw, Nw)`` is documentation that has gone stale, which is + how #146 stayed invisible. Put such prose in a ``#`` comment. + """ + # The two pre-#146 spellings. Whitespace-insensitive so a reformat cannot hide one. + BANNED = (re.compile(r'linspace\(\s*-\s*t_ref_wind'), + re.compile(r'arange\(\s*-\s*Nw')) + offenders = [] + for path in (_BATCHMODE, _WRAPPER, + os.path.join(_CODE, 'bin', 'integrate_likelihood_extrinsic_jax')): + with open(path) as f: + for i, line in enumerate(f, 1): + code = line.split('#', 1)[0] + if any(rx.search(code) for rx in BANNED): + offenders.append('%s:%d: %s' + % (os.path.basename(path), i, line.rstrip())) + assert not offenders, ( + "hand-rolled window grid(s) reintroduced; call " + "factored_likelihood.marginalization_time_grid() instead:\n " + + "\n ".join(offenders)) + + +if __name__ == '__main__': + raise SystemExit(pytest.main([__file__, '-v'])) diff --git a/MonteCarloMarginalizeCode/Code/test/test_asimov_compatibility.py b/MonteCarloMarginalizeCode/Code/test/test_asimov_compatibility.py new file mode 100644 index 000000000..48481f125 --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/test/test_asimov_compatibility.py @@ -0,0 +1,277 @@ +#!/usr/bin/env python3 +"""Cross-version contract tests for the RIFT ASIMOV adapter.""" + +import os +import types + +import pytest + +pytest.importorskip("asimov") +rift_asimov = pytest.importorskip("RIFT.asimov.rift") + +Rift = rift_asimov.Rift +PipelineException = rift_asimov.PipelineException + + +class _Logger: + def info(self, *args, **kwargs): + pass + + def warning(self, *args, **kwargs): + pass + + +def _pipe(production): + pipe = Rift.__new__(Rift) + pipe.production = production + pipe.category = production.category + pipe.logger = _Logger() + return pipe + + +def test_asimov_07_completion_defers_to_separate_postprocessing(monkeypatch): + production = types.SimpleNamespace( + status="processing", category="C01_offline", meta={"job id": 12} + ) + pipe = _pipe(production) + monkeypatch.setattr(rift_asimov, "PESummaryPipeline", None) + + pipe.after_completion() + + assert production.status == "finished" + + +def test_legacy_completion_submits_pesummary_once(monkeypatch): + calls = [] + + class _LegacyPESummary: + def __init__(self, production, category=None): + calls.append((production, category)) + + def submit_dag(self): + return 314 + + production = types.SimpleNamespace( + status="running", category="C01_offline", meta={} + ) + pipe = _pipe(production) + monkeypatch.setattr(rift_asimov, "PESummaryPipeline", _LegacyPESummary) + + pipe.after_completion() + + assert calls == [(production, "C01_offline")] + assert production.meta["job id"] == 314 + assert production.status == "processing" + + +def test_collect_assets_publishes_pesummary_inputs(tmp_path): + rundir = tmp_path / "run" + rundir.mkdir() + samples = rundir / "extrinsic_posterior_samples.dat" + samples.write_text("# samples\n") + config = tmp_path / "repository" / "C01_offline" / "rift.ini" + config.parent.mkdir(parents=True) + config.write_text("[analysis]\n") + psd = tmp_path / "H1-psd.dat" + psd.write_text("20 1e-46\n") + calibration = tmp_path / "H1-calibration.dat" + calibration.write_text("20 0 0\n") + + repository = types.SimpleNamespace(directory=str(tmp_path / "repository")) + event = types.SimpleNamespace(name="S250202cu", repository=repository) + production = types.SimpleNamespace( + name="rift-SEOBNRv5PHM", + category="C01_offline", + rundir=str(rundir), + event=event, + psds={"H1": str(psd)}, + xml_psds={}, + meta={"data": {"calibration": {"H1": str(calibration)}}}, + get_configuration=lambda: types.SimpleNamespace(ini_loc="rift.ini"), + ) + + assets = _pipe(production).collect_assets(absolute=True) + + assert assets["asset_contract"] == "rift-assets/v1" + assert assets["samples"] == [str(samples)] + assert assets["config"] == str(config) + assert assets["psds"] == {"H1": str(psd)} + assert assets["calibration"] == {"H1": str(calibration)} + assert assets["provenance"] == { + "pipeline": "rift", + "event": "S250202cu", + "analysis": "rift-SEOBNRv5PHM", + } + + +def test_reweighted_samples_keep_list_contract(tmp_path): + rundir = tmp_path / "run" + rundir.mkdir() + reweighted = rundir / "reweighted_posterior_samples.dat" + reweighted.write_text("# samples\n") + event = types.SimpleNamespace( + name="S250202cu", repository=types.SimpleNamespace(directory=str(tmp_path)) + ) + production = types.SimpleNamespace( + name="rift-calmarg", + category="C01_offline", + rundir=str(rundir), + event=event, + psds={}, + meta={"data": {}}, + get_configuration=lambda: (_ for _ in ()).throw(ValueError()), + ) + + assets = _pipe(production).collect_assets(absolute=True) + + assert assets["samples"] == [str(reweighted)] + assert assets["samples_calmarg"] == str(reweighted) + assert "asset_contract" not in assets + + +def test_collect_assets_resolves_relative_detector_paths_from_repository( + tmp_path, monkeypatch): + repository_dir = tmp_path / "repository" + run = tmp_path / "run" + run.mkdir() + (run / "extrinsic_posterior_samples.dat").write_text("# samples\n") + config = repository_dir / "C01_offline" / "rift.ini" + config.parent.mkdir(parents=True) + config.write_text("[analysis]\n") + psd = repository_dir / "assets" / "H1-psd.dat" + calibration = repository_dir / "assets" / "H1-calibration.dat" + psd.parent.mkdir() + psd.write_text("20 1e-46\n") + calibration.write_text("20 0 0\n") + elsewhere = tmp_path / "elsewhere" + elsewhere.mkdir() + monkeypatch.chdir(elsewhere) + + event = types.SimpleNamespace( + name="S250202cu", + repository=types.SimpleNamespace(directory=str(repository_dir)), + ) + production = types.SimpleNamespace( + name="rift-relative", + category="C01_offline", + rundir=str(run), + event=event, + psds={"H1": "assets/H1-psd.dat"}, + xml_psds={}, + meta={"data": {"calibration": { + "H1": "assets/H1-calibration.dat"}}}, + get_configuration=lambda: types.SimpleNamespace(ini_loc="rift.ini"), + ) + + assets = _pipe(production).collect_assets(absolute=True) + + assert assets["psds"] == {"H1": str(psd)} + assert assets["calibration"] == {"H1": str(calibration)} + assert assets["asset_contract"] == "rift-assets/v1" + + +def test_collect_assets_distinguishes_standard_calmarg_and_all_net(tmp_path): + run = tmp_path / "run" + run.mkdir() + standard = run / "extrinsic_posterior_samples.dat" + calmarg = run / "reweighted_posterior_samples.dat" + all_net = run / "all.net" + standard.write_text("# standard\n") + calmarg.write_text("# calmarg\n") + all_net.write_text("# likelihood\n") + repository = tmp_path / "repository" + config = repository / "C01_offline" / "rift.ini" + config.parent.mkdir(parents=True) + config.write_text("[analysis]\n") + event = types.SimpleNamespace( + name="S250202cu", + repository=types.SimpleNamespace(directory=str(repository)), + ) + production = types.SimpleNamespace( + name="rift-both", category="C01_offline", rundir=str(run), + event=event, psds={}, xml_psds={}, meta={"data": {}}, + get_configuration=lambda: types.SimpleNamespace(ini_loc="rift.ini"), + ) + + assets = _pipe(production).collect_assets(absolute=True) + + assert assets["samples"] == [str(calmarg)] + assert assets["samples_raw"] == str(standard) + assert assets["samples_calmarg"] == str(calmarg) + assert assets["lnL_marg"] == str(all_net) + assert assets["asset_contract"] == "rift-assets/v1" + + +def test_asimov_07_psd_attributes_replace_legacy_getter(): + production = types.SimpleNamespace( + category="C01_offline", + psds={"H1": "/tmp/H1.dat"}, + xml_psds={"H1": "/tmp/H1.xml.gz"}, + ) + pipe = _pipe(production) + + assert pipe._get_psds("ascii") == production.psds + assert pipe._get_psds("xml") == ["/tmp/H1.xml.gz"] + + +def test_single_sample_list_is_unwrapped_for_bootstrap(monkeypatch): + dependency = types.SimpleNamespace( + name="pesummary", + pipeline=types.SimpleNamespace( + collect_assets=lambda: {"samples": ["combined.h5"]} + ), + ) + event = types.SimpleNamespace(productions=[dependency]) + production = types.SimpleNamespace( + name="rift-bootstrap", + category="C01_offline", + dependencies=["pesummary"], + event=event, + meta={"scheduler": {}}, + ) + pipe = _pipe(production) + monkeypatch.setattr(pipe, "_dataset_label", lambda path: "rift-source") + + assert pipe._find_posterior() == "combined.h5" + assert production.meta["dataset"] == "rift-source" + + +def test_multiple_sample_files_are_rejected_for_bootstrap(): + dependency = types.SimpleNamespace( + name="pesummary", + pipeline=types.SimpleNamespace( + collect_assets=lambda: {"samples": ["a.h5", "b.h5"]} + ), + ) + event = types.SimpleNamespace(productions=[dependency]) + production = types.SimpleNamespace( + name="rift-bootstrap", + category="C01_offline", + dependencies=["pesummary"], + event=event, + meta={"scheduler": {}}, + ) + + with pytest.raises(PipelineException, match="exactly one PESummary metafile"): + _pipe(production)._find_posterior() + + +def test_existing_bootstrap_requires_explicit_unprovenanced_reuse(tmp_path): + bootstrap = tmp_path / "bootstrap.xml.gz" + bootstrap.write_text("old grid") + production = types.SimpleNamespace( + name="rift-bootstrap", category="C01_offline", + meta={"scheduler": {}}, + ) + pipe = _pipe(production) + + with pytest.raises(PipelineException, match="bootstrap reuse existing"): + pipe._reuse_existing_bootstrap(str(bootstrap), "new-posterior.h5") + + production.meta["scheduler"]["bootstrap reuse existing"] = True + assert pipe._reuse_existing_bootstrap( + str(bootstrap), "new-posterior.h5") is True + + +if __name__ == "__main__": + raise SystemExit(pytest.main([os.path.abspath(__file__), "-v"])) diff --git a/MonteCarloMarginalizeCode/Code/test/test_cosmology_single_source.py b/MonteCarloMarginalizeCode/Code/test/test_cosmology_single_source.py new file mode 100644 index 000000000..07e56b66c --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/test/test_cosmology_single_source.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python +""" +One named cosmology, from one helper, across every code in the tree. + +RO, 2026-08-16: *"move it to the helper, so it is consistent by default and changed in a +consistent fashion between codes; not hardcoded. Agree minute effect, but the sort of random +complaint people do make in refereeing reports."* + +Three files used to build their own: the ILE driver and `util_InitMargTable` (both +`FlatLambdaCDM` from `lal.H0_SI`/`lal.OMEGA_M` = H0 67.900, Om0 0.3065, with a hardcoded +fallback), and `resample_uniform_comoving` (`LambdaCDM(H0=67.90, ...)`, named `Planck15_lal` +because it deliberately reproduced the ILE's cosmology to ~1e-12). They all now ask +`priors_utils.get_astropy_cosmology("Planck15")`. + +WHY THAT MATTERED MORE THAN 0.05%. The three are coupled: + * the ILE driver builds the distance prior for the UNmarginalized path and + `util_InitMargTable` for the MARGINALIZED one, and `helper_LDG_Events` hands both the + same `--d-prior` -- so a disagreement means identical CLI gives two different priors + depending only on `--internal-marginalize-distance`; + * `resample_uniform_comoving` DIVIDES OUT the prior the ILE imposed, so the two only cancel + if they are the same object. +For one commit the ILE driver moved to the helper and the other two did not, which created +both defects at once. An adversarial review found it. + +WHY THIS FILE IS A SWEEP, NOT A LIST. The first version of these tests parametrized over a +hand-written TARGETS list that named the LISA driver (which has no cosmology at all, so those +cases were vacuous and could never fail) and omitted the two files that actually violated the +property. A second version discovered TARGETS by looking for files that MENTION a cosmology +class -- which goes vacuous the moment the last construction is removed. So: sweep every +file, assert the construction count is zero, and assert the sweep itself saw a plausible +number of files. A hand-maintained list of what to check is the same mistake as a +hand-maintained cosmology. +""" + +import ast +import os + +import pytest + +_HERE = os.path.dirname(os.path.abspath(__file__)) +_CODE = os.path.abspath(os.path.join(_HERE, '..')) + +# Cosmology classes it is a defect to instantiate outside the helper. +_COSMO_CLASSES = ('FlatLambdaCDM', 'LambdaCDM', 'wCDM', 'FlatwCDM', 'w0waCDM', 'w0wzCDM') + +# The helper IS the source of truth, so it may name and return these freely. +_ALLOWED = ('RIFT/likelihood/priors_utils.py',) + +_LAL_H0_LITERAL = '2.200489137532724e-18' + + +def _python_files(): + """Every python source under bin/ and RIFT/, tests excluded. + + bin/ holds extensionless executables, so selection is by successful parse rather than by + suffix -- picking only *.py would skip util_InitMargTable, which is one of the files this + exists to police. + """ + out = [] + for sub in ('bin', 'RIFT'): + for root, _dirs, files in os.walk(os.path.join(_CODE, sub)): + for f in files: + rel = os.path.relpath(os.path.join(root, f), _CODE) + if rel in _ALLOWED: + continue + if f.endswith(('.pyc', '.ipynb', '.txt', '.md', '.xml', '.dat', '.png')): + continue + if os.sep + 'test' in os.sep + rel or rel.startswith('test'): + continue + try: + with open(os.path.join(_CODE, rel)) as fh: + src = fh.read() + ast.parse(src) + except (IOError, OSError, UnicodeDecodeError, SyntaxError, ValueError): + continue + out.append((rel, src)) + return out + + +@pytest.fixture(scope="module") +def sources(): + return _python_files() + + +def test_the_sweep_covers_a_plausible_number_of_files(sources): + """A broken walk returning [] would make every assertion below vacuous.""" + assert len(sources) > 50, ( + "the sweep parsed only %d files; it is not covering the tree" % len(sources)) + rels = {r for r, _ in sources} + for expect in ('bin/integrate_likelihood_extrinsic_batchmode', + 'bin/util_InitMargTable', + 'bin/resample_uniform_comoving.py'): + assert expect in rels, "the sweep missed %s, which it exists to police" % expect + + +def test_nothing_constructs_its_own_cosmology(sources): + """Matches BOTH call forms: bare name and `astropy.cosmology.FlatLambdaCDM(...)`.""" + offenders = [] + for rel, src in sources: + for n in ast.walk(ast.parse(src)): + if not isinstance(n, ast.Call): + continue + f = n.func + name = (f.id if isinstance(f, ast.Name) + else f.attr if isinstance(f, ast.Attribute) else None) + if name in _COSMO_CLASSES: + offenders.append("%s:%d (%s)" % (rel, n.lineno, name)) + assert not offenders, ( + "these build their own cosmology instead of calling " + "priors_utils.get_astropy_cosmology():\n " + "\n ".join(offenders)) + + +def test_nothing_hardcodes_the_lal_H0_constant(sources): + offenders = ["%s" % rel for rel, src in sources if _LAL_H0_LITERAL in src] + assert not offenders, ( + "these hardcode an H0 that cannot be cited by name in a paper: %s" % offenders) + + +def test_the_framework_helper_defaults_to_Planck15(): + import inspect + + import RIFT.likelihood.priors_utils as priors_utils + assert inspect.signature(priors_utils.get_astropy_cosmology).parameters['name'].default \ + == 'Planck15' + c = priors_utils.get_astropy_cosmology() + assert abs(c.H0.value - 67.74) < 0.01 and abs(c.Om0 - 0.3075) < 0.001 + + +@pytest.mark.parametrize("rel", ['bin/integrate_likelihood_extrinsic_batchmode', + 'bin/util_InitMargTable', + 'bin/resample_uniform_comoving.py']) +def test_the_coupled_three_all_ask_the_helper(rel): + """Named explicitly because these three must agree with EACH OTHER, not merely avoid + hardcoding: two build the distance prior for the two marginalization paths, and the third + divides that prior out again.""" + with open(os.path.join(_CODE, rel)) as fh: + src = fh.read() + assert 'get_astropy_cosmology("Planck15")' in src, \ + "%s does not ask the helper for its cosmology" % rel diff --git a/MonteCarloMarginalizeCode/Code/test/test_fairdraw_double_weighting.py b/MonteCarloMarginalizeCode/Code/test/test_fairdraw_double_weighting.py index ba62f61c7..c20cb94e8 100644 --- a/MonteCarloMarginalizeCode/Code/test/test_fairdraw_double_weighting.py +++ b/MonteCarloMarginalizeCode/Code/test/test_fairdraw_double_weighting.py @@ -286,11 +286,12 @@ def test_the_ile_uses_the_block_form_only_for_a_fair_drawn_export(): over them is finer-grained than the block form -- so the switch must be conditional.""" src = open(_ILE).read() i = src.index('_neff_pooled') - block = src[i - 1800:i + 2000] + block = src[i - 2200:i + 2400] # keyed on whether pooling FLATTENED any block -- not on a record-level flag, which the # pooling step two hundred lines above clears, making this branch dead assert '_blocks_flattened' in block, 'the switch is unconditional or dead' - assert '_kish_neff_of_rvs(sampler._rvs)' in block, \ + # whitespace-insensitive: the call gained a record= argument and wrapped across lines + assert '_kish_neff_of_rvs(sampler._rvs' in ''.join(block.split()).replace(',record', ''), \ 'the non-flattened path no longer uses the pooled Kish' @@ -430,8 +431,9 @@ def test_rejecting_the_warm_pass_restores_the_cold_reserve(): then seeds the next intrinsic point from. Snapshot and restore must move together.""" ns = {} src = open(_ILE).read() - start = src.index("def _snapshot_pass_state") + start = src.index("def _rebound_record") # _snapshot_pass_state calls it end = src.index("def _warm_seed_geometry") + ns.update({"numpy": np, "np": np}) exec(compile(src[start:end], "ile_state_helpers", "exec"), ns) class _S(object): @@ -461,8 +463,9 @@ def test_the_restore_reaches_portfolio_member_reserves_too(): aggregate would leave that fallback pointing at the rejected warm pass.""" ns = {} src = open(_ILE).read() - start = src.index("def _snapshot_pass_state") + start = src.index("def _rebound_record") # _snapshot_pass_state calls it end = src.index("def _warm_seed_geometry") + ns.update({"numpy": np, "np": np}) exec(compile(src[start:end], "ile_state_helpers", "exec"), ns) class _S(object): @@ -553,11 +556,23 @@ def test_the_block_kish_branch_is_reachable_after_pooling(): @pytest.mark.skipif(not os.path.exists(_ILE), reason='ILE executable not in this tree') def test_the_posterior_weight_helper_asks_the_equal_weight_question(): + # Take the function's ACTUAL extent, not a magic character count: the previous version + # sliced 2600 chars and started failing the moment the docstring grew, which reads as a + # regression in the code rather than in the test. + import ast as _ast src = open(_ILE).read() - i = src.index('def ln_weights_for_posterior') - body = src[i:i + 2600] + body = None + for _n in _ast.walk(_ast.parse(src)): + if isinstance(_n, _ast.FunctionDef) and _n.name == 'ln_weights_for_posterior': + body = _ast.get_source_segment(src, _n) + assert body is not None, 'ln_weights_for_posterior has gone' assert '_rvs_is_equal_weight(sampler)' in body assert '_rvs_is_export_resample(sampler)' not in body + # ...and the weight itself now comes from the record + assert '_rec.log_weights(' in body, \ + 'the weight is still derived outside the record; the migration is incomplete' + assert 'convert=convert' in body, \ + "the caller's converter is dropped on the record path" ### diff --git a/MonteCarloMarginalizeCode/Code/test/test_l0_rescue_seed.py b/MonteCarloMarginalizeCode/Code/test/test_l0_rescue_seed.py index bc9c2df1a..3b6c88379 100644 --- a/MonteCarloMarginalizeCode/Code/test/test_l0_rescue_seed.py +++ b/MonteCarloMarginalizeCode/Code/test/test_l0_rescue_seed.py @@ -832,6 +832,11 @@ def test_the_reject_gate_reads_both_sides_from_the_same_record(): 'nothing stops the gate comparing a retained-set lnZ against a fair-drawn one' assert 'lnZ_from_reserve' in src, \ 'the reserve reading still averages over stored rows instead of over the draws made' - # the cold reserve must be snapshotted before the warm pass overwrites it - assert block.index('_cold_reserve_l0') < block.index('sampler.integrate('), \ + # the cold reserve must be snapshotted before the warm pass overwrites it. Asserted on + # the WHOLE source between the two anchors rather than inside a fixed-size window: the + # window version started failing when unrelated lines were added between them, which reads + # as a regression in the gate rather than in the test. + _i_res = src.index('_cold_reserve_l0') + _i_int = src.index('sampler.integrate(', _i_res) + assert _i_res < _i_int, \ 'the cold reserve is read after the warm pass has already replaced it' diff --git a/MonteCarloMarginalizeCode/Code/test/test_lisa_av_state.py b/MonteCarloMarginalizeCode/Code/test/test_lisa_av_state.py new file mode 100644 index 000000000..db39cc320 --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/test/test_lisa_av_state.py @@ -0,0 +1,407 @@ +#!/usr/bin/env python +""" +Tests for the AV live-volume state, per-axis bin allocation and collapse gate ported into +the LISA ILE driver (bin/integrate_likelihood_extrinsic_batchmode_lisa). + +Four options, all sampler-agnostic: --sampler-save-state / --sampler-load-state (the AV +grid, which carries no detector convention), --sampler-anisotropic-bins, and +--reject-collapsed-live-volume. + +THE ONE THING TO KNOW. The main driver calls its collapse gate TWICE -- once on the first +run, and again on the replica POOL, because replication can turn a healthy first run into a +collapsed pool. This driver has no replica pooling yet, so only the first call exists here. +When --mc-error-replicas is ported the second call MUST come with it, or the flag is +silently bypassed for exactly the case pooling introduces. That is recorded at the helper, +in the drift ledger, and asserted below. +""" + +import ast +import os +import textwrap + +import pytest + +_HERE = os.path.dirname(os.path.abspath(__file__)) +_LISA = os.path.join(_HERE, '..', 'bin', 'integrate_likelihood_extrinsic_batchmode_lisa') +_MAIN = os.path.join(_HERE, '..', 'bin', 'integrate_likelihood_extrinsic_batchmode') + +OPTS = ["--sampler-save-state", "--sampler-load-state", + "--sampler-anisotropic-bins", "--reject-collapsed-live-volume"] + +HELPERS = ['_maybe_load_av_state', '_maybe_save_av_state', + '_maybe_enable_anisotropic_bins', '_reject_if_collapsed', + '_report_and_gate_collapse'] + + +def _src(path): + with open(path) as fh: + return fh.read() + + +def _option_nodes(path): + out = {} + for n in ast.walk(ast.parse(_src(path), filename=path)): + if (isinstance(n, ast.Call) and isinstance(n.func, ast.Attribute) + and n.func.attr in ("add_option", "add_argument")): + names = [a.value for a in n.args + if isinstance(a, ast.Constant) and isinstance(a.value, str)] + if names and names[0].startswith("--"): + out[names[0]] = n + return out + + +def _kwargs_of(node): + out = {} + for kw in node.keywords: + try: + out[kw.arg] = ast.literal_eval(kw.value) + except Exception: + out[kw.arg] = ast.dump(kw.value) + return out + + +class _Collapse(Exception): + pass + + +class _AVModule(object): + LiveVolumeCollapse = _Collapse + + +def _load(**optkw): + base = {"sampler_load_state": None, "sampler_save_state": None, + "sampler_anisotropic_bins": False, "reject_collapsed_live_volume": False, + "sampler_method": "AV"} + base.update(optkw) + defs = {n.name: n for n in ast.parse(_src(_LISA)).body + if isinstance(n, ast.FunctionDef) and n.name in HELPERS} + missing = sorted(set(HELPERS) - set(defs)) + assert not missing, "LISA driver is missing: %s" % missing + mod = ast.Module(body=[defs[n] for n in HELPERS], type_ignores=[]) + ns = {"opts": type("O", (), base)(), + "mcsamplerAdaptiveVolume": _AVModule, "mcsampler_AV_ok": True} + exec(compile(ast.fix_missing_locations(mod), "av_state", "exec"), ns) + return ns + + +# ------------------------------------------------------------------------------- options +@pytest.mark.parametrize("opt", OPTS) +def test_option_present_and_matches_the_main_driver(opt): + a, b = _kwargs_of(_option_nodes(_LISA)[opt]), _kwargs_of(_option_nodes(_MAIN)[opt]) + for key in ("default", "type", "action", "choices"): + assert a.get(key) == b.get(key), "%s: %s differs" % (opt, key) + + +# ---------------------------------------------------------------------------- load / save +class _AV(object): + def __init__(self): + self.loaded = self.saved = None + + def load_state(self, p): + self.loaded = p + + def save_state(self, p): + self.saved = p + + +class _NoState(object): + pass + + +def test_state_hooks_are_noops_when_unset(): + ns = _load() + s = _AV() + ns['_maybe_load_av_state'](s) + ns['_maybe_save_av_state'](s) + assert s.loaded is None and s.saved is None + + +def test_state_round_trip_reaches_the_sampler(): + ns = _load(sampler_load_state="/in.npz", sampler_save_state="/out.npz") + s = _AV() + ns['_maybe_load_av_state'](s) + ns['_maybe_save_av_state'](s) + assert s.loaded == "/in.npz" and s.saved == "/out.npz" + + +def test_save_state_is_restricted_to_the_AV_method(): + """Main gates the save on sampler_method == 'AV'; a portfolio's aggregate has no such grid.""" + ns = _load(sampler_save_state="/out.npz", sampler_method="portfolio") + s = _AV() + ns['_maybe_save_av_state'](s) + assert s.saved is None + + +def test_rejected_or_failed_rescue_state_is_not_saved(capsys): + """The warm grid may outlive restoration of the cold result; never persist that mismatch.""" + ns = _load(sampler_save_state="/out.npz") + s = _AV() + s._av_state_reuse_safe = False + ns['_maybe_save_av_state'](s) + assert s.saved is None + assert "not saving" in capsys.readouterr().out + + +def test_state_hooks_tolerate_a_sampler_without_state_support(): + ns = _load(sampler_load_state="/in.npz", sampler_save_state="/out.npz") + ns['_maybe_load_av_state'](_NoState()) + ns['_maybe_save_av_state'](_NoState()) + + +def test_a_bad_state_file_degrades_to_a_cold_run(): + """A missing/corrupt state must not kill the point.""" + ns = _load(sampler_load_state="/in.npz", sampler_save_state="/out.npz") + + class _Boom(object): + def load_state(self, p): + raise IOError("nope") + + def save_state(self, p): + raise IOError("read-only") + + ns['_maybe_load_av_state'](_Boom()) + ns['_maybe_save_av_state'](_Boom()) + + +# --------------------------------------------------------------------------- anisotropic +class _Binned(object): + anisotropic_bins = False + + +def test_anisotropic_bins_is_opt_in(): + ns = _load() + s = _Binned() + ns['_maybe_enable_anisotropic_bins'](s) + assert s.anisotropic_bins is False + + +def test_anisotropic_bins_reaches_portfolio_members_too(): + """The grid lives on the MEMBERS; setting it only on the aggregate would do nothing.""" + ns = _load(sampler_anisotropic_bins=True) + m1, m2 = _Binned(), _Binned() + s = _Binned() + s.portfolio_realizations = [m1, m2] + ns['_maybe_enable_anisotropic_bins'](s) + assert s.anisotropic_bins and m1.anisotropic_bins and m2.anisotropic_bins + + +def test_anisotropic_bins_skips_members_that_do_not_support_it(): + ns = _load(sampler_anisotropic_bins=True) + s = _Binned() + s.portfolio_realizations = [_NoState()] + ns['_maybe_enable_anisotropic_bins'](s) # must not raise + assert s.anisotropic_bins is True + + +# -------------------------------------------------------------------------- collapse gate +COLLAPSED = {'live_volume_collapsed': True, 'collapse_reason': 'zero volume'} +HEALTHY = {'live_volume_collapsed': False} + + +def test_gate_is_inert_when_the_flag_is_off(): + _load()['_reject_if_collapsed'](COLLAPSED, "first run") + + +def test_gate_is_inert_on_a_healthy_run(): + _load(reject_collapsed_live_volume=True)['_reject_if_collapsed'](HEALTHY, "first run") + + +def test_gate_raises_when_flag_set_and_run_collapsed(): + with pytest.raises(_Collapse): + _load(reject_collapsed_live_volume=True)['_reject_if_collapsed'](COLLAPSED, "first run") + + +def test_gate_message_names_the_stage_and_reason(): + """The stage is in the message because the main driver calls this at two stages.""" + with pytest.raises(_Collapse) as e: + _load(reject_collapsed_live_volume=True)['_reject_if_collapsed'](COLLAPSED, "pooled") + assert "pooled" in str(e.value) and "zero volume" in str(e.value) + + +@pytest.mark.parametrize("dd", [None, "not a dict", {}]) +def test_gate_tolerates_a_missing_or_malformed_dict_return(dd): + _load(reject_collapsed_live_volume=True)['_reject_if_collapsed'](dd, "first run") + + +def test_report_announces_a_collapse_even_when_the_gate_is_off(capsys): + """Not rejecting is not the same as not telling anyone.""" + ns = _load() + capsys.readouterr() + ns['_report_and_gate_collapse'](COLLAPSED) + out = capsys.readouterr().out + assert "LIVE VOLUME COLLAPSED" in out and "NOT a fair draw" in out + + +def test_report_says_nothing_on_a_healthy_run(capsys): + ns = _load() + capsys.readouterr() + ns['_report_and_gate_collapse'](HEALTHY) + assert "COLLAPSED" not in capsys.readouterr().out + + +def test_report_still_raises_when_gated(): + with pytest.raises(_Collapse): + _load(reject_collapsed_live_volume=True)['_report_and_gate_collapse'](COLLAPSED) + + +def test_gate_falls_back_to_RuntimeError_without_AV(): + """mcsampler_AV_ok False -> the AV exception class is unavailable.""" + defs = {n.name: n for n in ast.parse(_src(_LISA)).body + if isinstance(n, ast.FunctionDef) and n.name == '_reject_if_collapsed'} + mod = ast.Module(body=[defs['_reject_if_collapsed']], type_ignores=[]) + ns = {"opts": type("O", (), {"reject_collapsed_live_volume": True})(), + "mcsamplerAdaptiveVolume": None, "mcsampler_AV_ok": False} + exec(compile(ast.fix_missing_locations(mod), "av_state", "exec"), ns) + with pytest.raises(RuntimeError): + ns['_reject_if_collapsed'](COLLAPSED, "first run") + + +# ------------------------------------------------------------------------- call-site wiring +def test_both_analyze_event_variants_get_every_hook(): + tree = ast.parse(_src(_LISA)) + fns = {n.name: n for n in tree.body + if isinstance(n, ast.FunctionDef) and n.name in ('analyze_event', 'analyze_event_LISA')} + assert set(fns) == {'analyze_event', 'analyze_event_LISA'} + for name, node in fns.items(): + called = {c.func.id for c in ast.walk(node) + if isinstance(c, ast.Call) and isinstance(c.func, ast.Name)} + for hook in ('_maybe_load_av_state', '_maybe_enable_anisotropic_bins', + '_maybe_replicate_for_mc_error'): + assert hook in called, "%s does not call %s" % (name, hook) + # The SAVE is reached through the replica helper, which sequences it after the + # first-run gate (see test_hook_ordering_at_both_call_sites). Calling it here too + # would write a grid the gate has not yet approved. + assert '_maybe_save_av_state' not in called, ( + "%s saves AV state directly, bypassing the collapse gate the helper puts in " + "front of it" % name) + + +def test_hook_ordering_at_both_call_sites(): + """Only a nonempty, COLLAPSE-APPROVED result may persist its live-volume state. + + The save now lives inside _maybe_replicate_for_mc_error, doubly constrained: + * AFTER the first-run gate, so a grid that --reject-collapsed-live-volume rejects is + never written (otherwise the next point warm-starts from the degenerate volume); + * BEFORE the replica loop, or it persists the LAST replica's grid. + + An earlier revision of this test dropped the gate= 2, ( + "the replica helper performs %d collapse-gate call(s); it needs the first-run gate " + "AND the pooled-verdict gate" % len(gates)) + assert "pooled over" in fn, "the pooled gate does not label its stage" + + +def test_analyze_event_does_not_gate_collapse_itself(): + """The helper owns both gates; a direct call here would duplicate the first-run one.""" + for n in ast.parse(_src(_LISA)).body: + if isinstance(n, ast.FunctionDef) and n.name in ("analyze_event", "analyze_event_LISA"): + names = {c.func.id for c in ast.walk(n) + if isinstance(c, ast.Call) and isinstance(c.func, ast.Name)} + assert "_report_and_gate_collapse" not in names, \ + "%s calls the collapse gate directly" % n.name + assert "_maybe_replicate_for_mc_error" in names, \ + "%s never runs the replica/gate helper" % n.name + +def _named(path, name): + for n in ast.walk(ast.parse(_src(path))): + if isinstance(n, ast.FunctionDef) and n.name == name: + return n + raise AssertionError("%s not found in %s" % (name, os.path.basename(path))) + + +def _normalized(fn): + node = ast.parse(ast.unparse(fn)).body[0] if hasattr(ast, "unparse") else fn + body = list(node.body) + if (body and isinstance(body[0], ast.Expr) + and isinstance(getattr(body[0], "value", None), ast.Constant) + and isinstance(body[0].value.value, str)): + body = body[1:] + return ast.dump(ast.fix_missing_locations(ast.Module(body=body, type_ignores=[]))) + + +def test_reject_if_collapsed_body_is_identical_to_the_main_drivers(): + """Hoisted out of analyze_event here, but the body must not have changed with it.""" + assert (_normalized(_named(_LISA, '_reject_if_collapsed')) + == _normalized(_named(_MAIN, '_reject_if_collapsed'))), \ + "_reject_if_collapsed has drifted between the two drivers (docstrings excluded)" diff --git a/MonteCarloMarginalizeCode/Code/test/test_lisa_driver_drift.py b/MonteCarloMarginalizeCode/Code/test/test_lisa_driver_drift.py new file mode 100644 index 000000000..52fe0d0be --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/test/test_lisa_driver_drift.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python +""" +Drift gate for the LISA ILE driver. + +The two ILE drivers are a DELIBERATE fork: + + bin/integrate_likelihood_extrinsic_batchmode <- main, moves fast + bin/integrate_likelihood_extrinsic_batchmode_lisa <- LISA, lags + +RO, 2026-08-13: "It is super annoying we have to have two of them, but the overhead of one +ring to rule them all is too high." So this gate does NOT try to close the gap, and does +not assert that any particular item was ported. Closing the gap is not the goal. + +What it asserts is that nothing drifts in UNNOTICED: every helper, CLI option, module +constant and sampler provenance marker present in the main driver and absent from the LISA +one carries a recorded decision -- PORT / PORTED / NA / PHYSICS -- with a reason. "Does not +apply to LISA" is a fine answer; silence is not. + +When this fails, the fix is to classify the new item, not to delete the test: + + cd test/expensive_before_merging/integrators + python3 audit_lisa_driver_drift.py --undecided # what is unclassified + $EDITOR make_lisa_drift_ledger.py # add a rule, with a reason + python3 make_lisa_drift_ledger.py # regenerate the ledger + +This exists because 2,357 lines of drift accumulated while the LISA driver's nine CI tests +(all import/contract/smoke level) stayed green. +""" + +import os +import sys + +import pytest + +_HERE = os.path.dirname(os.path.abspath(__file__)) +_AUDIT_DIR = os.path.join(_HERE, 'expensive_before_merging', 'integrators') + +if _AUDIT_DIR not in sys.path: + sys.path.insert(0, _AUDIT_DIR) + +audit = pytest.importorskip("audit_lisa_driver_drift", + reason="LISA drift auditor not present") + + +@pytest.fixture(scope="module") +def state(): + gap, extras = audit.compute_gap() + ledger = audit.load_ledger() + return audit.annotate(gap, ledger), extras, ledger + + +def test_the_gap_is_non_empty_so_the_audit_is_actually_looking(state): + """Guard against a silently broken extractor reporting a clean tree.""" + gap, _extras, _ledger = state + assert len(gap) > 0, "the audit found no drift at all, which almost certainly means " \ + "the extractor broke rather than that the drivers converged" + + +def test_every_gap_item_carries_a_recorded_decision(state): + gap, _extras, _ledger = state + undecided = [g for g in gap if g["decision"] is None] + assert not undecided, ( + "%d item(s) drifted into the main ILE driver with no recorded decision about the " + "LISA driver:\n%s\n\nClassify each as PORT / PORTED / NA / PHYSICS with a reason " + "in make_lisa_drift_ledger.py, then regenerate the ledger." + % (len(undecided), "\n".join(" %s (main:%d)" % (g["key"], g["main_line"]) + for g in undecided))) + + +def test_no_item_claims_to_be_ported_while_still_missing(state): + """A PORTED verdict is a claim about the tree, so the tree gets to contradict it. + + This is the regression direction: if a ported helper is later deleted from the LISA + driver, the item reappears in the gap still marked PORTED, and this fails. + """ + gap, _extras, _ledger = state + stale = [g for g in gap if g["decision"] == "PORTED"] + assert not stale, ( + "marked PORTED but absent from the LISA driver: %s" + % ", ".join(g["key"] for g in stale)) + + +def test_every_decision_is_a_known_verdict(state): + gap, _extras, _ledger = state + bad = sorted({g["decision"] for g in gap + if g["decision"] is not None and g["decision"] not in audit.DECISIONS}) + assert not bad, "unknown decision value(s) in the ledger: %s" % bad + + +def test_every_decision_carries_a_reason(state): + """A verdict without a reason is silence with extra steps.""" + gap, _extras, _ledger = state + thin = [g["key"] for g in gap + if g["decision"] is not None and len((g["reason"] or "").strip()) < 20] + assert not thin, "decision recorded with no usable reason: %s" % ", ".join(thin) + + +def test_ledger_has_no_entries_for_items_outside_the_gap(state): + """Spent entries are not a failure, but they should not pile up as fiction. + + An entry naming something no longer in the gap means it was ported or the main driver + dropped it; regenerating the ledger clears it. + """ + gap, _extras, ledger = state + gap_keys = {g["key"] for g in gap} + spent = sorted(k for k in ledger if k not in gap_keys) + assert not spent, ("ledger describes %d item(s) that are no longer in the gap: %s\n" + "Regenerate with make_lisa_drift_ledger.py." + % (len(spent), ", ".join(spent))) + + +def test_the_committed_ledger_matches_what_its_generator_produces(): + """The ledger is GENERATED. Nothing enforced that until this test. + + An adversarial audit added an option to the main driver and hand-wrote a + ``{"decision": "NA", "reason": "..."}`` entry straight into the JSON: the whole gate + passed while make_lisa_drift_ledger.py still reported the item as matching no rule. + The stated property -- that a person has to classify new drift AS A RULE, with a reason + -- was silenceable by a one-line JSON edit. + + So regenerate in memory and compare. This also catches a ledger left stale after the + main driver moved. + """ + gen = pytest.importorskip("make_lisa_drift_ledger", + reason="LISA drift ledger generator not present") + gap, _extras = audit.compute_gap() + expected, unmatched = {}, [] + for item in gap: + decision, reason = gen.classify(item["key"]) + if decision is None: + unmatched.append(item["key"]) + else: + expected[item["key"]] = {"decision": decision, "reason": reason} + + assert not unmatched, ( + "%d gap item(s) match no rule in make_lisa_drift_ledger.py: %s\n" + "Add a rule with a reason -- do not hand-edit the JSON." + % (len(unmatched), ", ".join(unmatched))) + + committed = audit.load_ledger() + assert committed == expected, ( + "lisa_drift_ledger.json does not match make_lisa_drift_ledger.py.\n" + "Regenerate it (python3 make_lisa_drift_ledger.py) rather than editing the JSON:\n" + " only in committed: %s\n only in generated: %s\n differing: %s" + % (sorted(set(committed) - set(expected)), + sorted(set(expected) - set(committed)), + sorted(k for k in set(committed) & set(expected) if committed[k] != expected[k]))) + + +def test_the_fairdraw_helpers_ported_in_this_pass_are_present_in_lisa(): + """Belt and braces: name them, so deleting one fails here as well as via the ledger.""" + lisa = audit.collect(audit.LISA) + for name in ('ln_weights_from_rvs', 'ln_weights_for_posterior', + '_rvs_is_export_resample', '_rvs_is_equal_weight', '_rvs_len', + '_rvs_lnL_convention'): + assert name in lisa["FUNC"], "%s is missing from the LISA driver" % name + for marker in ('_rvs_is_fairdraw', '_rvs_is_pooled'): + assert marker in lisa["ATTR"], "%s is no longer read by the LISA driver" % marker diff --git a/MonteCarloMarginalizeCode/Code/test/test_lisa_fairdraw_weights.py b/MonteCarloMarginalizeCode/Code/test/test_lisa_fairdraw_weights.py new file mode 100644 index 000000000..18f275204 --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/test/test_lisa_fairdraw_weights.py @@ -0,0 +1,401 @@ +#!/usr/bin/env python +""" +Tests for the fair-draw weighting helpers ported into the LISA ILE driver +(bin/integrate_likelihood_extrinsic_batchmode_lisa) from the main driver, PR #87. + +WHY THE LISA DRIVER NEEDS THEM AT ALL. The three consumers whose double-weighting PR #87 +fixed -- the `--extrinsic-proposal-output` breadcrumb, the `.dgrid` exporter and the +`.dslice` reweight core -- do not exist in the LISA driver, so there is no live w^2 bug +there today. What DOES exist is the hazard: the LISA driver sets +`igrand_fairdraw_samples` from `--fairdraw-extrinsic-output`, so its `_rvs` can be a fair +draw, and every shared sampler in RIFT/integrators/ already sets `_rvs_is_fairdraw` at its +rebind. The marker was arriving and nothing read it. These tests pin the readers. + +TWO DISTINCT PROPERTIES, deliberately not one flag (audit Finding 6): + + rows resampled -- each row drawn proportional to w (per-BLOCK property) + equal weight -- the record as a whole is uniform (property of the WHOLE record) + +and the anti-drift test at the bottom pins the LISA copies to the main driver's, because +these are deliberate COPIES in a deliberate fork, not an import. + +Conventions follow test_fairdraw_double_weighting.py and test_l0_rescue_seed.py: the driver +scripts are not importable (they parse argv at import), so the helpers are exec'd out. +""" + +import ast +import os + +import numpy as np +from RIFT.integrators.rvs_record import SamplerOutputMixin as _SamplerOutputMixin +import pytest + +_HERE = os.path.dirname(os.path.abspath(__file__)) +_LISA = os.path.join(_HERE, '..', 'bin', 'integrate_likelihood_extrinsic_batchmode_lisa') +_MAIN = os.path.join(_HERE, '..', 'bin', 'integrate_likelihood_extrinsic_batchmode') + +# The helpers ported in this pass. Named explicitly: if a future edit drops one, the +# extraction below fails loudly rather than silently testing a smaller surface. +# The record accessors are in this list DELIBERATELY: it is both the exec set and the +# anti-drift set, so naming them here fixes the namespace AND puts them under the +# change-one-change-both gate, which is where a shared-by-copy helper belongs. +PORTED = ['_rvs_lnL_convention', 'ln_weights_from_rvs', '_rvs_len', + '_rvs_is_export_resample', '_rvs_is_equal_weight', + '_rvs_record_for', '_sampler_keeps_records', '_internal_record_of', + '_rebound_record', '_lw_of', 'ln_weights_for_posterior'] + + + +def _driver_def_names(path): + """Every top-level name the driver BINDS: functions and imports alike. + + Imports are in here because of a real miss: the guard originally covered only defs, so + `SamplerOutputMixin` -- imported by the driver, referenced by _sampler_keeps_records -- + slipped straight through it and surfaced as a NameError inside an exec'd helper. + """ + with open(path) as fh: # read directly: _src() differs between these harnesses + src = fh.read() + names = set() + for n in ast.parse(src).body: + if isinstance(n, ast.FunctionDef): + names.add(n.name) + elif isinstance(n, (ast.Import, ast.ImportFrom)): + for a in n.names: + if a.name != '*': + names.add(a.asname or a.name.split('.')[0]) + return names + + +def _assert_helper_set_is_closed(ns, names, path): + """Fail LOUDLY if an exec'd helper calls a driver helper that was not exec'd with it. + + The same omission in test_lisa_mc_error_replicas.py did NOT raise: _lnZ_of_rvs catches + broadly and returns None, so a missing name read as "no evidence" and the pooled + weights silently collapsed to 1/K. Kept in all three LISA harnesses so the next + ported helper cannot reintroduce it here instead. + """ + driver = _driver_def_names(path) + missing = {} + for name in names: + code = getattr(ns.get(name), "__code__", None) + if code is None: + continue + stack, seen = [code], set() + while stack: + c = stack.pop() + if id(c) in seen: + continue + seen.add(id(c)) + for used in c.co_names: + if used in driver and used not in ns: + missing.setdefault(name, set()).add(used) + stack.extend(k for k in c.co_consts if hasattr(k, "co_names")) + assert not missing, ( + "exec'd helper set is not closed -- add these to the name list:\n " + + "\n ".join("%s needs %s" % (k, sorted(v)) for k, v in sorted(missing.items()))) + +def _extract(path, names): + """Return {name: ast.FunctionDef} for top-level defs, by name.""" + with open(path) as fh: + tree = ast.parse(fh.read(), filename=path) + found = {n.name: n for n in tree.body + if isinstance(n, ast.FunctionDef) and n.name in names} + missing = sorted(set(names) - set(found)) + assert not missing, "%s is missing ported helper(s): %s" % (os.path.basename(path), missing) + return found + + +def _load(path, names=PORTED): + """Exec the named helpers out of a driver script into a namespace.""" + defs = _extract(path, names) + mod = ast.Module(body=[defs[n] for n in names], type_ignores=[]) + ns = {"numpy": np, "np": np, "SamplerOutputMixin": _SamplerOutputMixin} + exec(compile(ast.fix_missing_locations(mod), "lisa_weight_helpers", "exec"), ns) + _assert_helper_set_is_closed(ns, names, _LISA) + return ns + + +@pytest.fixture(scope="module") +def H(): + return _load(_LISA) + + +# --------------------------------------------------------------------------- record builders +def _log_record(n=6, seed=0): + rng = np.random.default_rng(seed) + return {'log_integrand': rng.normal(size=n) * 3.0, + 'log_joint_prior': rng.normal(size=n), + 'log_joint_s_prior': rng.normal(size=n), + 'right_ascension': rng.uniform(0, 2 * np.pi, size=n)} + + +def _linear_record(n=6, seed=1, lnL=False): + rng = np.random.default_rng(seed) + ig = (rng.normal(size=n) * 3.0) if lnL else rng.uniform(0.1, 5.0, size=n) + return {'integrand': ig, + 'joint_prior': rng.uniform(0.1, 2.0, size=n), + 'joint_s_prior': rng.uniform(0.1, 2.0, size=n), + 'psi': rng.uniform(0, np.pi, size=n)} + + +class _FakeSampler(object): + def __init__(self, fairdraw=None, pooled=None): + if fairdraw is not None: + self._rvs_is_fairdraw = fairdraw + if pooled is not None: + self._rvs_is_pooled = pooled + + +# ------------------------------------------------------------------- ln_weights_from_rvs +def test_log_form_is_the_canonical_combination(H): + r = _log_record() + got = H['ln_weights_from_rvs'](r) + want = r['log_integrand'] + r['log_joint_prior'] - r['log_joint_s_prior'] + assert np.allclose(got, want) + + +def test_log_form_preferred_over_linear_when_both_present(H): + """The log columns win. A record carrying both must not be read the linear way.""" + r = _log_record() + r.update({'integrand': np.full(len(r['log_integrand']), 1.0), + 'joint_prior': np.full(len(r['log_integrand']), 1.0), + 'joint_s_prior': np.full(len(r['log_integrand']), 1.0)}) + got = H['ln_weights_from_rvs'](r) + want = r['log_integrand'] + r['log_joint_prior'] - r['log_joint_s_prior'] + assert np.allclose(got, want), "linear columns shadowed the canonical log ones" + + +def test_linear_form_linear_convention(H): + r = _linear_record(lnL=False) + got = H['ln_weights_from_rvs'](r, use_lnL=False) + want = np.log(r['integrand']) + np.log(r['joint_prior']) - np.log(r['joint_s_prior']) + assert np.allclose(got, want) + + +def test_linear_form_out_of_support_rows_are_minus_inf(H): + r = _linear_record(lnL=False) + r['joint_prior'][2] = 0.0 # zero prior -> out of support + r['integrand'][4] = 0.0 # zero L -> out of support + got = H['ln_weights_from_rvs'](r, use_lnL=False) + assert got[2] == -np.inf and got[4] == -np.inf + assert np.isfinite(got[[0, 1, 3, 5]]).all() + + +def test_lnL_convention_does_not_log_twice_and_keeps_negative_lnL(H): + """The bug this argument exists for. + + mcsamplerEnsemble reuses 'integrand' for BOTH conventions. Under return_lnI it holds + lnL, so the linear reading would (a) take log() of it, compressing tens of nats into + log(tens), and (b) apply `ig > 0`, silently discarding every sample with lnL <= 0. + """ + r = _linear_record(lnL=True) + r['integrand'][0] = -12.5 # a perfectly good low-likelihood point + got = H['ln_weights_from_rvs'](r, use_lnL=True) + want = r['integrand'] + np.log(r['joint_prior']) - np.log(r['joint_s_prior']) + assert np.allclose(got, want) + assert np.isfinite(got[0]), "a negative lnL row was discarded as out-of-support" + + wrong = H['ln_weights_from_rvs'](r, use_lnL=False) + assert not np.allclose(np.nan_to_num(wrong, neginf=-1e9), got), \ + "the two conventions agree, so this test cannot detect reading lnL as L" + + +def test_raises_when_neither_component_set_is_present(H): + """An explicit failure beats a plausible wrong number.""" + with pytest.raises(Exception): + H['ln_weights_from_rvs']({'psi': np.zeros(4), 'log_weights': np.zeros(4)}) + + +def test_cached_log_weights_column_is_never_read(H): + """mcsamplerGPU stores the ADAPTATION weight there, with adapt-weight-exponent baked in.""" + r = _log_record() + r['log_weights'] = np.full(len(r['log_integrand']), 999.0) + got = H['ln_weights_from_rvs'](r) + assert not np.allclose(got, 999.0) + + +# ------------------------------------------------------------------------ the two predicates +@pytest.mark.parametrize("fairdraw,pooled,resample,equal", [ + (None, None, False, False), # markers absent entirely -> both False, no AttributeError + (False, False, False, False), + (True, False, True, True), # a plain fair draw has BOTH properties + (True, True, True, False), # pooled: rows resampled, record NOT globally uniform + (False, True, False, False), +]) +def test_predicate_truth_table(H, fairdraw, pooled, resample, equal): + s = _FakeSampler(fairdraw, pooled) + assert H['_rvs_is_export_resample'](s) is resample + assert H['_rvs_is_equal_weight'](s) is equal + + +def test_predicates_differ_on_a_pooled_record(H): + """The Finding-6 property: one flag cannot answer both questions.""" + s = _FakeSampler(fairdraw=True, pooled=True) + assert H['_rvs_is_export_resample'](s) != H['_rvs_is_equal_weight'](s) + + +# --------------------------------------------------------------- ln_weights_for_posterior +def test_fair_drawn_record_gets_uniform_posterior_weights(H): + """The anti-double-weighting property: rows already ~w must not be weighted by w again.""" + r = _log_record() + w = H['ln_weights_for_posterior'](r, _FakeSampler(fairdraw=True, pooled=False)) + assert w.shape == (len(r['log_integrand']),) + assert np.allclose(w, 0.0) + + +def test_non_fairdrawn_record_gets_the_importance_weights(H): + r = _log_record() + s = _FakeSampler(fairdraw=False, pooled=False) + assert np.allclose(H['ln_weights_for_posterior'](r, s), H['ln_weights_from_rvs'](r)) + + +def test_pooled_record_keeps_its_between_block_weights(H): + """Pooling weights block k by the replica evidence: uniform here would discard that.""" + r = _log_record() + w = H['ln_weights_for_posterior'](r, _FakeSampler(fairdraw=True, pooled=True)) + assert not np.allclose(w, 0.0) + assert np.allclose(w, H['ln_weights_from_rvs'](r)) + + +def test_double_weighting_would_shift_a_posterior_mean(H): + """Why it matters, not just that it differs. + + Build a record whose weight correlates with a coordinate, fair-draw it, then compare the + mean under the correct (uniform) weights against the mean under a second application of + w. The second application concentrates toward high-w rows and moves the answer. + """ + rng = np.random.default_rng(7) + n = 4000 + x = rng.uniform(0.0, 1.0, size=n) + lnw = 4.0 * x # weight correlated with the coordinate + w = np.exp(lnw - lnw.max()) + idx = rng.choice(n, size=n, replace=True, p=w / w.sum()) # the fair draw + rec = {'log_integrand': lnw[idx], 'log_joint_prior': np.zeros(n), + 'log_joint_s_prior': np.zeros(n), 'x': x[idx]} + + correct = H['ln_weights_for_posterior'](rec, _FakeSampler(fairdraw=True, pooled=False)) + assert np.allclose(correct, 0.0) + mean_correct = np.average(rec['x'], weights=np.exp(correct - correct.max())) + + doubled = H['ln_weights_from_rvs'](rec) # what the pre-fix consumers did + mean_doubled = np.average(rec['x'], weights=np.exp(doubled - doubled.max())) + + shift = abs(mean_doubled - mean_correct) / abs(mean_correct) + assert shift > 0.05, ("double weighting should move the posterior mean materially; " + "got %.3f%%" % (100 * shift)) + + +# ----------------------------------------------------------------------------- _rvs_len +def test_rvs_len_counts_rows(H): + assert H['_rvs_len'](_log_record(n=9)) == 9 + + +def test_rvs_len_survives_an_unsized_entry(H): + r = _log_record(n=5) + r['not_an_array'] = None + assert H['_rvs_len'](r) == 5 + + +def _record_with_a_combined_parameter(n=6): + """Columns in the order a sampler seeds them: PARAMETERS FIRST, then the weight columns. + + The order is the whole point. A parameter registered under a TUPLE key is a combined + parameter stored (ndim, N) -- the convention every sampler indexes by, `col[:, idx]` for a + tuple key against `col[idx]` otherwise -- and it is seeded before the weight columns, so + "whichever column came first" lands on it in the ordinary case rather than a corner. + """ + r = {('mc', 'delta_mc'): np.zeros((2, n))} + r.update(_log_record(n=n)) + return r + + +def test_rvs_len_counts_ROWS_not_entries_for_a_combined_parameter(): + """ndim*N is not a row count, and it is not a cosmetic one either. + + Both drivers: the LISA copy checks the pooled export's weight vector against this number, + so an inflated count made the check fail and shipped the pooled record weight-mixed; the + main copy hands back a uniform vector OF THIS LENGTH for a fair draw and records it as the + pooled `block_sizes`. + """ + r = _record_with_a_combined_parameter(n=6) + for path in (_LISA, _MAIN): + assert _load(path)['_rvs_len'](r) == 6, os.path.basename(path) + + +def test_rvs_len_reads_the_row_axis_from_the_key_when_no_weight_column_is_present(): + """No canonical per-row column to settle it -> the key's own layout decides.""" + r = {('mc', 'delta_mc'): np.zeros((2, 7)), 'psi': np.zeros(7)} + for path in (_LISA, _MAIN): + assert _load(path)['_rvs_len'](r) == 7, os.path.basename(path) + + +def test_fair_draw_uniform_weights_are_one_per_row_with_a_combined_parameter(H): + """The consumer-visible failure: a weight vector ndim times longer than the record.""" + r = _record_with_a_combined_parameter(n=6) + w = H['ln_weights_for_posterior'](r, _FakeSampler(fairdraw=True, pooled=False)) + assert w.shape == (6,) + + +# ------------------------------------------------------------------ the convention resolver +def test_lnL_convention_prefers_the_explicit_argument(H): + assert H['_rvs_lnL_convention'](True) is True + assert H['_rvs_lnL_convention'](False) is False + + +def test_lnL_convention_falls_back_to_linear_outside_the_driver(H): + """No `rvs_integrand_is_lnL` in scope (which is the case in these tests) -> False.""" + assert H['_rvs_lnL_convention'](None) is False + + +# ------------------------------------------------------------- source-level wiring in LISA +def _lisa_src(): + with open(_LISA) as fh: + return fh.read() + + +def test_lisa_derives_the_convention_from_pinned_params_not_the_cli_option(): + """The trap this port had to avoid. + + --internal-use-lnL is ALSO accepted for adaptive_cartesian_gpu and portfolio, and those + branches set use_lnL WITHOUT return_lnI -- they still store linear L. Deriving the + stored convention from the option would read those records as lnL. + """ + src = _lisa_src() + assert 'rvs_integrand_is_lnL = bool(pinned_params.get("return_lnI", False))' in src, \ + "the stored-integrand convention is not derived from pinned_params['return_lnI']" + assert 'rvs_integrand_is_lnL = bool(opts.internal_use_lnL' not in src, \ + "the convention is keyed off the CLI option, which is a different predicate" + + +def test_lisa_still_requests_the_fair_draw(): + """If this ever stops being set, the helpers become dead code and should be revisited.""" + assert '"igrand_fairdraw_samples": opts.fairdraw_extrinsic_output' in _lisa_src() + + +# ------------------------------------------------------------------ anti-drift vs the main driver +def _normalized(fn): + """AST dump of a function with its docstring stripped. + + Docstrings are deliberately allowed to differ -- the LISA copies carry LISA-specific + notes. Everything the interpreter runs must match. + """ + node = ast.parse(ast.unparse(fn)).body[0] if hasattr(ast, "unparse") else fn + body = list(node.body) + if (body and isinstance(body[0], ast.Expr) + and isinstance(getattr(body[0], "value", None), ast.Constant) + and isinstance(body[0].value.value, str)): + body = body[1:] + stripped = ast.Module(body=body, type_ignores=[]) + return ast.dump(ast.fix_missing_locations(stripped)) + + +@pytest.mark.parametrize("name", PORTED) +def test_ported_helper_is_identical_to_the_main_driver(name): + """These are COPIES in a deliberate fork. A copy that quietly changes is the whole risk. + + If you intend to change one, change both -- or record the divergence explicitly. + """ + lisa = _extract(_LISA, [name])[name] + main = _extract(_MAIN, [name])[name] + assert _normalized(lisa) == _normalized(main), ( + "%s has drifted between the two drivers (docstrings excluded)" % name) diff --git a/MonteCarloMarginalizeCode/Code/test/test_lisa_l0_rescue.py b/MonteCarloMarginalizeCode/Code/test/test_lisa_l0_rescue.py new file mode 100644 index 000000000..638a0d0d0 --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/test/test_lisa_l0_rescue.py @@ -0,0 +1,679 @@ +#!/usr/bin/env python +""" +Tests for the L0 auto-rescue ported into the LISA ILE driver +(bin/integrate_likelihood_extrinsic_batchmode_lisa) from the main driver. + +WHY IT BELONGS IN LISA. The rescue targets the high-SNR n_eff LOTTERY: a large fraction of +independent AV/portfolio runs collapse to n_eff ~ 1 by contracting onto the wrong spot, and +the rescue re-seeds such a run from the peak it did find. LISA MBHB are high-SNR by +construction, so this is the regime, not an edge case. The sampler-side machinery +(`build_warm_seed`, `lnZ_from_reserve`, the reserve itself) already lives in +RIFT/integrators/ and therefore already reached LISA; only the driver-side wiring was missing. + +ONE DELIBERATE STRUCTURAL DIVERGENCE. The main driver inlines the rescue in its single +`analyze_event`. This driver has TWO -- `analyze_event_LISA` (with --LISA) and +`analyze_event` (the fallback) -- so the block was lifted into `_maybe_l0_rescue` and both +call it. That is a divergence in SHAPE, not behaviour, and it buys something main does not +have: the reject gate becomes unit-testable. The audit notes that in main these call sites +"cannot be exercised from a unit test" because analyze_event needs data, PSDs and a waveform. +Here the gate is a function of its arguments, so the tests below drive it directly. + +ORDERING IS LOAD-BEARING (see test_rescue_runs_before_the_no_result_guard). In main the +`if not(res): raise` guard sits ~200 lines below the integrate call and the ordering is +implicit. In this driver it is immediately after, so the rescue had to be inserted BETWEEN +them: a degenerate early termination returns (None,None,None,None) and is the STRONGEST +rescue trigger, so raising on it first would skip exactly the case the rescue exists for. +""" + +import ast +import os + +import numpy as np +from RIFT.integrators.rvs_record import SamplerOutputMixin as _SamplerOutputMixin +import pytest + +_HERE = os.path.dirname(os.path.abspath(__file__)) +_LISA = os.path.join(_HERE, '..', 'bin', 'integrate_likelihood_extrinsic_batchmode_lisa') +_MAIN = os.path.join(_HERE, '..', 'bin', 'integrate_likelihood_extrinsic_batchmode') + +# Helpers ported verbatim from the main driver. _maybe_l0_rescue is NOT in this list: it is +# the LISA-only wrapper, and has no counterpart to be identical to. +PORTED = ['_lnZ_of_rvs', '_kish_neff_of_rvs', '_lnZ_of_reserve_or_rvs', + '_snapshot_pass_state', '_restore_pass_state', + '_warm_seed_reserve_for', '_warm_seed_geometry', '_clear_warm_state'] + +# Everything the exec'd namespace needs, in dependency order. +# The record accessors are here because the PORTED helpers call them by name: +# _snapshot_pass_state/_restore_pass_state thread the sampler's RvsRecord, and +# ln_weights_for_posterior reads it. Leaving one out is a NameError at exec time, +# not a missing assertion -- which is exactly how this list is meant to fail. +_DEPS = ['_rvs_lnL_convention', 'ln_weights_from_rvs', '_rvs_len', + '_rvs_is_export_resample', '_rvs_is_equal_weight', + '_rvs_record_for', '_sampler_keeps_records', '_internal_record_of', + '_rebound_record', '_lw_of', 'ln_weights_for_posterior'] + + + +def _driver_def_names(path): + """Every top-level name the driver BINDS: functions and imports alike. + + Imports are in here because of a real miss: the guard originally covered only defs, so + `SamplerOutputMixin` -- imported by the driver, referenced by _sampler_keeps_records -- + slipped straight through it and surfaced as a NameError inside an exec'd helper. + """ + with open(path) as fh: # read directly: _src() differs between these harnesses + src = fh.read() + names = set() + for n in ast.parse(src).body: + if isinstance(n, ast.FunctionDef): + names.add(n.name) + elif isinstance(n, (ast.Import, ast.ImportFrom)): + for a in n.names: + if a.name != '*': + names.add(a.asname or a.name.split('.')[0]) + return names + + +def _assert_helper_set_is_closed(ns, names, path): + """Fail LOUDLY if an exec'd helper calls a driver helper that was not exec'd with it. + + The same omission in test_lisa_mc_error_replicas.py did NOT raise: _lnZ_of_rvs catches + broadly and returns None, so a missing name read as "no evidence" and the pooled + weights silently collapsed to 1/K. Kept in all three LISA harnesses so the next + ported helper cannot reintroduce it here instead. + """ + driver = _driver_def_names(path) + missing = {} + for name in names: + code = getattr(ns.get(name), "__code__", None) + if code is None: + continue + stack, seen = [code], set() + while stack: + c = stack.pop() + if id(c) in seen: + continue + seen.add(id(c)) + for used in c.co_names: + if used in driver and used not in ns: + missing.setdefault(name, set()).add(used) + stack.extend(k for k in c.co_consts if hasattr(k, "co_names")) + assert not missing, ( + "exec'd helper set is not closed -- add these to the name list:\n " + + "\n ".join("%s needs %s" % (k, sorted(v)) for k, v in sorted(missing.items()))) + +def _defs(path, names): + with open(path) as fh: + tree = ast.parse(fh.read(), filename=path) + found = {n.name: n for n in tree.body + if isinstance(n, ast.FunctionDef) and n.name in names} + missing = sorted(set(names) - set(found)) + assert not missing, "%s is missing: %s" % (os.path.basename(path), missing) + return found + + +class _FakeAV(object): + """Stand-in for RIFT.integrators.mcsamplerAdaptiveVolume inside the helpers.""" + lnZ_value = None + seed_info = {'puffed': False, 'n_core': 3, 'rank_core': 3, 'dim': 3, + 'rank_final': 3, 'n_puff': 0, 'puff_scale': 'auto'} + + @classmethod + def lnZ_from_reserve(cls, reserve): + return cls.lnZ_value + + @classmethod + def build_warm_seed(cls, cols, lnL, lo, hi, axes, **kw): + return np.asarray(cols, dtype=float), dict(cls.seed_info) + + +class _Opts(object): + sampler_method = 'AV' + sampler_warmstart_retry_neff = 5.0 + sampler_l0_rescue_reject_dlnZ = 3.0 + sampler_l0_rescue_accept_truncated = False + sampler_l0_rescue_puff_scale = 'auto' + sampler_l0_rescue_puff_width_frac = 0.005 + sampler_l0_rescue_puff_factor = 2.0 + sampler_sequential_warmstart_deltalnL = 15.0 + + def __init__(self, **kw): + for k, v in kw.items(): + setattr(self, k, v) + + +def _load(opts=None, av=None): + """Exec the rescue helpers out of the LISA driver with injected globals.""" + names = _DEPS + PORTED + ['_maybe_l0_rescue'] + defs = _defs(_LISA, names) + mod = ast.Module(body=[defs[n] for n in names], type_ignores=[]) + ns = {"numpy": np, "np": np, "SamplerOutputMixin": _SamplerOutputMixin, + "opts": opts if opts is not None else _Opts(), + "mcsamplerAdaptiveVolume": av if av is not None else _FakeAV} + exec(compile(ast.fix_missing_locations(mod), "lisa_l0_helpers", "exec"), ns) + _assert_helper_set_is_closed(ns, names, _LISA) + return ns + + +@pytest.fixture +def H(): + return _load() + + +# ------------------------------------------------------------------------------ fake sampler +class _Sampler(object): + def __init__(self, rvs=None, reserve=None, params=('a', 'b'), members=None, + integrate_result=None, raise_in_integrate=False): + self._rvs = rvs if rvs is not None else {} + self._warm_seed_reserve = reserve + self.params_ordered = list(params) + self.llim = {p: 0.0 for p in self.params_ordered} + self.rlim = {p: 1.0 for p in self.params_ordered} + self.portfolio_realizations = members or [] + self._warm = "stale" + self._warm_applied = True + self._integrate_result = integrate_result + self._raise_in_integrate = raise_in_integrate + self.bootstrapped = None + self.warm_rvs = None + + def identity_convert(self, x): + return x + + def bootstrap_from_samples(self, seed, cover_frac=0.0): + self.bootstrapped = (np.asarray(seed), cover_frac) + + def integrate(self, fn, *a, **kw): + if self._raise_in_integrate: + # Repopulate _rvs IN PLACE first, then raise: this is the dangerous shape -- + # the assignment at the call site never completes, so res/var/neff still hold + # the COLD pass while _rvs holds the WARM samples. + self._rvs = dict(self.warm_rvs or {}) + raise RuntimeError("warm pass exploded") + if self.warm_rvs is not None: + self._rvs = dict(self.warm_rvs) + return self._integrate_result + + +def _rec(lnL, n=None): + lnL = np.asarray(lnL, dtype=float) + n = len(lnL) if n is None else n + return {'log_integrand': lnL, + 'log_joint_prior': np.zeros(n), + 'log_joint_s_prior': np.zeros(n), + 'a': np.linspace(0.1, 0.9, n), 'b': np.linspace(0.2, 0.8, n)} + + +# ------------------------------------------------------------------------------- lnZ helpers +def test_lnZ_pooled_is_the_sum_and_unpooled_is_the_mean(H): + r = _rec([0.0, 0.0, 0.0, 0.0]) + pooled = H['_lnZ_of_rvs'](r, already_pooled=True) + single = H['_lnZ_of_rvs'](r, already_pooled=False) + assert np.isclose(pooled, np.log(4.0)) + assert np.isclose(single, 0.0) + assert np.isclose(pooled - single, np.log(4.0)) + + +def test_lnZ_returns_none_when_weights_cannot_be_rebuilt(H): + assert H['_lnZ_of_rvs']({'a': np.zeros(3)}) is None + + +def test_lnZ_ignores_non_finite_rows(H): + r = _rec([0.0, -np.inf, 0.0]) + assert np.isclose(H['_lnZ_of_rvs'](r, already_pooled=True), np.log(2.0)) + + +def test_kish_neff_of_equal_weights_is_the_row_count(H): + assert np.isclose(H['_kish_neff_of_rvs'](_rec(np.zeros(7))), 7.0) + + +def test_kish_neff_collapses_on_one_dominant_row(H): + neff = H['_kish_neff_of_rvs'](_rec([0.0, -50.0, -50.0, -50.0])) + assert 1.0 <= neff < 1.01 + + +# -------------------------------------------------------------- reserve-vs-fairdraw provenance +def test_lnZ_prefers_the_retained_reserve_and_says_so(H): + _FakeAV.lnZ_value = -1.25 + s = _Sampler(reserve={'log_joint_prior': np.zeros(3), 'log_joint_s_prior': np.zeros(3)}) + val, src = H['_lnZ_of_reserve_or_rvs'](s, _rec([0.0, 0.0])) + assert src == 'retained' and np.isclose(val, -1.25) + + +def test_lnZ_falls_back_to_the_fairdraw_record_when_no_reserve(H): + s = _Sampler(reserve=None) + val, src = H['_lnZ_of_reserve_or_rvs'](s, _rec([0.0, 0.0])) + assert src == 'fairdraw' and np.isclose(val, 0.0) + + +def test_lnZ_falls_back_when_lnZ_from_reserve_is_not_finite(H): + """Degrade to the previous behaviour, not to no gate at all.""" + _FakeAV.lnZ_value = np.nan + s = _Sampler(reserve={'log_joint_prior': np.zeros(3), 'log_joint_s_prior': np.zeros(3)}) + _val, src = H['_lnZ_of_reserve_or_rvs'](s, _rec([0.0, 0.0])) + assert src == 'fairdraw' + + +# ----------------------------------------------------------------- snapshot / restore (Finding 5) +def test_snapshot_restore_round_trips_the_whole_pass(H): + member = _Sampler(params=('a', 'b')) + member._warm_seed_reserve = {'tag': 'cold-member'} + s = _Sampler(rvs=_rec([1.0, 2.0]), reserve={'tag': 'cold'}, members=[member]) + s._rvs_is_fairdraw, s._rvs_is_pooled = True, False + + snap = H['_snapshot_pass_state'](s, 'RES', 'VAR', 'NEFF', {'d': 1}) + + # the warm pass overwrites everything + s._rvs = _rec([9.0]) + s._warm_seed_reserve = {'tag': 'WARM'} + s._rvs_is_fairdraw, s._rvs_is_pooled = False, True + member._warm_seed_reserve = {'tag': 'WARM-member'} + + out = H['_restore_pass_state'](s, snap) + assert out == ('RES', 'VAR', 'NEFF', {'d': 1}) + assert s._warm_seed_reserve == {'tag': 'cold'}, "the RESERVE did not come back (Finding 5)" + assert member._warm_seed_reserve == {'tag': 'cold-member'}, "per-member reserve did not come back" + assert s._rvs_is_fairdraw is True and s._rvs_is_pooled is False + assert np.allclose(s._rvs['log_integrand'], [1.0, 2.0]) + + +def test_snapshot_takes_a_copy_not_an_alias(H): + """integrate_log repopulates _rvs IN PLACE, so an alias would hold the warm samples.""" + s = _Sampler(rvs=_rec([1.0, 2.0])) + snap = H['_snapshot_pass_state'](s, 1, 2, 3, {}) + s._rvs['log_integrand'] = np.array([99.0, 99.0]) + assert snap['rvs'] is not s._rvs + + +# -------------------------------------------------------------------- the reserve lookup guard +def test_reserve_lookup_declines_a_column_order_mismatch(H): + """A silent mismatch produces a seed in the wrong coordinates, so decline it.""" + s = _Sampler(reserve={'params_ordered': ['b', 'a']}, params=('a', 'b')) + assert H['_warm_seed_reserve_for'](s) is None + + +def test_reserve_lookup_accepts_matching_column_order(H): + res = {'params_ordered': ['a', 'b']} + assert H['_warm_seed_reserve_for'](_Sampler(reserve=res, params=('a', 'b'))) is res + + +def test_reserve_lookup_falls_through_to_a_portfolio_member(H): + member = _Sampler(params=('a', 'b')) + member._warm_seed_reserve = {'params_ordered': ['a', 'b'], 'tag': 'member'} + s = _Sampler(reserve=None, params=('a', 'b'), members=[member]) + assert H['_warm_seed_reserve_for'](s)['tag'] == 'member' + + +# ------------------------------------------------------------------------------- seed geometry +def test_geometry_uses_the_samplers_adaptive_axes_when_it_has_them(H): + s = _Sampler(params=('a', 'b')) + s.warm_seed_axes = lambda: [1] + axes, lo, hi = H['_warm_seed_geometry'](s) + assert axes == [1] and np.allclose(lo, [0, 0]) and np.allclose(hi, [1, 1]) + + +def test_geometry_defaults_to_every_column(H): + axes, _lo, _hi = H['_warm_seed_geometry'](_Sampler(params=('a', 'b', 'c'))) + assert axes == [0, 1, 2] + + +def test_geometry_falls_through_to_a_portfolio_member(H): + member = _Sampler(params=('a', 'b')) + member.warm_seed_axes = lambda: [0] + s = _Sampler(params=('a', 'b'), members=[member]) + assert H['_warm_seed_geometry'](s)[0] == [0] + + +# ---------------------------------------------------------------------------- clearing warm state +def test_clear_warm_state_prefers_the_portfolio_hook(H): + s = _Sampler() + calls = [] + s.clear_warm_state = lambda: calls.append(1) + H['_clear_warm_state'](s) + assert calls == [1], "portfolio members would keep the previous point's contracted grid" + + +def test_clear_warm_state_falls_back_to_the_attributes(H): + s = _Sampler() + H['_clear_warm_state'](s) + assert s._warm is None and s._warm_applied is False + + +def test_clear_warm_state_does_not_swallow_failures(H): + """A reset that quietly did not happen is the silent bias this guards against.""" + s = _Sampler() + + def _boom(): + raise RuntimeError("no") + s.clear_warm_state = _boom + with pytest.raises(RuntimeError): + H['_clear_warm_state'](s) + + +# ------------------------------------------------------------------------- the rescue itself +def _run(H, sampler, res=1.0, var=0.1, neff=1.0, dict_return=None): + return H['_maybe_l0_rescue'](sampler, res, var, neff, dict_return or {'cold': True}, + lambda *a, **k: None, (), {}) + + +def _assert_declined(H, sampler, capsys, **runkw): + """The rescue must DECLINE silently -- not run and get rescued by its own except. + + Asserting only the return value is not enough, and an earlier version of these tests + made exactly that mistake: with a guard removed the rescue starts, throws somewhere + inside, and `except Exception` returns the inputs unchanged -- so the return value is + identical either way. The observable difference is that a declining rescue says + NOTHING and never touches the sampler. + """ + capsys.readouterr() + out_vals = _run(H, sampler, **runkw) + printed = capsys.readouterr().out + assert "[L0 auto-rescue]" not in printed, \ + "the rescue engaged when it should have declined: %r" % printed + assert getattr(sampler, 'bootstrapped', None) is None + return out_vals + + +def test_rescue_is_a_noop_when_the_option_is_off(capsys): + """Uses a DEGENERATE neff, so the option guard is the only thing declining. + + With neff=None, `_needs_l0_rescue` is True on its own; only the + `opts.sampler_warmstart_retry_neff` conjunct can stop the rescue here. A healthy neff + would make this test pass with that conjunct deleted. + """ + H = _load(opts=_Opts(sampler_warmstart_retry_neff=None)) + s = _Sampler(rvs=_rec([1.0, 2.0, 3.0]), integrate_result=('R2', 'V2', 42.0, {'warm': True})) + assert _assert_declined(H, s, capsys, neff=None) == (1.0, 0.1, None, {'cold': True}) + + +def test_rescue_is_a_noop_for_a_sampler_method_it_does_not_apply_to(capsys): + """AV/portfolio only. Every other conjunct is satisfied here.""" + H = _load(opts=_Opts(sampler_method='GMM')) + s = _Sampler(rvs=_rec([1.0, 2.0, 3.0]), integrate_result=('R2', 'V2', 42.0, {'warm': True})) + _assert_declined(H, s, capsys, neff=1.0) + + +def test_rescue_is_a_noop_for_a_sampler_that_cannot_warm_start(capsys): + """mcsampler/GMM have no bootstrap_from_samples; the rescue must decline, not crash.""" + H = _load() + + class _NoBootstrap(object): + def __init__(self): + self._rvs = _rec([1.0]) + self.params_ordered = ['a', 'b'] + + def identity_convert(self, x): + return x + + s = _NoBootstrap() + assert not hasattr(s, 'bootstrap_from_samples') + _assert_declined(H, s, capsys, neff=1.0) + + +def test_rescue_does_not_touch_identity_convert_before_deciding_it_applies(capsys): + """Regression: RIFT.integrators.mcsampler.MCSampler has NO identity_convert. + + That is the object this driver keeps for --sampler-method adaptive_cartesian. The main + driver evaluates `sampler.identity_convert(neff)` BEFORE its guard, so porting it + verbatim made every adaptive_cartesian event die with AttributeError at the end of a + completed integration, before --output-file was written. The applicability guards must + run first. + """ + H = _load(opts=_Opts(sampler_method='adaptive_cartesian')) + + class _NoConvert(object): + """Exactly mcsampler.MCSampler's relevant shape: no identity_convert.""" + def __init__(self): + self._rvs = _rec([1.0]) + self.params_ordered = ['a', 'b'] + + s = _NoConvert() + assert not hasattr(s, 'identity_convert') + capsys.readouterr() + assert _run(H, s, neff=1.0) == (1.0, 0.1, 1.0, {'cold': True}) + + +def test_rescue_is_a_noop_when_neff_is_healthy(capsys): + H = _load() + s = _Sampler(rvs=_rec([1.0]), integrate_result=('R2', 'V2', 42.0, {'warm': True})) + assert _assert_declined(H, s, capsys, neff=500.0)[3] == {'cold': True} + + +def test_degenerate_early_termination_triggers_the_rescue(): + """neff=None is the STRONGEST trigger, not a reason to skip.""" + H = _load() + s = _Sampler(rvs=_rec([1.0, 2.0, 3.0]), integrate_result=('R2', 'V2', 42.0, {'warm': True})) + s.warm_rvs = _rec([5.0, 5.0, 5.0]) + out = _run(H, s, neff=None) + assert s.bootstrapped is not None, "a degenerate pass did not trigger the rescue" + assert out[2] == 42.0 + + +def test_accepted_warm_pass_replaces_the_cold_result(): + H = _load() + s = _Sampler(rvs=_rec([0.0, 0.0]), integrate_result=('R2', 'V2', 42.0, {'warm': True})) + s.warm_rvs = _rec([0.0, 0.0]) # same lnZ -> no evidence of loss + out = _run(H, s) + assert out == ('R2', 'V2', 42.0, {'warm': True}) + assert s._av_state_reuse_safe is True + + +def test_warm_pass_far_below_cold_is_rejected_and_cold_is_restored(): + """The gate: positive evidence of lost mass keeps the full-support cold pass.""" + H = _load() + cold = _rec([0.0, 0.0, 0.0, 0.0]) # lnZ = 0 + s = _Sampler(rvs=cold, reserve={'tag': 'cold'}, + integrate_result=('R2', 'V2', 42.0, {'warm': True})) + s._rvs_is_fairdraw = True + s.warm_rvs = _rec([-20.0, -20.0, -20.0, -20.0]) # lnZ = -20, far below + out = _run(H, s, res='R1', var='V1', neff=1.0, dict_return={'cold': True}) + assert out == ('R1', 'V1', 1.0, {'cold': True}), "the warm pass was not rejected" + assert s._warm_seed_reserve == {'tag': 'cold'}, "the reserve did not come back (Finding 5)" + assert np.allclose(s._rvs['log_integrand'], cold['log_integrand']) + assert s._av_state_reuse_safe is False, "the rejected warm grid could be persisted" + + +def test_a_later_healthy_event_resets_the_state_save_veto(): + """Sampler objects are reused; an earlier rejection must not poison later state saves.""" + H = _load() + s = _Sampler(rvs=_rec([0.0, 0.0]), integrate_result=('R2', 'V2', 42.0, {})) + s.warm_rvs = _rec([-20.0, -20.0]) + _run(H, s) # rejected warm pass + assert s._av_state_reuse_safe is False + _run(H, s, neff=42.0) # healthy next event; returns before attempting a rescue + assert s._av_state_reuse_safe is True + + +def test_reject_message_reports_lnZ_on_the_events_offset_scale(capsys): + """lnL_offset is this event's manual_avoid_overflow_logarithm. + + It exists so the *** REJECTING *** line quotes absolute lnZ rather than the internally + offset value. Nothing else reads it, so dropping it at the call sites is invisible + unless a test drives it at a NON-ZERO value -- which is what made it possible to delete + `lnL_offset=manual_avoid_overflow_logarithm` from both call sites with 81 tests green. + """ + H = _load() + s = _Sampler(rvs=_rec([0.0, 0.0]), integrate_result=('R2', 'V2', 42.0, {'warm': True})) + s.warm_rvs = _rec([-20.0, -20.0]) + capsys.readouterr() + H['_maybe_l0_rescue'](s, 'R1', 'V1', 1.0, {'cold': True}, + lambda *a, **k: None, (), {}, lnL_offset=1000.0) + out = capsys.readouterr().out + assert "REJECTING" in out + # cold lnZ 0.0 and warm lnZ -20.0, both shifted by +1000 in the report + assert "1000.000" in out and "980.000" in out, \ + "the reject message did not quote lnZ on the event's offset scale: %r" % out + + +def test_both_call_sites_pass_the_events_offset(): + """Source-level, because the value comes from a local of each analyze_event.""" + src = _src() + # Count PER HELPER, not globally: more than one helper now takes lnL_offset (the L0 + # rescue and the MC-error replica block), so a global count silently absorbs a call + # site that dropped it as long as some other helper still passes it. + tree = ast.parse(src) + for helper, want in (("_maybe_l0_rescue", 2), ("_maybe_replicate_for_mc_error", 2)): + passing = [c for c in ast.walk(tree) + if isinstance(c, ast.Call) and isinstance(c.func, ast.Name) + and c.func.id == helper + and any(k.arg == "lnL_offset" + and isinstance(k.value, ast.Name) + and k.value.id == "manual_avoid_overflow_logarithm" + for k in c.keywords)] + assert len(passing) == want, ( + "%s: %d of %d call sites pass lnL_offset=manual_avoid_overflow_logarithm; a " + "site that dropped it would quote the internally-offset lnZ, not the absolute one" + % (helper, len(passing), want)) + + +def test_accept_truncated_reports_the_warm_pass_anyway(): + H = _load(opts=_Opts(sampler_l0_rescue_accept_truncated=True)) + s = _Sampler(rvs=_rec([0.0, 0.0]), integrate_result=('R2', 'V2', 42.0, {'warm': True})) + s.warm_rvs = _rec([-20.0, -20.0]) + assert _run(H, s)[0] == 'R2' + + +def test_reject_threshold_is_respected(): + """A shortfall smaller than the threshold is not evidence of loss.""" + H = _load(opts=_Opts(sampler_l0_rescue_reject_dlnZ=50.0)) + s = _Sampler(rvs=_rec([0.0, 0.0]), integrate_result=('R2', 'V2', 42.0, {'warm': True})) + s.warm_rvs = _rec([-20.0, -20.0]) + assert _run(H, s)[0] == 'R2', "a 20-nat drop was rejected against a 50-nat threshold" + + +def test_a_raising_warm_pass_restores_the_cold_state(): + """The silent-for-a-campaign shape: _rvs holds warm samples, res/neff still hold cold.""" + H = _load() + cold = _rec([0.0, 0.0]) + s = _Sampler(rvs=cold, reserve={'tag': 'cold'}, raise_in_integrate=True) + s.warm_rvs = _rec([7.0, 7.0]) + out = _run(H, s, res='R1', var='V1', neff=1.0, dict_return={'cold': True}) + assert out == ('R1', 'V1', 1.0, {'cold': True}) + assert np.allclose(s._rvs['log_integrand'], cold['log_integrand']), \ + "cold diagnostics were reported beside a warm export" + assert s._warm_seed_reserve == {'tag': 'cold'} + assert s._av_state_reuse_safe is False, "the failed warm grid could be persisted" + + +def test_rescue_clears_warm_state_afterwards(): + H = _load() + s = _Sampler(rvs=_rec([0.0, 0.0]), integrate_result=('R2', 'V2', 42.0, {})) + s.warm_rvs = _rec([0.0, 0.0]) + _run(H, s) + assert s._warm is None, "the next point would draw from this point's contracted grid" + + +def test_mixed_lnZ_provenance_falls_back_to_a_like_for_like_comparison(): + """Cold read from the reserve, warm from the fair draw, is not a difference. + + The two readings differ by ~log(n_retained/eff_samp), so a mixed comparison manufactures + a gap of several nats out of nothing. The numbers here are chosen so the two paths + DISAGREE about the outcome -- an earlier version of this test used values where both + accepted, and it passed with the guard disabled. + + mixed (broken): cold 'retained' +10.0 vs warm 'fairdraw' 0.0 -> 10 nats -> REJECT + like-for-like : both re-read from _rvs, 0.0 vs 0.0 -> 0 nats -> ACCEPT + """ + class _AV(_FakeAV): + calls = {'n': 0} + + @classmethod + def lnZ_from_reserve(cls, reserve): + # available for the cold read, gone for the warm one + cls.calls['n'] += 1 + return 10.0 if cls.calls['n'] == 1 else None + _AV.calls['n'] = 0 + H = _load(av=_AV) + s = _Sampler(rvs=_rec([0.0, 0.0]), + reserve={'log_joint_prior': np.zeros(2), 'log_joint_s_prior': np.zeros(2)}, + integrate_result=('R2', 'V2', 42.0, {'warm': True})) + s.warm_rvs = _rec([0.0, 0.0]) + out = _run(H, s, res='R1', var='V1', neff=1.0, dict_return={'cold': True}) + assert out[0] == 'R2', ("a like-for-like lnZ comparison found no evidence of loss, so the " + "warm pass must stand; rejecting it means the gate compared a " + "'retained' reading against a 'fairdraw' one") + + +# ---------------------------------------------------------------------- source-level wiring +def _src(): + with open(_LISA) as fh: + return fh.read() + + +def test_both_analyze_event_variants_call_the_rescue(): + """This driver has two; a rescue wired into only one is a silent half-port.""" + tree = ast.parse(_src()) + fns = {n.name: n for n in tree.body + if isinstance(n, ast.FunctionDef) and n.name in ('analyze_event', 'analyze_event_LISA')} + assert set(fns) == {'analyze_event', 'analyze_event_LISA'} + for name, node in fns.items(): + called = any(isinstance(c, ast.Call) and isinstance(c.func, ast.Name) + and c.func.id == '_maybe_l0_rescue' for c in ast.walk(node)) + assert called, "%s does not call _maybe_l0_rescue" % name + + +def test_rescue_runs_before_the_no_result_guard(): + """Ordering is load-bearing. + + A degenerate early termination returns (None,None,None,None); `if not(res): raise` would + abort on it, skipping the strongest rescue trigger. In the main driver that guard sits + ~200 lines below the integrate call so the ordering is implicit -- here it is adjacent, + so it is pinned. + """ + src = _src() + guard = "if not(res): # no resut" + assert src.count(guard) == 2, "expected the guard in both analyze_event variants" + pos = 0 + for _ in range(2): + g = src.index(guard, pos) + call = src.rindex("_maybe_l0_rescue(", 0, g) + integ = src.rindex("sampler.integrate(like_to_integrate", 0, call) + assert integ < call < g, "the rescue must sit between integrate and the not(res) guard" + pos = g + 1 + + +def test_rescue_is_not_hidden_behind_the_LISA_flag(): + """Both variants get it; nothing keys the rescue off opts.LISA.""" + tree = ast.parse(_src()) + fn = [n for n in tree.body if isinstance(n, ast.FunctionDef) and n.name == '_maybe_l0_rescue'][0] + body = ast.dump(fn) + assert "'LISA'" not in body and 'attr=\'LISA\'' not in body + + +@pytest.mark.parametrize("opt,default", [ + ("--sampler-l0-rescue-reject-dlnZ", "default=3.0"), + ("--sampler-l0-rescue-puff-width-frac", "default=0.005"), + ("--sampler-l0-rescue-puff-factor", "default=2.0"), + ("--sampler-sequential-warmstart-deltalnL", "default=15.0"), +]) +def test_option_defaults_match_the_main_driver(opt, default): + """A knob that means something different in the two drivers is worse than a missing one. + + reject-dlnZ 3.0 in particular is a MEASURED value (L0_REJECT_DLNZ_MEASUREMENT.md); the + old 0.5 binned 25% of good portfolio warm passes while catching 0 of 55 truncated ones. + """ + for path in (_LISA, _MAIN): + with open(path) as fh: + src = fh.read() + i = src.index('"%s"' % opt) + line = src[i:src.index("\n", i)] + assert default.replace(" ", "") in line.replace(" ", ""), \ + "%s: %s does not carry %s" % (os.path.basename(path), opt, default) + + +# ------------------------------------------------------------------ anti-drift vs the main driver +def _normalized(fn): + node = ast.parse(ast.unparse(fn)).body[0] if hasattr(ast, "unparse") else fn + body = list(node.body) + if (body and isinstance(body[0], ast.Expr) + and isinstance(getattr(body[0], "value", None), ast.Constant) + and isinstance(body[0].value.value, str)): + body = body[1:] + return ast.dump(ast.fix_missing_locations(ast.Module(body=body, type_ignores=[]))) + + +@pytest.mark.parametrize("name", PORTED) +def test_ported_helper_is_identical_to_the_main_driver(name): + """Deliberate COPIES in a deliberate fork. Change one, change both.""" + assert _normalized(_defs(_LISA, [name])[name]) == _normalized(_defs(_MAIN, [name])[name]), \ + "%s has drifted between the two drivers (docstrings excluded)" % name diff --git a/MonteCarloMarginalizeCode/Code/test/test_lisa_mc_error_replicas.py b/MonteCarloMarginalizeCode/Code/test/test_lisa_mc_error_replicas.py new file mode 100644 index 000000000..a70dade4d --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/test/test_lisa_mc_error_replicas.py @@ -0,0 +1,769 @@ +#!/usr/bin/env python +""" +MC-error replicas and replica pooling in the LISA ILE driver. + +`--mc-error-replicas` re-runs the extrinsic integration as independent cold replicas when the +reported error is untrustworthy, then POOLS every replica's samples rather than picking one. +Pooling, not selection, because lnZ is the linear mean over K replicas so the exported +posterior must represent that same mixture -- and n_eff is the wrong selector anyway, since it +measures weight CONCENTRATION, not coverage, so a mode-collapsed replica scores highest. + +THE THREE THINGS THAT MUST BE RIGHT, each a defect the main driver already paid for: + +1. `already_resampled` is a PER-REPLICA SEQUENCE, not one boolean. Each pass decides + independently whether to fair-draw (the draw is skipped when it would not shrink that + pass's record), so near the n_extr boundary a run produces a MIXTURE. One global flag + either flattens a replica whose importance weights are genuine, or leaves a resampled + replica double-weighted (audit Finding 6). +2. The empty-record filter runs in LOCKSTEP with rep_lnZ and the flags. Filtering rep_rvs + alone shifts every later block against the wrong evidence. +3. The collapse gate fires on the POOLED verdict as well as the first run, or + --reject-collapsed-live-volume is silently bypassed for the case pooling creates. + +Pooling maths: block k gets weights summing to Z_k/K -- equal WITHIN a block when that block +was fair-drawn (it is already an equal-weight posterior draw), scaled otherwise. That is the +importance weight against the real pooled proposal q'_ki = q_ki * K * n_k. +""" + +import ast +import os +import textwrap + +import numpy as np +from RIFT.integrators.rvs_record import SamplerOutputMixin as _SamplerOutputMixin +import pytest + +_HERE = os.path.dirname(os.path.abspath(__file__)) +_LISA = os.path.join(_HERE, '..', 'bin', 'integrate_likelihood_extrinsic_batchmode_lisa') +_MAIN = os.path.join(_HERE, '..', 'bin', 'integrate_likelihood_extrinsic_batchmode') + +# The record accessors are REQUIRED here even though no test calls them directly: +# _lnZ_of_rvs / _kish_neff_of_rvs resolve their weights through _lw_of. Leaving one out +# does NOT raise -- _lnZ_of_rvs catches broadly and returns None, so a NameError becomes +# "no evidence for this block" and the pooled weights silently collapse to 1/K. That is +# an assertion failure three layers away from its cause; see the guard in H() below. +HELPERS = ['_rvs_lnL_convention', 'ln_weights_from_rvs', '_rvs_len', + '_rvs_record_for', '_sampler_keeps_records', '_internal_record_of', + '_rebound_record', '_lw_of', '_lnZ_of_rvs', + '_kish_neff_of_rvs', '_extract_mc_diag', '_pool_replica_rvs'] + + +def _src(path): + with open(path) as fh: + return fh.read() + + +def _driver_def_names(path): + """Every top-level name the driver BINDS: functions and imports alike. + + Imports are in here because of a real miss: the guard originally covered only defs, so + `SamplerOutputMixin` -- imported by the driver, referenced by _sampler_keeps_records -- + slipped straight through it and surfaced as a NameError inside an exec'd helper. + """ + with open(path) as fh: # read directly: _src() differs between these harnesses + src = fh.read() + names = set() + for n in ast.parse(src).body: + if isinstance(n, ast.FunctionDef): + names.add(n.name) + elif isinstance(n, (ast.Import, ast.ImportFrom)): + for a in n.names: + if a.name != '*': + names.add(a.asname or a.name.split('.')[0]) + return names + + +def _assert_helper_set_is_closed(ns, names, path=_LISA): + """Fail LOUDLY if an exec'd helper calls a driver helper that was not exec'd with it. + + Without this, a name missing from the list above is not a NameError anyone sees: + _lnZ_of_rvs catches broadly and returns None, so the omission reads as "this block + has no evidence" and the pooled weights collapse to 1/K. The test then fails on a + weight assertion far from the cause. Checked against the DRIVER's own def names, so + ordinary attribute names and locals cannot trip it. + """ + driver = _driver_def_names(path) + missing = {} + for name in names: + fn = ns.get(name) + code = getattr(fn, "__code__", None) + if code is None: + continue + stack, seen = [code], set() + while stack: + c = stack.pop() + if id(c) in seen: + continue + seen.add(id(c)) + for used in c.co_names: + if used in driver and used not in ns: + missing.setdefault(name, set()).add(used) + stack.extend(k for k in c.co_consts if hasattr(k, "co_names")) + assert not missing, ( + "exec'd helper set is not closed -- add these to the name list:\n " + + "\n ".join("%s needs %s" % (k, sorted(v)) for k, v in sorted(missing.items()))) + + +def _defs(path, names): + found = {n.name: n for n in ast.parse(_src(path)).body + if isinstance(n, ast.FunctionDef) and n.name in names} + missing = sorted(set(names) - set(found)) + assert not missing, "%s missing: %s" % (os.path.basename(path), missing) + return found + + +@pytest.fixture(scope="module") +def H(): + defs = _defs(_LISA, HELPERS) + mod = ast.Module(body=[defs[n] for n in HELPERS], type_ignores=[]) + ns = {"numpy": np, "np": np, "SamplerOutputMixin": _SamplerOutputMixin} + exec(compile(ast.fix_missing_locations(mod), "mcerr", "exec"), ns) + _assert_helper_set_is_closed(ns, HELPERS) + return ns + + +class _S(object): + """Minimal sampler: pooling only needs identity_convert.""" + def identity_convert(self, x): + return x + + +def _rec(lnL, n=None): + lnL = np.asarray(lnL, dtype=float) + n = len(lnL) if n is None else n + return {'log_integrand': lnL.copy(), + 'log_joint_prior': np.zeros(n), + 'log_joint_s_prior': np.zeros(n), + 'x': np.linspace(0.0, 1.0, n)} + + +def _lw(H, rec): + return H['ln_weights_from_rvs'](rec) + + +# ------------------------------------------------------------------------ _extract_mc_diag +def test_extract_mc_diag_pulls_the_four_diagnostics(H): + dd = {'pareto_khat': 0.9, 'sigma_lnZ_block': 0.3, 'n_ESS': 12.0, 'lnZ_ci90': [1, 2, 3]} + assert H['_extract_mc_diag'](dd) == (0.9, 0.3, 12.0, [1, 2, 3]) + + +@pytest.mark.parametrize("dd", [None, "not a dict", {}, 7]) +def test_extract_mc_diag_tolerates_anything(H, dd): + assert H['_extract_mc_diag'](dd) == (None, None, None, None) + + +# --------------------------------------------------------------- pooling: the basic contract +def test_single_replica_is_returned_unchanged(H): + r = _rec([0.0, 0.0]) + assert H['_pool_replica_rvs']([r], _S()) is r + + +def test_no_replicas_gives_an_empty_record(H): + assert H['_pool_replica_rvs']([], _S()) == {} + + +def test_pooled_record_concatenates_every_replica(H): + reps = [_rec([0.0] * 3), _rec([0.0] * 4)] + out = H['_pool_replica_rvs'](reps, _S(), rep_lnZ=[0.0, 0.0]) + assert H['_rvs_len'](out) == 7, "pooling dropped or duplicated rows" + + +def test_pooling_preserves_the_layout_of_a_combined_parameter(H): + """A combined parameter is stored (ndim, N) under a TUPLE key: the row axis is the SECOND. + + Ravelling every column and concatenating on axis 0 made it a 1-D column of ndim*sum(N) + values while the scalar columns had sum(N) rows, and this driver's exporter unpacks it -- + `samples["latitude"], samples["longitude"] = samples[("declination", "right_ascension")]` + -- so a pooled record could not be written out. The main driver carries the same fix; a + layout rule that holds in only one of the two forks is how this fork rots. + """ + sky = ("declination", "right_ascension") + reps = [] + for n in (3, 4): + r = _rec([0.0] * n) + r[sky] = np.vstack([np.linspace(-1.0, 1.0, n), np.linspace(0.0, 6.0, n)]) + reps.append(r) + + out = H['_pool_replica_rvs'](reps, _S(), rep_lnZ=[0.0, 0.0]) + assert out[sky].shape == (2, 7), ( + "combined parameter pooled to shape {} rather than (ndim, sum(N))".format( + out[sky].shape)) + assert H['_rvs_len'](out) == 7, "combined column disagrees with the scalar columns" + lat, lon = out[sky] # the exporter's unpack, on the pooled record + assert np.allclose(lat, np.concatenate([reps[0][sky][0], reps[1][sky][0]])) + assert np.allclose(lon, np.concatenate([reps[0][sky][1], reps[1][sky][1]])) + + +def test_each_block_contributes_its_own_evidence_over_K(H): + """Block k's weights must sum to Z_k/K -- that is what makes the pool match lnZ.""" + reps = [_rec([0.0] * 4), _rec([0.0] * 6)] + rep_lnZ = [0.0, np.log(3.0)] # Z = 1 and 3 + out = H['_pool_replica_rvs'](reps, _S(), rep_lnZ=rep_lnZ) + w = np.exp(_lw(H, out)) + K = 2 + b0, b1 = w[:4].sum(), w[4:].sum() + assert np.isclose(b0, 1.0 / K, rtol=1e-6), b0 + assert np.isclose(b1, 3.0 / K, rtol=1e-6), b1 + assert np.isclose(w.sum(), (1.0 + 3.0) / K, rtol=1e-6), "pooled Z is not the linear mean" + + +# ------------------------------------------------- constraint (c): the per-replica sequence +def test_a_resampled_block_is_flattened_and_a_raw_one_is_not(H): + """The MIXTURE case, which one global boolean cannot express. + + Replica 0 was fair-drawn -- its rows are already an equal-weight posterior draw, so it + must contribute CONSTANT weights. Replica 1 was not, so its genuine importance weights + must survive. + """ + reps = [_rec([0.0, 3.0, 6.0]), _rec([0.0, 3.0, 6.0])] + out = H['_pool_replica_rvs'](reps, _S(), rep_lnZ=[0.0, 0.0], + already_resampled=[True, False]) + w = np.exp(_lw(H, out)) + b0, b1 = w[:3], w[3:] + assert np.allclose(b0, b0[0]), "the fair-drawn block was not flattened (w^2 double-weighting)" + assert not np.allclose(b1, b1[0]), "the raw block was flattened, discarding real weights" + + +def test_a_global_boolean_would_get_the_mixture_wrong(H): + """Pins that the sequence and the boolean genuinely differ, so the test above has teeth.""" + reps = [_rec([0.0, 3.0, 6.0]), _rec([0.0, 3.0, 6.0])] + seq = np.exp(_lw(H, H['_pool_replica_rvs'](reps, _S(), rep_lnZ=[0.0, 0.0], + already_resampled=[True, False]))) + allT = np.exp(_lw(H, H['_pool_replica_rvs'](reps, _S(), rep_lnZ=[0.0, 0.0], + already_resampled=True))) + allF = np.exp(_lw(H, H['_pool_replica_rvs'](reps, _S(), rep_lnZ=[0.0, 0.0], + already_resampled=False))) + assert not np.allclose(seq, allT) and not np.allclose(seq, allF) + + +@pytest.mark.parametrize("flags", [True, False, [True, True], [False, False]]) +def test_uniform_flags_still_work_in_either_form(H, flags): + out = H['_pool_replica_rvs']([_rec([0.0, 1.0]), _rec([0.0, 1.0])], _S(), + rep_lnZ=[0.0, 0.0], already_resampled=flags) + assert H['_rvs_len'](out) == 4 + + +# ------------------------------------------------------- constraint (b): lockstep filtering +def test_empty_replicas_are_dropped_in_lockstep_with_their_metadata(H): + """An empty record in the middle must not shift later blocks onto the wrong lnZ. + + Replica 1 is empty. If the filter ran on rep_rvs alone, block 2 would be weighted with + replica 1's evidence. + """ + reps = [_rec([0.0] * 3), {}, _rec([0.0] * 3)] + rep_lnZ = [0.0, -99.0, np.log(3.0)] + out = H['_pool_replica_rvs'](reps, _S(), rep_lnZ=rep_lnZ) + w = np.exp(_lw(H, out)) + assert H['_rvs_len'](out) == 6 + K = 2 # the empty replica is gone, so K is 2 not 3 + assert np.isclose(w[:3].sum(), 1.0 / K, rtol=1e-6) + assert np.isclose(w[3:].sum(), 3.0 / K, rtol=1e-6), \ + "the surviving block was weighted with the dropped replica's evidence" + + +def test_lockstep_applies_to_the_resampled_flags_too(H): + reps = [{}, _rec([0.0, 3.0, 6.0]), _rec([0.0, 3.0, 6.0])] + out = H['_pool_replica_rvs'](reps, _S(), rep_lnZ=[-99.0, 0.0, 0.0], + already_resampled=[False, True, False]) + w = np.exp(_lw(H, out)) + assert np.allclose(w[:3], w[0]), "the flags did not shift with the records" + assert not np.allclose(w[3:], w[3]) + + +# ------------------------------------------------------------------------ fallbacks +def test_a_record_without_a_sampling_prior_column_falls_back_to_the_first_replica(H): + reps = [{'x': np.zeros(3)}, {'x': np.zeros(3)}] + out = H['_pool_replica_rvs'](reps, _S(), rep_lnZ=[0.0, 0.0]) + assert out is reps[0], "fallback must return an INPUT record, so _did_pool is False" + + +def test_fallback_identity_is_what_the_driver_keys_on(): + """`_did_pool = not any(_pooled_rvs is _r for _r in _rep_rvs)` -- identity, not length.""" + src = _src(_LISA) + assert "_did_pool = not any(_pooled_rvs is _r for _r in _rep_rvs)" in src + + +# ------------------------------------------------------------------- cached weights +def test_cached_weights_are_recomputed_from_the_canonical_columns(H): + """Consumers PREFER a cached log_weights column; a stale one silently undoes the pooling.""" + reps = [_rec([0.0, 1.0]), _rec([0.0, 1.0])] + for r in reps: + r['log_weights'] = np.full(2, 999.0) + out = H['_pool_replica_rvs'](reps, _S(), rep_lnZ=[0.0, 0.0]) + assert not np.allclose(out['log_weights'], 999.0), "stale cached weights survived pooling" + assert np.allclose(out['log_weights'], _lw(H, out)) + + +# ------------------------------------------------------------------ source-level wiring +def _helper_src(name): + src = _src(_LISA) + a = src.index("def %s(" % name) + return src[a:src.index("\ndef ", a + 1)] + + +def test_the_driver_passes_the_per_replica_sequence_not_the_cli_flag(): + """The CLI flag is not the question: the draw is skipped per pass when it would not shrink.""" + fn = _helper_src("_maybe_replicate_for_mc_error") + assert "already_resampled=_rep_fairdraw" in fn + assert "_rep_fairdraw = [bool(getattr(sampler, '_rvs_is_fairdraw', False))]" in fn + assert "already_resampled=opts.fairdraw_extrinsic_output" not in fn, \ + "the pooler was handed the CLI flag instead of what each pass actually did" + + +def test_the_pooled_marker_is_set_only_when_pooling_happened(): + fn = _helper_src("_maybe_replicate_for_mc_error") + assert "sampler._rvs_is_pooled = True" in fn + assert "if _did_pool:" in fn + + +def test_rvs_is_pooled_is_reset_on_entry_of_both_analyze_event_variants(): + """Cleared only on the happy path, it survives the pooled gate's raise (Finding 7). + + POSITION-AWARE, not merely presence-aware. An adversarial review pointed out that an + earlier version used `"... = False" in ast.unparse(fn)`, which passes just as happily if + the reset is MOVED to after the replica call -- reintroducing exactly the bug, since the + pooled gate raises and the caller's `except` swallows it. So: the reset must be the first + statement region of the function and must precede the replica call. + """ + tree = ast.parse(_src(_LISA)) + for n in tree.body: + if not (isinstance(n, ast.FunctionDef) + and n.name in ("analyze_event", "analyze_event_LISA")): + continue + resets = [st.lineno for st in ast.walk(n) + if isinstance(st, ast.Assign) + and any(isinstance(t, ast.Attribute) and t.attr == "_rvs_is_pooled" + and isinstance(t.value, ast.Name) and t.value.id == "sampler" + for t in st.targets)] + assert resets, "%s never resets the pooled marker" % n.name + calls = [c.lineno for c in ast.walk(n) + if isinstance(c, ast.Call) and isinstance(c.func, ast.Name) + and c.func.id == "_maybe_replicate_for_mc_error"] + assert calls, "%s never runs the replica helper" % n.name + assert min(resets) < min(calls), ( + "%s resets _rvs_is_pooled at line %d, AFTER the replica helper at %d; the pooled " + "gate raises, the caller swallows it, and the marker survives into the next event" + % (n.name, min(resets), min(calls))) + # and it must be one of the FIRST STATEMENTS, not buried behind work that can fail. + # Counted in statements, not lines: the reset carries a long explanatory comment, so a + # line-distance rule fails on the correct code (it did, on the first attempt). + top = [st for st in n.body[:4]] + assert any(isinstance(st, ast.Assign) + and any(isinstance(t, ast.Attribute) and t.attr == "_rvs_is_pooled" + for t in st.targets) + for st in top), ( + "%s does not reset the pooled marker within its first 4 statements; it must " + "happen on entry, before anything can raise" % n.name) + +def test_block_kish_neff_is_used_when_blocks_were_flattened(): + """Kish over a flattened pooled record just reports the EXPORT SIZE (5K by default).""" + fn = _helper_src("_maybe_replicate_for_mc_error") + assert "_blocks_flattened" in fn + assert "numpy.sum(_Zk[_ok]) ** 2 / numpy.sum(_Zk[_ok] ** 2 / _nk[_ok])" in fn + + +def test_collapse_status_is_the_OR_over_pooled_replicas(): + fn = _helper_src("_maybe_replicate_for_mc_error") + assert "_any_collapsed = any(_rep_collapsed)" in fn + assert "n_replicas_pooled" in fn and "n_replicas_collapsed" in fn + + +# ------------------------------------------------------- anti-drift vs the main driver +def _normalized(fn): + node = ast.parse(ast.unparse(fn)).body[0] if hasattr(ast, "unparse") else fn + body = list(node.body) + if (body and isinstance(body[0], ast.Expr) + and isinstance(getattr(body[0], "value", None), ast.Constant) + and isinstance(body[0].value.value, str)): + body = body[1:] + return ast.dump(ast.fix_missing_locations(ast.Module(body=body, type_ignores=[]))) + + +@pytest.mark.parametrize("name", ["_pool_replica_rvs", "_extract_mc_diag"]) +def test_ported_helper_is_identical_to_the_main_driver(name): + lisa = _defs(_LISA, [name])[name] + main = [n for n in ast.walk(ast.parse(_src(_MAIN))) + if isinstance(n, ast.FunctionDef) and n.name == name][0] + assert _normalized(lisa) == _normalized(main), \ + "%s has drifted between the two drivers (docstrings excluded)" % name + + +# ========================================================================================== +# BEHAVIOURAL coverage of _maybe_replicate_for_mc_error. +# +# Everything above this line tests _pool_replica_rvs (a pure function) behaviourally and the +# ORCHESTRATION only at source level. An adversarial review planted five bugs in the +# orchestration -- moving the _rvs_is_pooled reset off entry, dedenting the pooled-marker +# assignment out of `if _did_pool`, inverting `if _blocks_flattened`, neutering the POOLED +# collapse gate, and deleting the collapse-status OR -- and all five passed 220/220 tests. +# Substring and AST-name checks cannot see any of that. So: execute the helper. +# ========================================================================================== + +ORCH = ['_rvs_lnL_convention', 'ln_weights_from_rvs', '_rvs_len', + '_rvs_record_for', '_sampler_keeps_records', '_internal_record_of', + '_rebound_record', '_lw_of', '_lnZ_of_rvs', + '_kish_neff_of_rvs', '_extract_mc_diag', '_pool_replica_rvs', + '_maybe_save_av_state', '_reject_if_collapsed', '_report_and_gate_collapse', + '_maybe_replicate_for_mc_error'] + + +class _Collapse(Exception): + pass + + +class _AVmod(object): + LiveVolumeCollapse = _Collapse + + +class _RepSampler(object): + """Sampler whose integrate() returns a scripted list of replica results.""" + + def __init__(self, first_rvs, replicas, fairdraw_first=False): + self._rvs = first_rvs + self._rvs_is_fairdraw = fairdraw_first + self._rvs_is_pooled = False + self._warm_seed_reserve = None + self.params_ordered = ['x'] + self._queue = list(replicas) # [(res,var,neff,dd,rvs,fairdraw), ...] + self.saved = None + + def identity_convert(self, x): + return x + + def save_state(self, path): + self.saved = path + + def integrate(self, fn, *a, **kw): + res, var, neff, dd, rvs, fd = self._queue.pop(0) + self._rvs = rvs + self._rvs_is_fairdraw = fd + return res, var, neff, dd + + +def _load_orch(**optkw): + base = dict(mc_error_replicas=0, mc_error_sigma_trigger=1e9, + mc_error_khat_trigger=0.7, mc_error_ess_trigger=0.0, + reject_collapsed_live_volume=False, internal_use_lnL=True, + sampler_method='AV', sampler_save_state=None) + base.update(optkw) + defs = _defs(_LISA, ORCH) + mod = ast.Module(body=[defs[n] for n in ORCH], type_ignores=[]) + ns = {"numpy": np, "np": np, "SamplerOutputMixin": _SamplerOutputMixin, "mcsamplerAdaptiveVolume": _AVmod, + "mcsampler_AV_ok": True, "rvs_integrand_is_lnL": False, + "opts": type("O", (), base)()} + exec(compile(ast.fix_missing_locations(mod), "orch", "exec"), ns) + _assert_helper_set_is_closed(ns, ORCH) + return ns + + +def _run_orch(ns, sampler, dict_return, log_res=0.0, sigma=5.0, neff=1.0): + return ns['_maybe_replicate_for_mc_error']( + sampler, np.exp(log_res), 1.0, neff, dict_return, log_res, sigma, + lambda *a, **k: None, (), {'neff': 100.0}) + + +def test_no_trigger_means_no_replicas_and_nothing_changed(): + ns = _load_orch(mc_error_replicas=0) + s = _RepSampler(_rec([0.0, 0.0]), []) + out = _run_orch(ns, s, {}, sigma=0.001) + assert out[0:3] == (1.0, 1.0, 1.0) or out[2] == 1.0 + assert s._rvs_is_pooled is False + + +def test_sigma_trigger_runs_replicas_and_pools_them(): + ns = _load_orch(mc_error_replicas=2, mc_error_sigma_trigger=0.1) + s = _RepSampler(_rec([0.0] * 4), [ + (1.0, 1.0, 5.0, {}, _rec([0.0] * 4), False), + (1.0, 1.0, 5.0, {}, _rec([0.0] * 4), False)]) + out = _run_orch(ns, s, {}, sigma=5.0) + assert not s._queue, "the replica loop did not run the requested replicas" + assert s._rvs_is_pooled is True, "the pooled marker was not set" + assert _rvs_len(s._rvs) == 12, "the pooled record is not the concatenation" + assert out[2] > 0 + + +class _RecordingRepSampler(_RepSampler): + """A _RepSampler that PARTICIPATES in the record scheme (samples/set_samples).""" + + def __init__(self, *a, **kw): + _RepSampler.__init__(self, *a, **kw) + self._rvs_record = None + + def samples(self): + return self._rvs_record + + def set_samples(self, record): + self._rvs_record = record + return record + + +def test_pooling_clears_a_stale_record_on_a_record_keeping_sampler(): + """The LISA replica path publishes NO pooled record, so it must publish none at all. + + The main driver builds an _RvsRecord.pooled() here; this driver does not collect the + per-replica records to build one from, so the weight route falls back to the flags. + That fallback is correct -- but the record left on the sampler describes the PRE-POOL + columns, and it is otherwise declined only because _rvs_record_for compares by + identity and `_rvs` happens to become a new dict. Reading a per-pass record as if it + described the mixture would mix the replicas by row count instead of by evidence, + which is the exact defect the pooled weights exist to prevent. + """ + ns = _load_orch(mc_error_replicas=2, mc_error_sigma_trigger=0.1) + s = _RecordingRepSampler(_rec([0.0] * 4), [ + (1.0, 1.0, 5.0, {}, _rec([0.0] * 4), False), + (1.0, 1.0, 5.0, {}, _rec([0.0] * 4), False)]) + + class _StaleRecord(object): + internal = False + columns = s._rvs # describes the PRE-POOL columns + s.set_samples(_StaleRecord()) + + _run_orch(ns, s, {}, sigma=5.0) + assert s._rvs_is_pooled is True, "precondition: this test only means anything if it pooled" + assert s.samples() is None, \ + "a pre-pool record survived the pooling step: it would be read as the mixture" + + +def _rvs_len(rec): + for v in rec.values(): + return len(np.atleast_1d(np.asarray(v)).ravel()) + return 0 + + +def test_the_pooled_collapse_gate_fires_on_a_collapsed_REPLICA(): + """Mutation D: a healthy first run plus a collapsed replica must still be rejected. + + This is the whole reason the gate is called twice. The first-run gate sees nothing wrong. + """ + ns = _load_orch(mc_error_replicas=1, mc_error_sigma_trigger=0.1, + reject_collapsed_live_volume=True) + s = _RepSampler(_rec([0.0] * 4), [ + (1.0, 1.0, 5.0, {'live_volume_collapsed': True, 'collapse_reason': 'replica died'}, + _rec([0.0] * 4), False)]) + with pytest.raises(_Collapse) as e: + _run_orch(ns, s, {'live_volume_collapsed': False}, sigma=5.0) + assert "pooled over" in str(e.value) + + +def test_collapse_status_is_folded_back_as_the_OR(): + """Mutation E: the sidecar must not record collapsed=false for a tainted pool.""" + ns = _load_orch(mc_error_replicas=1, mc_error_sigma_trigger=0.1) + dd = {'live_volume_collapsed': False} + s = _RepSampler(_rec([0.0] * 4), [ + (1.0, 1.0, 5.0, {'live_volume_collapsed': True, 'collapse_reason': 'replica died'}, + _rec([0.0] * 4), False)]) + out = _run_orch(ns, s, dd, sigma=5.0) + got = out[5] + assert got['live_volume_collapsed'] is True, "a collapsed replica was not folded in" + assert got['n_replicas_pooled'] == 2 and got['n_replicas_collapsed'] == 1 + assert 'replica died' in got['collapse_reason'] + + +def test_the_pooled_marker_is_not_set_when_pooling_fell_back(): + """Mutation B: a fallback returns an INPUT record, which is not a pooled mixture. + + Records with no sampling-prior column make _pool_replica_rvs return replica 0 unchanged. + """ + bad = {'x': np.zeros(3), 'log_integrand': np.zeros(3)} # no *_joint_s_prior + ns = _load_orch(mc_error_replicas=1, mc_error_sigma_trigger=0.1) + s = _RepSampler(dict(bad), [(1.0, 1.0, 5.0, {}, dict(bad), False)]) + _run_orch(ns, s, {}, sigma=5.0) + assert s._rvs_is_pooled is False, \ + "the pooled marker was set even though pooling fell back to an input record" + + +def test_flattened_blocks_report_block_kish_not_the_export_row_count(): + """Mutation C: with fair-drawn replicas the pooled Kish is just the row count. + + Two agreeing replicas of n_eff 5 should give a pooled n_eff near their sum (10), not the + 12 rows of the export. + """ + ns = _load_orch(mc_error_replicas=1, mc_error_sigma_trigger=0.1) + # AGREEING replicas: with --internal-use-lnL the replica's lnZ IS its `res`, so the first + # run's log_res must match it or the two disagree and block-Kish correctly falls below the + # sum. (An earlier version of this test used 0.0 vs 1.0 and measured 8.24 -- the code was + # right and the setup was wrong, which is itself evidence the assertion is sensitive.) + s = _RepSampler(_rec([0.0] * 6), [(1.0, 1.0, 5.0, {}, _rec([0.0] * 6), True)], + fairdraw_first=True) + out = _run_orch(ns, s, {}, log_res=1.0, sigma=5.0, neff=5.0) + neff_out = float(out[2]) + assert 9.0 < neff_out < 11.0, ( + "expected block-Kish ~sum(neff)=10 for agreeing replicas, got %r (12 would be the " + "exported row count)" % neff_out) + + +def test_disagreeing_replicas_report_less_than_the_sum(): + """The property block-Kish exists for: disagreement must SHOW UP as lower n_eff.""" + ns = _load_orch(mc_error_replicas=1, mc_error_sigma_trigger=0.1) + s = _RepSampler(_rec([0.0] * 6), [(1.0, 1.0, 5.0, {}, _rec([0.0] * 6), True)], + fairdraw_first=True) + # replica lnZ far below the first run -> Z_k wildly unequal -> pooled neff -> ~5 + out = _run_orch(ns, s, {}, log_res=20.0, sigma=5.0, neff=5.0) + assert float(out[2]) < 9.0, "disagreeing replicas still reported the full sum" + + +# ========================================================================================== +# The XML export of a pooled record. +# +# The pool is deliberately weighted BETWEEN blocks (Z_k/K), and the SimInspiral export keeps no +# column carrying that: xmlutils maps joint_prior/joint_s_prior onto alpha2/alpha3, which the +# ILE export overwrites with zeros, and the log_joint_* columns the pool uses map to nothing. +# So the rows must be re-drawn to equal weight first, or downstream mixes the replicas by ROW +# COUNT instead of by evidence -- discarding the disagreement the replicas were run to measure. +# ========================================================================================== + +EXPORT = ['_rvs_lnL_convention', 'ln_weights_from_rvs', '_rvs_len', '_rvs_is_equal_weight', + '_rvs_record_for', '_sampler_keeps_records', '_internal_record_of', + '_rebound_record', '_lw_of', + 'ln_weights_for_posterior', '_export_rvs_equal_weight'] + + +@pytest.fixture(scope="module") +def EW(): + defs = _defs(_LISA, EXPORT) + mod = ast.Module(body=[defs[n] for n in EXPORT], type_ignores=[]) + ns = {"numpy": np, "np": np, "SamplerOutputMixin": _SamplerOutputMixin} + exec(compile(ast.fix_missing_locations(mod), "export", "exec"), ns) + _assert_helper_set_is_closed(ns, EXPORT) + return ns + + +class _ES(_S): + """Minimal sampler carrying the provenance markers the export helper keys on.""" + def __init__(self, pooled=True, fairdraw=True): + self._rvs_is_pooled = pooled + self._rvs_is_fairdraw = fairdraw + + +def test_a_record_that_was_never_pooled_is_exported_untouched(EW): + """Identity, not equality: no non-replica run may change shape because of this path.""" + r = _rec([0.0, 1.0, 2.0]) + assert EW['_export_rvs_equal_weight'](r, _ES(pooled=False)) is r + + +def test_the_pooled_export_mixes_replicas_by_EVIDENCE_not_by_row_count(EW): + """Block 1 has 3x the evidence of block 0 at equal row counts, so it must dominate. + + Both blocks are flat (each is its own equal-weight draw), which is exactly the case where + the row count carries no evidence information at all: unconverted, the XML would report the + two replicas as an even mixture. + """ + rec = {'log_integrand': np.zeros(8), + 'log_joint_prior': np.zeros(8), + # weights e^0 in block 0, e^log(3)=3 in block 1 + 'log_joint_s_prior': np.concatenate([np.zeros(4), -np.log(3.0) * np.ones(4)]), + 'x': np.concatenate([np.zeros(4), np.ones(4)])} + np.random.seed(7) + out = EW['_export_rvs_equal_weight'](rec, _ES()) + frac = float(np.mean(out['x'])) # share of rows from block 1 + assert 0.6 < frac < 0.9, ( + "pooled export mixed the replicas at %.2f; 0.5 is mixing by row count, 0.75 is the " + "evidence share" % frac) + assert _rvs_len(out) <= 8, "the export claims more rows than the pool held" + + +def test_an_unusable_pooled_record_is_returned_rather_than_mangled(EW): + bad = {'x': np.zeros(4)} # no weight components at all + assert EW['_export_rvs_equal_weight'](bad, _ES()) is bad + + +def test_both_xml_export_paths_convert_before_consuming_the_pool(): + """Source-level: the conversion must sit on the deepcopy, ahead of every consumer. + + Including resample_samples*, which picks a time per row and so assumes the rows already are + the posterior -- converting after it would leave that draw made from the wrong mixture. + """ + src = _src(_LISA) + copies = [i for i in range(len(src)) if src.startswith("copy.deepcopy(sampler._rvs)", i)] + assert len(copies) == 2, "expected two --save-samples export blocks, found %d" % len(copies) + for i in copies: + end = src.index("append_samples_to_xmldoc", i) # the block this deepcopy feeds + block = src[i:end] + assert "_export_rvs_equal_weight(samples, sampler" in block, \ + "an XML export path consumes the pooled record without converting it" + assert block.index("_export_rvs_equal_weight(samples, sampler") \ + < block.index("resample_time_marginalization"), \ + "the conversion happens after the time resampler has already drawn from the rows" + + +# --------------------------------------------- cold replicas on the standalone GMM sampler +class _GMMSampler(_RepSampler): + """mcsamplerEnsemble's shape: an `integrator` attribute and NONE of the reset methods. + + Warmth reaches a replica by two routes there, so a "cold" replica needs both cut: + integrate() transfers the previous integrator's fitted models into the new one, and the + gmm_dict it is handed is the caller's object, which the fit writes its models back into. + """ + + def __init__(self, first_rvs, replicas): + _RepSampler.__init__(self, first_rvs, replicas) + self.integrator = object() # the first run's fitted integrator + self.seen_integrator = "unset" + self.seen_gmm = None + + def integrate(self, fn, *a, **kw): + self.seen_integrator = self.integrator + self.seen_gmm = dict(kw.get('gmm_dict') or {}) + return _RepSampler.integrate(self, fn, *a, **kw) + + +def test_a_standalone_GMM_replica_is_cold_in_both_warm_start_channels(): + """Sharing the first run's proposal keeps the same mode missed in every replica. + + The between-replica scatter is then a measure of the draws alone, understating exactly the + MC error the replicas were run to expose. + """ + ns = _load_orch(mc_error_replicas=1, mc_error_sigma_trigger=0.1, sampler_method='GMM') + fitted, seeded = object(), object() + sky, phase = ('right_ascension', 'declination'), ('psi', 'phi_orb') + gmm_dict = {sky: fitted, phase: seeded} + gmm_adapt = {sky: True, phase: False} + s = _GMMSampler(_rec([0.0] * 4), [(1.0, 1.0, 5.0, {}, _rec([0.0] * 4), False)]) + ns['_maybe_replicate_for_mc_error']( + s, 1.0, 1.0, 1.0, {}, 0.0, 5.0, lambda *a, **k: None, (), + {'neff': 100.0, 'gmm_dict': gmm_dict, 'gmm_adapt': gmm_adapt}) + assert s.seen_integrator is None, \ + "the replica ran with the previous integrator, whose fitted models integrate() transfers" + assert s.seen_gmm[sky] is None, \ + "the replica inherited the first run's fit through the aliased gmm_dict" + assert s.seen_gmm[phase] is seeded, ( + "the fixed non-adapting proposal was blanked; _train skips that group, so it would " + "have no model at all and the group would degrade to uniform sampling") + + +def test_the_GMM_cold_reset_does_not_touch_samplers_that_have_their_own(): + """A portfolio owns clear_warm_state/reset_adaptation and no `integrator`: unchanged.""" + class _Portfolio(_RepSampler): + def __init__(self, *a, **kw): + _RepSampler.__init__(self, *a, **kw) + self.cleared = 0 + self.seen_gmm = None + + def reset_adaptation(self): + self.cleared += 1 + + def integrate(self, fn, *a, **kw): + self.seen_gmm = dict(kw.get('gmm_dict') or {}) + return _RepSampler.integrate(self, fn, *a, **kw) + + ns = _load_orch(mc_error_replicas=1, mc_error_sigma_trigger=0.1) + sky = ('right_ascension', 'declination') + fitted = object() + s = _Portfolio(_rec([0.0] * 4), [(1.0, 1.0, 5.0, {}, _rec([0.0] * 4), False)]) + ns['_maybe_replicate_for_mc_error']( + s, 1.0, 1.0, 1.0, {}, 0.0, 5.0, lambda *a, **k: None, (), + {'neff': 100.0, 'gmm_dict': {sky: fitted}, 'gmm_adapt': {sky: True}}) + assert s.cleared == 1, "the portfolio's own reset stopped being called" + assert s.seen_gmm[sky] is fitted, \ + "the GMM branch reached a sampler that rebuilds its members from their setup arguments" + + +def test_a_failing_replica_is_skipped_not_fatal(): + class _Boom(_RepSampler): + def integrate(self, fn, *a, **kw): + raise RuntimeError("replica exploded") + + ns = _load_orch(mc_error_replicas=1, mc_error_sigma_trigger=0.1) + s = _Boom(_rec([0.0] * 4), []) + out = _run_orch(ns, s, {}, sigma=5.0) + assert out is not None and out[2] == 1.0 diff --git a/MonteCarloMarginalizeCode/Code/test/test_lisa_portfolio_method_integrity.py b/MonteCarloMarginalizeCode/Code/test/test_lisa_portfolio_method_integrity.py new file mode 100644 index 000000000..230e18039 --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/test/test_lisa_portfolio_method_integrity.py @@ -0,0 +1,387 @@ +#!/usr/bin/env python +""" +`opts.sampler_method` must survive portfolio construction. + +THE DEFECT. Building a portfolio that carries a GMM member used to CLOBBER +`opts.sampler_method = 'GMM'`, so the GMM-specific argument blocks further down would run +and forward that member's config. It worked for that, and silently broke everything else +that asks "what sampler is this run using", because by then the honest answer -- 'portfolio' +-- had been overwritten. + +The consequence that matters here: the **L0 auto-rescue never fired for a portfolio**. Its +guard is + + opts.sampler_method in ('AV', 'portfolio') + +so for the single most common portfolio configuration -- one carrying a GMM member -- the +rescue silently declined, on a driver where the rescue had just been ported specifically +because LISA MBHB are high-SNR and that is the regime that stalls. No error, no log line; +the feature was simply absent. + +A portfolio also took GMM-only branches, `return_lnI` among them, which feeds +`rvs_integrand_is_lnL` and therefore how `ln_weights_from_rvs` reads the record. + +THE FIX, ported from the main driver, which had already made it: flag the member +non-destructively. `opts.sampler_method` stays 'portfolio'; the GMM blocks key off +`use_gmm_args = (sampler_method == "GMM") or use_gmm_member`. + +WHY THIS FILE IS SHAPED AS AN INVARIANT. A mutation of a shared option is not a FUNC, +OPTION, CONST or ATTR, so the drift audit produces zero gap items for it -- the same blind +spot that hid the missing AV/`use_lnL` branch. The first test below is therefore the +general rule ("nothing assigns opts.sampler_method") rather than a check on this one site, +because the next such clobber will be somewhere else. +""" + +import ast +from RIFT.integrators.rvs_record import SamplerOutputMixin as _SamplerOutputMixin +import os + +import pytest + +_HERE = os.path.dirname(os.path.abspath(__file__)) +_LISA = os.path.join(_HERE, '..', 'bin', 'integrate_likelihood_extrinsic_batchmode_lisa') +_MAIN = os.path.join(_HERE, '..', 'bin', 'integrate_likelihood_extrinsic_batchmode') + + +def _src(path): + with open(path) as fh: + return fh.read() + + +def _assignments_to(path, attr): + """Line numbers where `opts.` is assigned (=, augmented, or walrus-ish).""" + tree = ast.parse(_src(path), filename=path) + hits = [] + for node in ast.walk(tree): + targets = [] + if isinstance(node, ast.Assign): + targets = node.targets + elif isinstance(node, ast.AugAssign): + targets = [node.target] + for t in targets: + if (isinstance(t, ast.Attribute) and t.attr == attr + and isinstance(t.value, ast.Name) and t.value.id == 'opts'): + hits.append(node.lineno) + return hits + + +# ------------------------------------------------------------------------- the invariant +@pytest.mark.parametrize("path,label", [(_LISA, 'lisa'), (_MAIN, 'main')]) +def test_nothing_assigns_opts_sampler_method(path, label): + """The general rule, in BOTH drivers. + + `opts.sampler_method` is read by the L0 rescue gate, the AV state save, the use_lnL + branch table and several per-event resets. Any code that reassigns it makes every one + of those answer a question about a sampler the run is not using. + """ + hits = _assignments_to(path, 'sampler_method') + assert not hits, ( + "%s driver assigns opts.sampler_method at line(s) %s. Flag the condition " + "non-destructively (see use_gmm_member) instead of overwriting the run's identity." + % (label, hits)) + + +def test_the_rescue_guard_still_reads_sampler_method(): + """If the guard stops reading it, the invariant above protects nothing. + + Pins the two together so neither can be quietly relaxed on its own. + """ + assert "opts.sampler_method in ('AV', 'portfolio')" in _src(_LISA) + + +# ------------------------------------------------------------------- the replacement flag +def test_portfolio_loop_flags_a_GMM_member_without_clobbering(): + src = _src(_LISA) + assert 'use_gmm_member = True' in src, "the GMM member is not flagged at all" + assert "opts.sampler_method = 'GMM'" not in src, "the clobber is back" + assert 'use_gmm_member=False' in src, "the flag is never initialised" + + +def test_use_gmm_args_is_standalone_GMM_or_a_portfolio_member(): + assert 'use_gmm_args = (opts.sampler_method == "GMM") or use_gmm_member' in _src(_LISA) + + +def test_use_gmm_args_is_defined_before_every_use(): + src = _src(_LISA) + define = src.index('use_gmm_args = (opts.sampler_method') + first_use = src.index('if use_gmm_args:') + assert define < first_use + tree = ast.parse(src) + define_line = min(n.lineno for n in ast.walk(tree) + if isinstance(n, ast.Assign) and len(n.targets) == 1 + and getattr(n.targets[0], 'id', None) == 'use_gmm_args') + module_level_uses = [n.lineno for n in ast.walk(tree) + if isinstance(n, ast.Name) and n.id == 'use_gmm_args' + and isinstance(n.ctx, ast.Load)] + # uses inside analyze_event run later regardless; only module-level order can break. + assert min(module_level_uses) >= define_line + + +def test_the_GMM_setup_block_runs_for_a_portfolio_member(): + """This is what the clobber existed to achieve, now achieved honestly.""" + src = _src(_LISA) + i = src.index('use_gmm_args = (opts.sampler_method') + block = src[i:i + 400] + assert 'if use_gmm_args:' in block, "the GMM setup block no longer runs for a portfolio member" + + +def test_per_event_gmm_resets_key_off_use_gmm_args(): + """gmm_dict exists for a portfolio-with-GMM too, so the resets must reach it. + + Two analyze_event variants plus the --force-reset-all block: three sites. + """ + src = _src(_LISA) + assert src.count('elif use_gmm_args:') == 3, \ + "expected the two per-event resets and --force-reset-all to key off use_gmm_args" + + +def test_return_lnI_still_keys_on_the_method_not_the_member(): + """A portfolio must NOT take the GMM lnL branch. + + This is the other half of the clobber's damage: with sampler_method overwritten, a + portfolio run set return_lnI, which flips rvs_integrand_is_lnL and changes how + ln_weights_from_rvs reads the record. + """ + src = _src(_LISA) + assert 'if opts.sampler_method=="GMM" and opts.internal_use_lnL:' in src + i = src.index('if opts.sampler_method=="GMM" and opts.internal_use_lnL:') + assert 'return_lnI' in src[i:i + 300] + # and it must not have been widened to the member flag + assert 'use_gmm_args' not in src[i:i + 300], \ + "the return_lnI branch was widened to portfolios carrying a GMM member" + + +def _driver_def_names(path): + """Every top-level name the driver BINDS: functions and imports alike. + + Imports are in here because of a real miss: the guard originally covered only defs, so + `SamplerOutputMixin` -- imported by the driver, referenced by _sampler_keeps_records -- + slipped straight through it and surfaced as a NameError inside an exec'd helper. + """ + with open(path) as fh: # read directly: _src() differs between these harnesses + src = fh.read() + names = set() + for n in ast.parse(src).body: + if isinstance(n, ast.FunctionDef): + names.add(n.name) + elif isinstance(n, (ast.Import, ast.ImportFrom)): + for a in n.names: + if a.name != '*': + names.add(a.asname or a.name.split('.')[0]) + return names + + +def _assert_helper_set_is_closed(ns, names, path): + """Fail LOUDLY if an exec'd helper calls a driver helper that was not exec'd with it. + + Same guard as the other LISA harnesses. The failure it prevents is silent: the + callers here catch broadly, so a missing name turns into "the rescue did not fire" + rather than a NameError naming the helper. + """ + driver = _driver_def_names(path) + missing = {} + for name in names: + code = getattr(ns.get(name), "__code__", None) + if code is None: + continue + stack, seen = [code], set() + while stack: + c = stack.pop() + if id(c) in seen: + continue + seen.add(id(c)) + for used in c.co_names: + if used in driver and used not in ns: + missing.setdefault(name, set()).add(used) + stack.extend(k for k in c.co_consts if hasattr(k, "co_names")) + assert not missing, ( + "exec'd helper set is not closed -- add these to the name list:\n " + + "\n ".join("%s needs %s" % (k, sorted(v)) for k, v in sorted(missing.items()))) + + +# ------------------------------------------------------------- the rescue actually fires +def _load_rescue(sampler_method): + """Exec the rescue with a chosen opts.sampler_method.""" + # The record accessors ride along because the ported helpers resolve their weights + # through _lw_of / _rvs_record_for. Omitting one is NOT a visible NameError here -- + # _maybe_l0_rescue catches it and the rescue simply never fires, which shows up as + # "the rescue did not fire", three layers from the cause. Guarded below. + names = ['_rvs_lnL_convention', 'ln_weights_from_rvs', '_rvs_len', + '_rvs_is_export_resample', '_rvs_is_equal_weight', + '_rvs_record_for', '_sampler_keeps_records', '_internal_record_of', + '_rebound_record', '_lw_of', 'ln_weights_for_posterior', + '_lnZ_of_rvs', '_kish_neff_of_rvs', '_lnZ_of_reserve_or_rvs', + '_snapshot_pass_state', '_restore_pass_state', '_warm_seed_reserve_for', + '_warm_seed_geometry', '_clear_warm_state', '_maybe_l0_rescue'] + import numpy as np + defs = {n.name: n for n in ast.parse(_src(_LISA)).body + if isinstance(n, ast.FunctionDef) and n.name in names} + mod = ast.Module(body=[defs[n] for n in names], type_ignores=[]) + + class _AV(object): + @staticmethod + def lnZ_from_reserve(r): + return None + + @staticmethod + def build_warm_seed(cols, lnL, lo, hi, axes, **kw): + return np.asarray(cols, dtype=float), {'puffed': False, 'n_core': 3, + 'rank_core': 3, 'dim': 3, + 'rank_final': 3, 'n_puff': 0, + 'puff_scale': 'auto'} + + opts = type('O', (), { + 'sampler_method': sampler_method, 'sampler_warmstart_retry_neff': 5.0, + 'sampler_l0_rescue_reject_dlnZ': 3.0, 'sampler_l0_rescue_accept_truncated': False, + 'sampler_l0_rescue_puff_scale': 'auto', 'sampler_l0_rescue_puff_width_frac': 0.005, + 'sampler_l0_rescue_puff_factor': 2.0, + 'sampler_sequential_warmstart_deltalnL': 15.0})() + ns = {"numpy": np, "np": np, "SamplerOutputMixin": _SamplerOutputMixin, "opts": opts, "mcsamplerAdaptiveVolume": _AV} + exec(compile(ast.fix_missing_locations(mod), "rescue", "exec"), ns) + _assert_helper_set_is_closed(ns, names, _LISA) + return ns + + +class _Sampler(object): + def __init__(self): + import numpy as np + n = 3 + self._rvs = {'log_integrand': np.zeros(n), 'log_joint_prior': np.zeros(n), + 'log_joint_s_prior': np.zeros(n), + 'a': np.linspace(0.1, 0.9, n), 'b': np.linspace(0.2, 0.8, n)} + self._warm_seed_reserve = None + self.params_ordered = ['a', 'b'] + self.llim = {'a': 0.0, 'b': 0.0} + self.rlim = {'a': 1.0, 'b': 1.0} + self.portfolio_realizations = [] + self._warm = None + self._warm_applied = False + self.bootstrapped = None + + def identity_convert(self, x): + return x + + def bootstrap_from_samples(self, seed, cover_frac=0.0): + self.bootstrapped = seed + + def integrate(self, fn, *a, **k): + return ('R2', 'V2', 42.0, {'warm': True}) + + +@pytest.mark.parametrize("method", ['portfolio', 'AV']) +def test_rescue_fires_for_both_eligible_methods(method): + """The end the whole fix serves. + + With the clobber, a portfolio carrying a GMM member arrived here as 'GMM' and this + returned untouched -- no bootstrap, no warm pass, no message. + """ + ns = _load_rescue(method) + s = _Sampler() + out = ns['_maybe_l0_rescue'](s, 'R1', 'V1', 1.0, {'cold': True}, + lambda *a, **k: None, (), {}) + assert s.bootstrapped is not None, "the rescue did not fire for %s" % method + assert out[2] == 42.0 + + +def test_rescue_declines_for_a_clobbered_method(): + """The failure mode itself, pinned: if the method ever reads 'GMM', the rescue is off. + + Not an argument that declining for standalone GMM is wrong -- it is correct, GMM has no + bootstrap_from_samples in practice. It documents that the guard is exactly what the + clobber defeated, so the invariant above is what protects it. + """ + ns = _load_rescue('GMM') + s = _Sampler() + ns['_maybe_l0_rescue'](s, 'R1', 'V1', 1.0, {'cold': True}, lambda *a, **k: None, (), {}) + assert s.bootstrapped is None + + +# --------------------------------------------------- the member-dispatch chain itself +def _member_loop(path): + """The `for name in sampler_types:` loop body, as AST.""" + for node in ast.walk(ast.parse(_src(path), filename=path)): + if (isinstance(node, ast.For) and isinstance(node.target, ast.Name) + and node.target.id == 'name' + and isinstance(node.iter, ast.Name) and node.iter.id == 'sampler_types'): + return node + raise AssertionError("no `for name in sampler_types` loop in %s" % os.path.basename(path)) + + +@pytest.mark.parametrize("path,label", [(_LISA, 'lisa'), (_MAIN, 'main')]) +def test_member_dispatch_is_a_single_elif_chain(path, label): + """A chain of separate `if`s reuses the previous member on an unmatched name. + + With `if name == 'AV': ... ; if name == 'GMM': ...` a name matching NOTHING falls + through every test and leaves `sampler` bound to whatever it last held -- the plain + MCSampler built before the chain, or on later iterations the PREVIOUS member -- which is + then appended. A typo in --sampler-portfolio silently produced a DUPLICATE member + rather than an error. + """ + loop = _member_loop(path) + # Only the statements that DISPATCH ON THE MEMBER NAME. The loop body also holds an + # `if hasattr(sampler, 'xpy')` after the chain in both drivers, which is not part of it. + dispatch = [st for st in loop.body + if isinstance(st, ast.If) + and any(isinstance(n, ast.Name) and n.id == 'name' + for n in ast.walk(st.test))] + assert len(dispatch) == 1, ( + "%s: member dispatch is %d separate `if` statements, not one elif chain; an " + "unmatched name reuses the previous member" % (label, len(dispatch))) + + +@pytest.mark.parametrize("path,label", [(_LISA, 'lisa'), (_MAIN, 'main')]) +def test_an_unknown_member_name_raises(path, label): + """The chain must END in an else that raises, not fall off silently.""" + node = [st for st in _member_loop(path).body + if isinstance(st, ast.If) + and any(isinstance(n, ast.Name) and n.id == 'name' + for n in ast.walk(st.test))][0] + while isinstance(node, ast.If): + tail = node.orelse + if len(tail) == 1 and isinstance(tail[0], ast.If): + node = tail[0] + continue + break + assert tail, "%s: the member dispatch chain has no else clause" % label + assert any(isinstance(st, ast.Raise) for st in tail), ( + "%s: the else clause does not raise, so an unknown --sampler-portfolio member is " + "accepted silently" % label) + + +def test_plugin_pipelines_are_dispatched_before_the_error(): + """A plugin member (nflow, ...) must CONSTRUCT, not fall through to the raise. + + Checking that the string "known_pipelines" merely appears is not enough: it also appears + in the error message, so deleting the whole dispatch branch left that check green. Walk + the chain and require a branch that both TESTS and SUBSCRIPTS known_pipelines. + """ + node = [st for st in _member_loop(_LISA).body + if isinstance(st, ast.If) + and any(isinstance(n, ast.Name) and n.id == 'name' + for n in ast.walk(st.test))][0] + found = False + while isinstance(node, ast.If): + tests_it = any(isinstance(a, ast.Attribute) and a.attr == 'known_pipelines' + for a in ast.walk(node.test)) + builds_it = any(isinstance(sub, ast.Subscript) + and any(isinstance(a, ast.Attribute) and a.attr == 'known_pipelines' + for a in ast.walk(sub.value)) + for sub in ast.walk(ast.Module(body=node.body, type_ignores=[]))) + if tests_it and builds_it: + found = True + break + node = node.orelse[0] if (len(node.orelse) == 1 + and isinstance(node.orelse[0], ast.If)) else None + if node is None: + break + assert found, ("no branch dispatches to mcsamplerPortfolio.known_pipelines, so a plugin " + "member falls through to the unknown-member error") + + +def test_the_unknown_member_error_names_what_is_known(): + assert "--sampler-portfolio: unknown member" in _src(_LISA) + + +def test_AC_is_accepted_as_an_alias(): + """main accepts 'AC' alongside 'adaptive_cartesian_gpu'; a portfolio spec is shared.""" + assert "name == 'AC'" in _src(_LISA) diff --git a/MonteCarloMarginalizeCode/Code/test/test_lisa_sampler_plumbing.py b/MonteCarloMarginalizeCode/Code/test/test_lisa_sampler_plumbing.py new file mode 100644 index 000000000..976d17169 --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/test/test_lisa_sampler_plumbing.py @@ -0,0 +1,223 @@ +#!/usr/bin/env python +""" +Tests for the portfolio freeze/allocation policy ported into the LISA ILE driver +(bin/integrate_likelihood_extrinsic_batchmode_lisa). + +This is pure PASS-THROUGH plumbing to samplers the LISA driver already wires -- it exposes +the same ``ok_lnL_methods`` as the main driver (``GMM, adaptive_cartesian, +adaptive_cartesian_gpu, AV, portfolio``, verified identical) and builds mcsamplerPortfolio +the same way. Before this port the knobs were reachable only through +``--sampler-portfolio-args``, an eval-able dict; the pipeline passes the named flags. + +WHAT CAN ACTUALLY GO WRONG HERE, and is therefore what these tests check: + + * a default that differs between the two drivers. Worse than a missing option: the same + command line then means two different things depending on which driver ran it. + * an option that is UNSET leaking into the kwargs as ``None`` and overriding the sampler's + own default with nothing. The assembly's whole shape -- ``if opts.x is not None`` -- + exists for that, and a single dropped guard is invisible until a run behaves oddly. + * the two mutually-exclusive VARAHA flags resolving the wrong way round. + +The freeze-policy assembly is inline in both drivers (not a function), so it is exercised +here by extracting the block and exec'ing it against a fake ``opts``. That tests the real +source, not a paraphrase of it. +""" + +import ast +import os +import re +import textwrap + +import pytest + +_HERE = os.path.dirname(os.path.abspath(__file__)) +_LISA = os.path.join(_HERE, '..', 'bin', 'integrate_likelihood_extrinsic_batchmode_lisa') +_MAIN = os.path.join(_HERE, '..', 'bin', 'integrate_likelihood_extrinsic_batchmode') + +PORTFOLIO_OPTS = [ + "--portfolio-adaptive-alloc", "--portfolio-alloc-exponent", "--portfolio-freeze-wt", + "--portfolio-grace-iters", "--portfolio-probe-period", "--portfolio-quality-signal", + "--portfolio-revive-period", "--portfolio-varaha-can-freeze", + "--portfolio-varaha-max-frac", "--portfolio-varaha-min-frac", + "--portfolio-varaha-never-freeze", "--portfolio-weight-clip", +] + + +def _src(path): + with open(path) as fh: + return fh.read() + + +def _option_nodes(path): + """{'--foo': ast.Call} for every add_option in a driver.""" + out = {} + for n in ast.walk(ast.parse(_src(path), filename=path)): + if (isinstance(n, ast.Call) and isinstance(n.func, ast.Attribute) + and n.func.attr in ("add_option", "add_argument")): + names = [a.value for a in n.args + if isinstance(a, ast.Constant) and isinstance(a.value, str)] + if names and names[0].startswith("--"): + out[names[0]] = n + return out + + +def _kwargs_of(node): + out = {} + for kw in node.keywords: + try: + out[kw.arg] = ast.literal_eval(kw.value) + except Exception: + out[kw.arg] = ast.dump(kw.value) + return out + + +@pytest.fixture(scope="module") +def opts_lisa(): + return _option_nodes(_LISA) + + +@pytest.fixture(scope="module") +def opts_main(): + return _option_nodes(_MAIN) + + +# ------------------------------------------------------------------------------ presence +@pytest.mark.parametrize("opt", PORTFOLIO_OPTS) +def test_option_is_present_in_the_lisa_driver(opt, opts_lisa): + assert opt in opts_lisa + + +# ------------------------------------------------------------------------------- defaults +@pytest.mark.parametrize("opt", PORTFOLIO_OPTS) +def test_option_signature_matches_the_main_driver(opt, opts_lisa, opts_main): + """Same default, same type, same action. + + A knob that means something different in the two drivers is worse than a missing one: + the same pipeline command line would then produce two different integrations. + """ + a, b = _kwargs_of(opts_lisa[opt]), _kwargs_of(opts_main[opt]) + for key in ("default", "type", "action", "choices"): + assert a.get(key) == b.get(key), ( + "%s: %s differs (lisa=%r, main=%r)" % (opt, key, a.get(key), b.get(key))) + + +@pytest.mark.parametrize("opt", [ + "--portfolio-alloc-exponent", "--portfolio-freeze-wt", "--portfolio-grace-iters", + "--portfolio-probe-period", "--portfolio-quality-signal", "--portfolio-revive-period", + "--portfolio-varaha-max-frac", "--portfolio-varaha-min-frac", "--portfolio-weight-clip", +]) +def test_tuning_options_default_to_none_so_the_sampler_keeps_its_own(opt, opts_lisa): + """None is the sentinel the assembly keys on. A default of 0/0.0 would silently + override the sampler's built-in value for every run that never set the flag.""" + assert _kwargs_of(opts_lisa[opt]).get("default") is None + + +@pytest.mark.parametrize("opt", [ + "--portfolio-adaptive-alloc", "--portfolio-varaha-can-freeze", + "--portfolio-varaha-never-freeze", +]) +def test_flags_are_store_true_and_default_false(opt, opts_lisa): + kw = _kwargs_of(opts_lisa[opt]) + assert kw.get("action") == "store_true" and kw.get("default") is False + + +# --------------------------------------------------------- the assembly block, executed +_START = "_freeze_policy_kwargs = {}" +_END = 'print(" PORTFOLIO freeze-policy overrides: "' + + +def _assembly_block(path): + """The inline freeze-policy assembly, dedented so it can be exec'd on its own. + + Slice from the START OF THE LINE holding the sentinel, not from the sentinel itself: + otherwise the first line carries no indentation while the rest do, and dedent finds no + common prefix. + """ + src = _src(path) + i = src.rindex("\n", 0, src.index(_START)) + 1 + j = src.index(_END, i) + j = src.rindex("\n", i, j) + 1 + return textwrap.dedent(src[i:j]) + + +class _Opts(object): + """Every portfolio option at its documented default.""" + portfolio_grace_iters = None + portfolio_revive_period = None + portfolio_freeze_wt = None + portfolio_varaha_can_freeze = False + portfolio_varaha_never_freeze = False + portfolio_adaptive_alloc = False + portfolio_varaha_min_frac = None + portfolio_varaha_max_frac = None + portfolio_weight_clip = None + portfolio_quality_signal = None + portfolio_alloc_exponent = None + portfolio_probe_period = None + + def __init__(self, **kw): + for k, v in kw.items(): + assert hasattr(type(self), k), "unknown option %s" % k + setattr(self, k, v) + + +def _assemble(**kw): + ns = {"opts": _Opts(**kw)} + exec(compile(_assembly_block(_LISA), "freeze_policy", "exec"), ns) + return ns["_freeze_policy_kwargs"] + + +def test_nothing_set_means_nothing_overridden(): + """The important one: an all-defaults run must not touch the sampler's policy at all.""" + assert _assemble() == {} + + +def test_each_tuning_option_passes_through_when_set(): + got = _assemble(portfolio_grace_iters=7, portfolio_revive_period=3, + portfolio_freeze_wt=0.25, portfolio_varaha_min_frac=0.2, + portfolio_varaha_max_frac=0.8, portfolio_weight_clip=1.0, + portfolio_quality_signal='credit', portfolio_alloc_exponent=2.0, + portfolio_probe_period=5) + assert got == {'portfolio_grace_iters': 7, 'portfolio_revive_period': 3, + 'portfolio_freeze_wt': 0.25, 'portfolio_varaha_min_frac': 0.2, + 'portfolio_varaha_max_frac': 0.8, 'portfolio_weight_clip': 1.0, + 'portfolio_quality_signal': 'credit', 'portfolio_alloc_exponent': 2.0, + 'portfolio_probe_period': 5} + + +def test_zero_is_passed_through_not_treated_as_unset(): + """0 disables probing/reviving and is a REAL value; `if x:` would drop it.""" + got = _assemble(portfolio_probe_period=0, portfolio_revive_period=0) + assert got == {'portfolio_probe_period': 0, 'portfolio_revive_period': 0} + + +def test_varaha_never_freeze_sets_true(): + assert _assemble(portfolio_varaha_never_freeze=True) == {'portfolio_varaha_never_freeze': True} + + +def test_varaha_can_freeze_sets_false(): + assert _assemble(portfolio_varaha_can_freeze=True) == {'portfolio_varaha_never_freeze': False} + + +def test_can_freeze_wins_when_both_are_given(): + """Documented precedence; the two flags are mutually exclusive.""" + got = _assemble(portfolio_varaha_can_freeze=True, portfolio_varaha_never_freeze=True) + assert got == {'portfolio_varaha_never_freeze': False} + + +def test_adaptive_alloc_is_opt_in_only(): + assert 'portfolio_adaptive_alloc' not in _assemble() + assert _assemble(portfolio_adaptive_alloc=True) == {'portfolio_adaptive_alloc': True} + + +def test_assembly_block_is_identical_to_the_main_drivers(): + """Deliberate copies in a deliberate fork. Change one, change both.""" + def norm(s): + return re.sub(r"\s+", " ", s).strip() + assert norm(_assembly_block(_LISA)) == norm(_assembly_block(_MAIN)) + + +def test_assembly_result_is_actually_handed_to_setup(): + """Building the dict and not passing it would be a silent no-op.""" + src = _src(_LISA) + assert "sampler.setup(portfolio_args=opts.sampler_portfolio_args, **_freeze_policy_kwargs" in src diff --git a/MonteCarloMarginalizeCode/Code/test/test_lisa_use_lnL_branches.py b/MonteCarloMarginalizeCode/Code/test/test_lisa_use_lnL_branches.py new file mode 100644 index 000000000..894881634 --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/test/test_lisa_use_lnL_branches.py @@ -0,0 +1,193 @@ +#!/usr/bin/env python +""" +The per-sampler `use_lnL` / `return_lnI` branches in the LISA ILE driver. + +FOUND BY ADVERSARIAL AUDIT, NOT BY THE DRIFT GATE. The main driver has + + if opts.sampler_method == "AV" and opts.internal_use_lnL: + return_lnL = True + pinned_params.update({"use_lnL": True}) + +with the comment: *"without this, --internal-use-lnL --sampler-method AV passed the +ok_lnL_methods check but silently did nothing, so exp(lnL) overflowed at high SNR when no +logarithm offset was set."* The LISA driver had branches for GMM, adaptive_cartesian_gpu +and portfolio -- and none for AV. + +High SNR is the LISA MBHB regime, so this is the case, not an edge. + +WHY THE DRIFT AUDIT MISSED IT, and why this file exists. A missing `if` branch is not a +FUNC, OPTION, CONST or ATTR, so it produces zero gap items. The audit is a name-presence +set difference; behaviour behind a shared name is invisible to it. These tests close that +specific hole by pinning the branch TABLE in both drivers against each other. +""" + +import ast +import os + +import pytest + +_HERE = os.path.dirname(os.path.abspath(__file__)) +_LISA = os.path.join(_HERE, '..', 'bin', 'integrate_likelihood_extrinsic_batchmode_lisa') +_MAIN = os.path.join(_HERE, '..', 'bin', 'integrate_likelihood_extrinsic_batchmode') + +# Samplers both drivers accept. Verified identical in both ok_lnL_methods lists. +METHODS = ['GMM', 'adaptive_cartesian', 'adaptive_cartesian_gpu', 'AV', 'portfolio'] + + +def _src(path): + with open(path) as fh: + return fh.read() + + +def _pinned_updates(path): + """{method: {key: value}} for every `pinned_params.update({...})` guarded by a method test. + + Walks module-level `if` statements, works out which sampler method each one is about + from the string constants in its test, and records the pinned_params keys it sets. + """ + tree = ast.parse(_src(path), filename=path) + out = {} + for node in tree.body: + if not isinstance(node, ast.If): + continue + methods = {c.value for c in ast.walk(node.test) + if isinstance(c, ast.Constant) and c.value in METHODS} + if not methods: + continue + uses_lnL_opt = any(isinstance(a, ast.Attribute) and a.attr == 'internal_use_lnL' + for a in ast.walk(node.test)) + keys = {} + for call in ast.walk(node): + if (isinstance(call, ast.Call) and isinstance(call.func, ast.Attribute) + and call.func.attr == 'update' + and isinstance(call.func.value, ast.Name) + and call.func.value.id == 'pinned_params'): + for arg in call.args: + if isinstance(arg, ast.Dict): + for k, v in zip(arg.keys, arg.values): + if isinstance(k, ast.Constant): + try: + keys[k.value] = ast.literal_eval(v) + except Exception: + keys[k.value] = '' + if keys: + for m in methods: + rec = out.setdefault(m, {"keys": {}, "gated_on_internal_use_lnL": False}) + rec["keys"].update(keys) + rec["gated_on_internal_use_lnL"] |= uses_lnL_opt + return out + + +@pytest.fixture(scope="module") +def lisa(): + return _pinned_updates(_LISA) + + +@pytest.fixture(scope="module") +def main(): + return _pinned_updates(_MAIN) + + +def test_AV_sets_use_lnL_under_internal_use_lnL(lisa): + """The regression this file exists for. + + Without it, --sampler-method AV --internal-use-lnL is a SILENT no-op: the option passes + the ok_lnL_methods check and changes nothing, so the integrand stays linear and exp(lnL) + overflows at high SNR unless a manual logarithm offset happens to be set. + """ + assert 'AV' in lisa, "no AV branch sets pinned_params at all" + assert lisa['AV']['keys'].get('use_lnL') is True, \ + "--sampler-method AV --internal-use-lnL does not set use_lnL: silent no-op" + assert lisa['AV']['gated_on_internal_use_lnL'], \ + "the AV branch must be gated on --internal-use-lnL, not unconditional" + + +@pytest.mark.parametrize("method", ['GMM', 'adaptive_cartesian_gpu', 'AV']) +def test_branch_table_matches_the_main_driver(method, lisa, main): + """Same method -> same pinned_params keys in both drivers. + + This is the check that would have caught the missing AV branch, and it is the shape the + name-based drift audit cannot express. + """ + assert method in main, "the main driver has no %s branch to compare against" % method + assert method in lisa, "the LISA driver has no %s branch" % method + assert lisa[method]['keys'] == main[method]['keys'], ( + "%s: pinned_params differ (lisa=%r, main=%r)" + % (method, lisa[method]['keys'], main[method]['keys'])) + + +def test_portfolio_differs_from_main_only_by_the_deferred_GMM_forwarding(lisa, main): + """portfolio is the ONE branch still divergent, and only in a known, recorded way. + + The main driver's portfolio branch also forwards the --internal-gmm-* knobs to its GMM + member (gmm_adaptive / gmm_defensive_frac / gmm_inflate). Those options are deliberately + deferred: main wires them through its group-pairing setup, and this driver's GMM block is + structured differently, so they need their own pass. + + Asserting the delta EXACTLY -- rather than skipping portfolio -- means any OTHER + divergence in this branch still fails, and this test tightens on its own once the GMM + pass lands. + """ + deferred = {'gmm_adaptive', 'gmm_defensive_frac', 'gmm_inflate'} + lk, mk = lisa['portfolio']['keys'], main['portfolio']['keys'] + assert set(mk) - set(lk) == deferred, ( + "portfolio branch diverges beyond the deferred GMM forwarding: missing here = %s" + % sorted(set(mk) - set(lk))) + assert not set(lk) - set(mk), "the LISA portfolio branch sets keys main does not: %s" \ + % sorted(set(lk) - set(mk)) + for k in set(lk) & set(mk): + assert lk[k] == mk[k], "portfolio: %s differs (lisa=%r, main=%r)" % (k, lk[k], mk[k]) + + +def test_only_GMM_requests_return_lnI(lisa): + """return_lnI is what makes 'integrand' hold lnL, and it drives rvs_integrand_is_lnL. + + If another sampler gains it, the stored-convention derivation has to be revisited -- + ln_weights_from_rvs reads that convention to decide whether to log the integrand. + """ + with_lnI = {m for m, rec in lisa.items() if rec['keys'].get('return_lnI') is True} + assert with_lnI == {'GMM'}, "unexpected return_lnI set: %s" % sorted(with_lnI) + + +def test_adaptive_cartesian_has_no_use_lnL_branch(lisa): + """Plain adaptive_cartesian (RIFT.integrators.mcsampler) has no use_lnL handling at all. + + It always stores linear L. A branch here would make ln_weights_from_rvs read its + records as lnL, which is the failure the helper's docstring warns about. + """ + assert 'adaptive_cartesian' not in lisa or \ + 'use_lnL' not in lisa['adaptive_cartesian']['keys'] + + +def test_the_convention_is_still_derived_from_pinned_params(): + """Adding a branch must not tempt anyone back to the CLI option.""" + src = _src(_LISA) + assert 'rvs_integrand_is_lnL = bool(pinned_params.get("return_lnI", False))' in src + + +def test_the_convention_is_derived_after_every_branch_that_could_set_return_lnI(): + """Ordering: pinned_params must be final where the convention is read off it. + + The main driver derives it "where pinned_params is final". If a later update ever + carried return_lnI, deriving it early would silently pick the wrong convention. + """ + src = _src(_LISA) + tree = ast.parse(src) + derive_line = None + for node in ast.walk(tree): + if (isinstance(node, ast.Assign) and len(node.targets) == 1 + and getattr(node.targets[0], 'id', None) == 'rvs_integrand_is_lnL'): + derive_line = node.lineno + assert derive_line is not None, "rvs_integrand_is_lnL is never assigned" + # AST, not a text search: 'return_lnI' also appears in docstrings that DESCRIBE the + # convention, and an earlier version of this test matched those and failed on prose. + later = [c.lineno for c in ast.walk(tree) + if isinstance(c, ast.Call) and isinstance(c.func, ast.Attribute) + and c.func.attr == 'update' + and isinstance(c.func.value, ast.Name) and c.func.value.id == 'pinned_params' + and c.lineno > derive_line + and any(isinstance(k, ast.Constant) and k.value == 'return_lnI' + for a in c.args if isinstance(a, ast.Dict) for k in a.keys)] + assert not later, \ + "pinned_params gains return_lnI at line(s) %s, AFTER the stored convention is " \ + "derived from it at line %d" % (later, derive_line) diff --git a/MonteCarloMarginalizeCode/Code/test/test_rvs_record.py b/MonteCarloMarginalizeCode/Code/test/test_rvs_record.py new file mode 100644 index 000000000..81e8db880 --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/test/test_rvs_record.py @@ -0,0 +1,1301 @@ +#!/usr/bin/env python +""" +Contract for RvsRecord (see RIFT/integrators/DESIGN_rvs_naming.md). + +The point of this suite is not coverage for its own sake. Nine defects of one shape are on +record, and FOUR of them were found while reviewing the fix for the other five -- every one of +those four in the boolean bookkeeping that described `_rvs` from outside. So each section here +is one of those four failure shapes, written as a test that WOULD HAVE CAUGHT ITS ROUND had the +provenance lived with the rows from the start. + +If this design is adopted, these are the tests that justify it. If it is not, they are the +specification of what any replacement has to get right. +""" + +import numpy as np +import pytest + +from RIFT.integrators.rvs_record import RvsRecord, RvsProvenance, SamplerOutputMixin + + +def _cols(n, seed=0, spread=2.0): + rng = np.random.default_rng(seed) + lnL = rng.normal(0.0, spread, size=n) + return {"log_integrand": lnL, + "log_joint_prior": np.zeros(n), + "log_joint_s_prior": np.zeros(n), + "x": rng.normal(size=n)} + + +def _ln_w(columns): + """Stand-in for the ILE's ln_weights_from_rvs.""" + return (np.asarray(columns["log_integrand"], float) + + np.asarray(columns["log_joint_prior"], float) + - np.asarray(columns["log_joint_s_prior"], float)) + + +### +### Shape 2 (review round 2): ONE FLAG, TWO QUESTIONS +### +### A single boolean meant both "rows were drawn proportional to w" and "the record is +### globally equal-weight". A pooled record answers yes to the first and no to the second, so +### whichever way the flag was set, one consumer was wrong. +### + +def test_a_fair_draw_answers_yes_to_both_questions(): + rec = RvsRecord.fair_draw(_cols(50), n_retained=1000) + assert rec.rows_are_resampled() is True + assert rec.is_equal_weight() is True + + +def test_a_pooled_record_answers_yes_to_one_and_no_to_the_other(): + """The case a single boolean cannot represent.""" + rec = RvsRecord.pooled(_cols(80), resampled_blocks=[True, True], block_sizes=[40, 40]) + assert rec.rows_are_resampled() is True, \ + 'pooling concatenates blocks; it does not un-resample their rows' + assert rec.is_equal_weight() is False, \ + 'blocks differ by their replica evidences, so the record is not globally uniform' + + +def test_a_retained_record_answers_no_to_both(): + rec = RvsRecord.retained(_cols(500)) + assert rec.rows_are_resampled() is False + assert rec.is_equal_weight() is False + + +def test_the_flattening_question_is_a_third_thing_again(): + """`blocks_were_flattened` is a fact about the POOLING STEP, not about the record. + + Keying the pooled-n_eff branch on either of the other two put it below the line that + changed its own predicate, and it became dead code. + """ + plain = RvsRecord.fair_draw(_cols(30)) + assert plain.rows_are_resampled() and not plain.blocks_were_flattened(), \ + 'an unpooled fair draw was never flattened by pooling' + pooled_raw = RvsRecord.pooled(_cols(60), resampled_blocks=[False, False], block_sizes=[30, 30]) + assert not pooled_raw.blocks_were_flattened(), 'no block was resampled, so none was flattened' + pooled_mixed = RvsRecord.pooled(_cols(60), resampled_blocks=[False, True], block_sizes=[30, 30]) + assert pooled_mixed.blocks_were_flattened() + + +### +### Shape 3 (round 2): THE OPTION IS NOT THE EVENT +### +### `already_resampled=opts.fairdraw_extrinsic_output` is not "did the draw fire": it is +### skipped per pass when it would not shrink that pass's record, so a run can produce a +### MIXTURE of raw and resampled replicas. +### + +def test_provenance_is_per_block_not_a_single_boolean(): + rec = RvsRecord.pooled(_cols(90), resampled_blocks=[True, False, True], + block_sizes=[30, 30, 30]) + assert rec.provenance.resampled_blocks == [True, False, True] + assert rec.rows_are_resampled() is True, \ + 'a consumer that cannot weight rows differently by provenance must treat the whole ' \ + 'record as unsafe to reweight' + + +def test_a_mixture_is_representable_at_all(): + """The property a scalar cannot have. Pinned because the scalar version type-checks.""" + mixed = RvsProvenance(resampled_blocks=[True, False], block_sizes=[10, 10], pooled=True) + assert any(mixed.resampled_blocks) and not all(mixed.resampled_blocks) + + +### +### Shape 1 (round 1) and shape 4 (round 3): PROVENANCE MUST TRAVEL WITH THE ROWS +### +### Round 1: a rejected warm pass restored its rows but left the reserve and the marker +### describing the pass that had just been thrown away. +### Round 3: a marker cleared only on the normal return survived a raised event and was +### inherited by the next one. +### +### Both are impossible when the provenance is a field of the record being restored, rather +### than a separate attribute someone has to remember. +### + +def test_a_snapshot_restores_provenance_along_with_the_rows(): + cold = RvsRecord.fair_draw(_cols(40, seed=1), n_retained=5000) + saved = cold.snapshot() + + # the warm pass replaces the record in place, with different provenance + warm = RvsRecord.pooled(_cols(9, seed=2), resampled_blocks=[True, True], block_sizes=[4, 5]) + + assert warm.is_equal_weight() is False + assert saved.is_equal_weight() is True, \ + 'the snapshot must still describe the COLD pass, not the warm one that replaced it' + assert saved.provenance.n_retained == 5000 + + +def test_a_snapshot_cannot_be_mutated_by_the_pass_that_follows_it(): + """Round 1 in miniature: the restored provenance must not alias the live one.""" + rec = RvsRecord.fair_draw(_cols(20), n_retained=100) + saved = rec.snapshot() + rec.provenance.pooled = True + rec.provenance.resampled_blocks.append(True) + assert saved.provenance.pooled is False + assert saved.provenance.resampled_blocks == [True], 'the snapshot aliased live provenance' + + +def test_there_is_no_marker_left_to_leak_across_events(): + """Round 3 could not happen here: 'pooled' is a field of the record, so dropping the + record drops it. Nothing survives to be inherited by the next event.""" + rec = RvsRecord.pooled(_cols(20), resampled_blocks=[True], block_sizes=[20]) + assert rec.is_equal_weight() is False + rec = RvsRecord.fair_draw(_cols(20)) # the next event builds a NEW record + assert rec.is_equal_weight() is True, \ + 'a fresh fair draw inherited "pooled" from the record before it' + + +### +### The weights, which is what all of this is for +### + +def test_posterior_weights_are_uniform_only_for_a_globally_equal_weight_record(): + fair = RvsRecord.fair_draw(_cols(60, seed=3)) + assert np.allclose(fair.posterior_log_weights(_ln_w), 0.0) + + retained = RvsRecord.retained(_cols(60, seed=3)) + lw = retained.posterior_log_weights(_ln_w) + assert np.allclose(lw, _ln_w(retained.columns)) + assert np.std(lw) > 1.0, 'these weights are not degenerate; flattening them loses the shape' + + +def test_a_pooled_record_keeps_its_between_block_weights(): + """The round-1 defect: flattening a pooled record mixes replicas by row count.""" + cols = _cols(80, seed=4) + # blocks offset by 2 nats, as _pool_replica_rvs would leave them + cols["log_integrand"] = np.concatenate([np.zeros(40), np.full(40, 2.0)]) + rec = RvsRecord.pooled(cols, resampled_blocks=[True, True], block_sizes=[40, 40]) + lw = rec.posterior_log_weights(_ln_w) + assert not np.allclose(lw, 0.0), 'the replica evidences were flattened away' + assert lw[40] - lw[0] == pytest.approx(2.0, abs=1e-9) + + +def test_len_reports_rows_not_columns(): + assert len(RvsRecord.retained(_cols(37))) == 37 + assert len(RvsRecord.retained({})) == 0 + + +### +### A COMBINED PARAMETER IS (ndim, N), AND IT COMES FIRST +### +### Every sampler indexes a TUPLE-keyed column as `col[:, idx]` and a plain one as `col[idx]`, +### and `_rvs` is seeded parameters-first -- so counting rows by flattening whichever column +### came first reported ndim*N for an ordinary run with a combined parameter. +### + +def _cols_with_combined(n, ndim=3, seed=0): + """Columns in the order a sampler builds them: the combined parameter FIRST.""" + rng = np.random.default_rng(seed) + cols = {tuple("p{}".format(i) for i in range(ndim)): rng.normal(size=(ndim, n))} + cols.update(_cols(n, seed=seed)) + return cols + + +def test_a_combined_parameter_does_not_multiply_the_row_count(): + rec = RvsRecord.retained(_cols_with_combined(64, ndim=3)) + assert len(rec) == 64, 'the (ndim, N) column was flattened into ndim*N rows' + assert rec.provenance.block_sizes == [64] + assert rec.provenance.n_retained == 64 + + +def test_a_fair_draws_uniform_weights_are_one_per_row_not_one_per_entry(): + """The output-length failure: this vector is handed to consumers alongside the rows.""" + rec = RvsRecord.fair_draw(_cols_with_combined(50, ndim=4, seed=5)) + lw = rec.posterior_log_weights(_ln_w) + assert lw.shape == (50,) + assert np.allclose(lw, 0.0) + + +def test_the_row_axis_is_read_from_the_key_even_with_no_scalar_column(): + """A record of parameter columns alone still has to know where its rows are.""" + rng = np.random.default_rng(7) + assert len(RvsRecord.retained({("m1", "m2"): rng.normal(size=(2, 12))})) == 12 + assert len(RvsRecord.retained({"m1": rng.normal(size=12)})) == 12 + + +### +### The record is deliberately NOT a dict +### + +def test_the_record_is_not_a_dict_subclass(): + """A dict subclass would let every existing `sampler._rvs[...]` keep working against an + object whose meaning it does not check -- the original problem, restated with more steps. + Consumers must reach for `.columns`, which is visible in a diff and greppable.""" + rec = RvsRecord.retained(_cols(5)) + assert not isinstance(rec, dict) + with pytest.raises(TypeError): + rec["log_integrand"] + assert "log_integrand" in rec.columns + + +### +### MIGRATION SAFETY: while the record and the flags both exist, they must agree +### +### This is the one real cost of option A -- two sources of truth during the migration -- so it +### is asserted rather than left as a promise in a design doc. Four review rounds on #87 were +### all "two descriptions of one thing drifted apart"; this is the guard against doing it again +### at one level up. +### + +import os + +_ILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), + '..', 'bin', 'integrate_likelihood_extrinsic_batchmode') + + +def _ile_predicates(): + """Exec the ILE's two provenance predicates (it parses argv, so it is not importable).""" + src = open(_ILE).read() + # start at the shared LOOKUP, which is defined before the two predicates + start = src.index("def _rvs_record_for") + end = src.index("def _pool_replica_rvs") + ns = {"numpy": np, "np": np, "_rvs_lnL_convention": lambda x=None: bool(x)} + exec(compile(src[start:end], "ile_predicates", "exec"), ns) + return ns + + +class _Sampler(SamplerOutputMixin): + """A sampler carrying BOTH descriptions, as the tree does mid-migration. + + Inherits the real mixin rather than faking `samples()`, so a change to the public API + breaks this double instead of leaving it quietly testing something that no longer exists. + """ + def __init__(self, record, is_fairdraw, is_pooled): + self.set_samples(record) + self._rvs_is_fairdraw = is_fairdraw + self._rvs_is_pooled = is_pooled + + +@pytest.mark.skipif(not os.path.exists(_ILE), reason='ILE executable not in this tree') +@pytest.mark.parametrize('state,record,flag_fd,flag_pooled', [ + ('retained', RvsRecord.retained(_cols(20)), False, False), + ('fair draw', RvsRecord.fair_draw(_cols(20)), True, False), + ('pooled', RvsRecord.pooled(_cols(20), [True, True], [10, 10]), True, True), + ('pooled mixed', RvsRecord.pooled(_cols(20), [True, False], [10, 10]), True, True), + ('pooled raw', RvsRecord.pooled(_cols(20), [False, False], [10, 10]), False, True), +]) +def test_the_record_and_the_flags_agree_in_every_state(state, record, flag_fd, flag_pooled): + P = _ile_predicates() + s = _Sampler(record, flag_fd, flag_pooled) + assert record.rows_are_resampled() == P["_rvs_is_export_resample"](s), \ + '{}: rows-resampled disagrees between record and flag'.format(state) + assert record.is_equal_weight() == P["_rvs_is_equal_weight"](s), \ + '{}: equal-weight disagrees between record and flag'.format(state) + + +@pytest.mark.skipif(not os.path.exists(_ILE), reason='ILE executable not in this tree') +def test_the_migrated_consumer_only_trusts_a_record_describing_THESE_columns(): + """The record is a second reference to a mutable dict. If _rvs has been replaced since the + record was built, the record describes the wrong rows -- so the consumer checks identity + and falls back to the flags rather than trusting a stale description.""" + src = open(_ILE).read() + # the identity check lives in ONE lookup, not repeated per consumer (two copies drift) + i = src.index('def _rvs_record_for') + lookup = src[i:i + 1400] + assert "getattr(rec, 'columns', None) is not rvs" in lookup, \ + 'the shared lookup trusts a record without checking it describes these columns' + assert 'return None' in lookup, 'a stale record must be declined, not returned' + + # and EVERY consumer goes through it rather than reading the attribute directly + body = src[src.index('def ln_weights_for_posterior'):] + n_direct = body.count("sampler._rvs_record") + assert n_direct == 0, \ + '{} consumer(s) touch sampler._rvs_record directly instead of the public API'.format( + n_direct) + assert body.count('_rvs_record_for(sampler') >= 3, \ + 'expected the weight helper, the .dslice guard and the pooled n_eff to share the lookup' + + # the PRODUCER at the pooling site asks a different question and has its own name: it is + # about to replace sampler._rvs, so "does a record describe the rows I hold" is wrong there + assert '_sampler_keeps_records(sampler)' in body, \ + 'the pooling producer should ask whether the sampler keeps records at all' + assert 'sampler.set_samples(' in body, \ + 'the pooling producer assigns the private attribute instead of using the setter' + assert src.count('def _sampler_keeps_records') == 1 + + # the flags remain as the fallback until the last consumer is migrated + assert '_rvs_is_equal_weight(sampler)' in body, 'the flag fallback is gone too early' + + +### +### THE RESERVE IS REFERENCED, NOT COPIED (open question 2, answered by measurement) +### + +def test_the_record_points_at_the_reserve_rather_than_copying_it(): + reserve = dict(X=np.zeros((7, 6)), lnL=np.zeros(7), n_retained=99999, + n_finite=7, ln_sum_w_finite=1.5, params_ordered=list('abcdef')) + rec = RvsRecord.fair_draw(_cols(3), n_retained=99999, reserve=reserve) + assert rec.reserve is reserve, 'the reserve was copied; that is the cost this design avoids' + assert rec.has_retained() + assert rec.retained_points().shape == (7, 6) + assert rec.n_retained() == 99999, 'n_retained is the PRE-draw count, not len(record)' + assert len(rec) == 3 + + +def test_a_record_without_a_reserve_says_so_rather_than_guessing(): + rec = RvsRecord.fair_draw(_cols(3)) + assert rec.has_retained() is False + assert rec.retained_points() is None and rec.retained_lnL() is None + + +def test_a_snapshot_keeps_the_reserve_by_reference(): + reserve = dict(X=np.zeros((4, 2)), lnL=np.zeros(4)) + rec = RvsRecord.fair_draw(_cols(3), reserve=reserve) + assert rec.snapshot().reserve is reserve + + +def test_a_pooled_record_carries_no_reserve(): + """A pooled record is a mixture of several passes, so there is no single retained set for + it to point at. Saying None is correct; pointing at one arbitrary pass's would not be.""" + rec = RvsRecord.pooled(_cols(20), [True, True], [10, 10]) + assert rec.has_retained() is False + + +### +### END TO END on the sampler that was converted +### + +def _av_sampler(n_chunk=20000): + import RIFT.integrators.mcsamplerAdaptiveVolume as AV + s = AV.MCSampler(n_chunk=n_chunk) + s.xpy = AV.xpy_default + s.identity_convert = AV.identity_convert + for name in ['right_ascension', 'declination', 'phi_orb', 'inclination', 'psi', 'distance']: + s.add_parameter(name, pdf=None, left_limit=0.0, right_limit=1.0, + prior_pdf=lambda x: np.ones(np.shape(x)), adaptive_sampling=True) + return s + + +def _av_peaked(rho): + x0 = 0.5 * np.ones(6) + w = (0.5 / rho) * np.ones(6) + lnLmax = 0.5 * rho ** 2 + + def lnL(*args, **kwargs): + x = np.array([np.asarray(a, dtype=float).ravel() for a in args]).T + out = lnLmax - 0.5 * np.sum(((x - x0) / w) ** 2, axis=-1) + return np.where(out > lnLmax - 745.0, out, -np.inf) + return lnL + + +NAMES6 = ['right_ascension', 'declination', 'phi_orb', 'inclination', 'psi', 'distance'] + + +def test_a_real_collapsed_pass_records_the_draw_and_points_at_its_reserve(): + np.random.seed(20260813) + s = _av_sampler() + s.integrate_log(_av_peaked(100.0), *NAMES6, nmax=400000, neff=8, n=20000, + no_protect_names=True, verbose=False, + igrand_fairdraw_samples=True, igrand_fairdraw_samples_max=200) + rec = s.samples() + assert rec is not None and rec.rows_are_resampled() and rec.is_equal_weight() + assert rec.columns is s._rvs, 'the record must view the live columns' + assert rec.reserve is s._warm_seed_reserve, 'the reserve was copied rather than referenced' + assert rec.n_retained() > len(rec), \ + 'this pass did not collapse, so it does not exercise the case ({} vs {})'.format( + rec.n_retained(), len(rec)) + assert rec.retained_points().shape[0] >= len(rec) + + +def test_a_pass_with_no_fair_draw_still_gets_a_record(): + """"absent" and "not resampled" are different statements; a consumer that has to tell them + apart is back to combining conditions by hand.""" + np.random.seed(20260813) + s = _av_sampler() + s.integrate_log(_av_peaked(100.0), *NAMES6, nmax=400000, neff=8, n=20000, + no_protect_names=True, verbose=False) + rec = s.samples() + assert rec is not None, 'no record on the no-fair-draw path' + assert rec.rows_are_resampled() is False and rec.is_equal_weight() is False + assert rec.columns is s._rvs + + +@pytest.mark.skipif(not os.path.exists(_ILE), reason='ILE executable not in this tree') +def test_the_migration_changes_no_number(): + """The record path and the flag path must return the SAME weights on a real pass. + + This is what makes the migration safe to land incrementally: converting a consumer is a + refactor, not a behaviour change, and the two paths can be compared directly until the + flags are removed. + """ + src = open(_ILE).read() + start = src.index("def ln_weights_from_rvs") + end = src.index("def _pool_replica_rvs") + ns = {"numpy": np, "np": np, "_rvs_lnL_convention": lambda x=None: bool(x)} + exec(compile(src[start:end], "ile_w", "exec"), ns) + ln_w_post = ns["ln_weights_for_posterior"] + + np.random.seed(20260813) + s = _av_sampler() + s.integrate_log(_av_peaked(100.0), *NAMES6, nmax=400000, neff=8, n=20000, + no_protect_names=True, verbose=False, + igrand_fairdraw_samples=True, igrand_fairdraw_samples_max=200) + assert s.samples() is not None and s._rvs_is_fairdraw + + with_record = ln_w_post(s._rvs, s) + stashed = s.samples() + s.set_samples(None) # force the flag path + without_record = ln_w_post(s._rvs, s) + s.set_samples(stashed) + assert np.array_equal(with_record, without_record), \ + 'the record path and the flag path disagree; the migration is not a refactor' + + # ...and the same on a pass with no fair draw, where the answer is the other branch + np.random.seed(20260813) + s2 = _av_sampler() + s2.integrate_log(_av_peaked(100.0), *NAMES6, nmax=400000, neff=8, n=20000, + no_protect_names=True, verbose=False) + a = ln_w_post(s2._rvs, s2) + s2.set_samples(None) + b = ln_w_post(s2._rvs, s2) + assert np.array_equal(a, b) + assert np.std(a) > 0.0, 'a retained record must keep its varying importance weights' + + +### +### THE COUNT MUST BE EAGER, because the record references a dict the draw replaces in place +### + +def test_n_retained_is_captured_eagerly_not_read_back_from_the_columns(): + """Found while wiring the samplers, and it is this project's own bug class in miniature. + + `RvsRecord.retained(self._rvs)` stores a REFERENCE to the live column dict. The fair draw + then rebinds every key of that same dict. So `len(record)` -- which reads `.columns` -- + returns the POST-draw length, while `provenance.n_retained`, captured at construction, + still holds the pre-draw count. Reading the wrong one made a collapsed pass report + n_retained == rows, i.e. "nothing was discarded", which is the exact opposite of the truth. + """ + cols = _cols(500) + rec = RvsRecord.retained(cols) + assert rec.n_retained() == 500 and len(rec) == 500 + + # the draw replaces every column IN PLACE, as integrate_log does + keep = np.arange(3) + for k in list(cols): + cols[k] = np.asarray(cols[k])[keep] + + assert len(rec) == 3, 'len() reads the live columns, by design' + assert rec.n_retained() == 500, \ + 'n_retained was read back from the mutated columns instead of captured eagerly' + + +def test_a_real_collapsed_pass_reports_more_retained_than_exported(): + """The end-to-end version: on a pass that actually collapses, the record must show the + discard, not a no-op.""" + np.random.seed(20260813) + s = _av_sampler() + s.integrate_log(_av_peaked(100.0), *NAMES6, nmax=400000, neff=8, n=20000, + no_protect_names=True, verbose=False, + igrand_fairdraw_samples=True, igrand_fairdraw_samples_max=200) + rec = s.samples() + assert rec.rows_are_resampled() + assert rec.n_retained() > len(rec), \ + 'n_retained={} rows={} -- the record claims the draw discarded nothing'.format( + rec.n_retained(), len(rec)) + + +### +### EVERY SAMPLER, not just the one that was converted first +### + +def _six_samplers(): + """(label, factory, method, target, extra_kwargs) for each sampler with a rebind site. + + mcsampler and mcsamplerEnsemble take a LINEAR integrand; AV/portfolio take log. Getting + that wrong makes the fair draw produce negative weights and raise -- verified to fail + identically on the pristine file, i.e. it is a harness contract, not a defect. + """ + import RIFT.integrators.mcsampler as MC + import RIFT.integrators.mcsamplerAdaptiveVolume as AV + import RIFT.integrators.mcsamplerEnsemble as ENS + + def _log_tgt(rho=8.0): + x0 = 0.5 * np.ones(6); w = (0.5 / rho) * np.ones(6); m = 0.5 * rho ** 2 + + def f(*a, **k): + x = np.array([np.asarray(v, float).ravel() for v in a]).T + o = m - 0.5 * np.sum(((x - x0) / w) ** 2, axis=-1) + return np.where(o > m - 745.0, o, -np.inf) + return f + + def _lin_tgt(rho=4.0): + x0 = 0.5 * np.ones(6); w = (0.5 / rho) * np.ones(6) + + def f(*a, **k): + x = np.array([np.asarray(v, float).ravel() for v in a]).T + return np.exp(-0.5 * np.sum(((x - x0) / w) ** 2, axis=-1)) + return f + + def _av(): + s = AV.MCSampler(n_chunk=5000) + s.xpy = AV.xpy_default; s.identity_convert = AV.identity_convert + for n in NAMES6: + s.add_parameter(n, pdf=None, left_limit=0.0, right_limit=1.0, + prior_pdf=lambda x: np.ones(np.shape(x)), adaptive_sampling=True) + return s + + def _vec(mod): + def build(): + s = mod.MCSampler() + v = np.vectorize(lambda x: 1.0) + for n in NAMES6: + s.add_parameter(n, v, prior_pdf=v, left_limit=0.0, right_limit=1.0, + adaptive_sampling=True) + return s + return build + + return [ + ('AV', _av, 'integrate_log', _log_tgt()), + ('Ensemble', _vec(ENS), 'integrate', _lin_tgt()), + ('mcsampler', _vec(MC), 'integrate', _lin_tgt()), + ] + + +@pytest.mark.skipif(not os.path.exists(_ILE), reason='ILE executable not in this tree') +@pytest.mark.parametrize('fairdraw', [True, False]) +def test_every_wired_sampler_leaves_a_record_that_agrees_with_its_flags(fairdraw): + """The mechanical step, checked rather than assumed. + + All seven rebind sites were wired by one patcher against PR #87's own markers, so a single + mistake would be replicated everywhere -- which is exactly the case worth testing rather + than eyeballing the diff. + """ + P = _ile_predicates() + for label, build, meth, target in _six_samplers(): + np.random.seed(11) + s = build() + kw = dict(nmax=50000, neff=30, n=5000, no_protect_names=True, + verbose=False, save_intg=True) + if fairdraw: + kw.update(igrand_fairdraw_samples=True, igrand_fairdraw_samples_max=50) + getattr(s, meth)(target, *NAMES6, **kw) + + rec = P["_rvs_record_for"](s, s._rvs) + assert rec is not None, '{}: no record describing the live columns'.format(label) + assert rec.rows_are_resampled() == P["_rvs_is_export_resample"](s), \ + '{}: rows-resampled disagrees with the flag'.format(label) + assert rec.is_equal_weight() == P["_rvs_is_equal_weight"](s), \ + '{}: equal-weight disagrees with the flag'.format(label) + assert rec.rows_are_resampled() is bool(fairdraw), \ + '{}: record does not reflect whether the draw fired'.format(label) + if fairdraw: + assert rec.n_retained() >= len(rec), \ + '{}: n_retained {} < exported rows {}'.format(label, rec.n_retained(), len(rec)) + + +def test_all_seven_rebind_sites_are_wired_the_same_way(): + """One patcher wired all seven; pin that none was missed or hand-edited differently.""" + import glob + total_fd = total_ret = total_reset = 0 + for p in sorted(glob.glob(os.path.join(_INTEGRATORS_DIR, 'mcsampler*.py'))): + src = open(p).read() + if 'bFairdraw' not in src: + continue + n_sites = src.count('self._rvs_is_fairdraw = True') + assert src.count('RvsRecord.fair_draw(') == n_sites, \ + '{}: {} rebind sites but {} fair_draw records'.format( + os.path.basename(p), n_sites, src.count('RvsRecord.fair_draw(')) + assert src.count('RvsRecord.retained(') == n_sites, \ + '{}: a rebind site has no pre-draw retained record'.format(os.path.basename(p)) + assert src.count('self._rvs_record = None') == n_sites, \ + '{}: a rebind site does not reset the record'.format(os.path.basename(p)) + assert 'n_retained=self._rvs_record.n_retained()' in src, \ + '{}: n_retained read back from the mutated columns'.format(os.path.basename(p)) + total_fd += src.count('RvsRecord.fair_draw(') + total_ret += src.count('RvsRecord.retained(') + total_reset += n_sites + assert total_fd == total_ret == total_reset == 7, \ + 'expected 7 rebind sites wired, got {}/{}/{}'.format(total_fd, total_ret, total_reset) + + +_INTEGRATORS_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), + '..', 'RIFT', 'integrators') + + +### +### BACKEND CONTRACTS: the differences are real, so make them visible rather than implicit +### + +_AUDIT_BE = os.path.join(os.path.dirname(os.path.abspath(__file__)), + 'expensive_before_merging', 'integrators', + 'audit_backend_contracts.py') + + +def _backend_contracts(): + import importlib.util + spec = importlib.util.spec_from_file_location('audit_backend_contracts', _AUDIT_BE) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +@pytest.mark.skipif(not os.path.exists(_AUDIT_BE), reason='backend audit not in this tree') +def test_the_recorded_backend_contracts_match_the_code(): + """The CI gate, as a unit test too: the point is not that the backends agree -- they do + not, and that is allowed -- but that a change to one shows up as a diff.""" + mod = _backend_contracts() + import json + assert os.path.exists(mod.LEDGER), 'no recorded contracts; run --emit-ledger' + want = json.load(open(mod.LEDGER)) + for b in mod.BACKENDS: + got = mod.scan(b) + assert b in want, '{} is not in the recorded contracts'.format(b) + for k in sorted(set(got) | set(want[b])): + assert got.get(k) == want[b].get(k), \ + '{}.{}: recorded {!r}, now {!r}'.format(b, k, want[b].get(k), got.get(k)) + + +@pytest.mark.skipif(not os.path.exists(_AUDIT_BE), reason='backend audit not in this tree') +def test_the_integrand_column_really_does_mean_three_different_things(): + """Pinned because it is the specific trap that cost time twice in one afternoon, and + because a future 'tidy-up' that collapses the three cases would be a behaviour change.""" + mod = _backend_contracts() + holds = {b: mod.scan(b)['integrand_holds'] for b in mod.BACKENDS} + assert holds['mcsamplerAdaptiveVolume'] == 'log (aliased)' + assert holds['mcsamplerPortfolio'] == 'log (aliased)' + assert holds['mcsampler'] == 'linear' + assert holds['mcsamplerEnsemble'] == 'L or lnL (kwarg)', \ + 'the runtime-dependent case is the dangerous one; it must stay visible' + assert len(set(holds.values())) == 3, \ + 'expected exactly three distinct meanings, got {}'.format(sorted(set(holds.values()))) + + +@pytest.mark.skipif(not os.path.exists(_AUDIT_BE), reason='backend audit not in this tree') +def test_only_two_backends_keep_a_warm_seed_reserve(): + """So RvsRecord.retained_points() must answer None for the other four rather than pretend, + and the L0 rescue / sequential warm start must keep their fallbacks.""" + mod = _backend_contracts() + keeps = {b for b in mod.BACKENDS if mod.scan(b)['keeps_warm_seed_reserve']} + assert keeps == {'mcsamplerAdaptiveVolume', 'mcsamplerPortfolio'}, sorted(keeps) + + +### +### THE UNIVERSAL OUTPUT API +### +### `_rvs` is internal. These are what a consumer should call, and the point is that they mean +### the SAME thing on every backend -- so nobody has to know that `integrand` holds lnL on three +### samplers, linear L on two, and either on a sixth depending on a kwarg. +### + +@pytest.mark.parametrize('mod_name', ['mcsampler', 'mcsamplerAdaptiveVolume', + 'mcsamplerEnsemble', 'mcsamplerGPU', + 'mcsamplerNFlow', 'mcsamplerPortfolio']) +def test_every_backend_exposes_the_public_samples_api(mod_name): + # SOURCE first, so the wiring is checked even for a backend whose optional dependency is + # absent (mcsamplerNFlow needs `nflows`). A skip that checked nothing would quietly stop + # covering a backend the day its dependency dropped out of the environment. + src = open(os.path.join(_INTEGRATORS_DIR, '{}.py'.format(mod_name))).read() + assert 'class MCSampler(SamplerOutputMixin' in src, \ + '{}.MCSampler does not inherit the public output API'.format(mod_name) + + import importlib + try: + mod = importlib.import_module('RIFT.integrators.{}'.format(mod_name)) + except ImportError as e: + pytest.skip('{} needs an optional dependency ({}); source wiring checked above' + .format(mod_name, e)) + assert issubclass(mod.MCSampler, SamplerOutputMixin), \ + '{}.MCSampler does not expose samples(); consumers would reach into _rvs'.format(mod_name) + assert callable(getattr(mod.MCSampler, 'samples', None)) + + +def test_log_likelihood_is_lnL_whatever_the_backend_stored(): + """The whole point. A log backend and a linear backend, same call, same meaning.""" + n = 40 + lnL = np.linspace(-5.0, 5.0, n) + + log_rec = RvsRecord.retained({'log_integrand': lnL, + 'log_joint_prior': np.zeros(n), + 'log_joint_s_prior': np.zeros(n)}) + lin_rec = RvsRecord.retained({'integrand': np.exp(lnL), + 'joint_prior': np.ones(n), + 'joint_s_prior': np.ones(n)}, + integrand_is_log=False) + assert np.allclose(log_rec.log_likelihood(), lnL) + assert np.allclose(lin_rec.log_likelihood(), lnL) + assert np.allclose(log_rec.log_weights(), lin_rec.log_weights()) + + +def test_a_raw_integrand_column_of_unknown_meaning_raises_rather_than_guessing(): + """The loud failure this codebase prefers. Without a recorded convention the column's + meaning is genuinely unrecoverable, and returning a plausible number would be the exact + defect the backend audit documents.""" + rec = RvsRecord.retained({'integrand': np.array([1.0, 2.0, 3.0]), + 'joint_prior': np.ones(3), 'joint_s_prior': np.ones(3)}) + assert rec.integrand_is_log is None + with pytest.raises(ValueError) as e: + rec.log_likelihood() + assert 'integrand_is_log' in str(e.value) + + +def test_a_log_integrand_column_needs_no_convention_at_all(): + """Which is why only mcsampler and Ensemble-in-linear-mode had to be told.""" + n = 5 + rec = RvsRecord.retained({'log_integrand': np.zeros(n), + 'log_joint_prior': np.zeros(n), + 'log_joint_s_prior': np.zeros(n)}) + assert rec.integrand_is_log is None + assert np.allclose(rec.log_likelihood(), 0.0) + + +def test_non_positive_linear_values_become_minus_inf_not_nan(): + """A rejected or underflowed row is a real zero, not a NaN, and must not poison a sum.""" + rec = RvsRecord.retained({'integrand': np.array([1.0, 0.0, -1.0]), + 'joint_prior': np.ones(3), 'joint_s_prior': np.ones(3)}, + integrand_is_log=False) + lnL = rec.log_likelihood() + assert lnL[0] == pytest.approx(0.0) + assert np.isneginf(lnL[1]) and np.isneginf(lnL[2]) + assert not np.any(np.isnan(lnL)) + + +def test_log_weights_needs_no_use_lnL_argument(): + """ln_weights_from_rvs must be told the convention because a bare dict cannot say what its + own columns mean. A record can, so the parameter disappears -- and with it the class of + bug where a caller passes opts.internal_use_lnL instead of the stored convention.""" + import inspect + sig = inspect.signature(RvsRecord.log_weights) + # a host-transfer hook is fine; a CONVENTION argument is not -- the record already knows + assert set(sig.parameters) <= {'self', 'convert'}, \ + 'log_weights() grew a convention argument; the record is supposed to already know' + for banned in ('use_lnL', 'return_lnI', 'integrand_is_log'): + assert banned not in sig.parameters, \ + 'log_weights() takes {}; the whole point is that it does not need one'.format(banned) + + +def test_the_ensemble_return_lnI_convention_is_recorded_by_the_sampler(): + """The case that made this necessary: for mcsamplerEnsemble the meaning of `integrand` is a + RUNTIME property of how the pass was called, so only the sampler can record it. + + And the predicate has to be the RIGHT runtime property. `integrand` is `value_array`, + which is `cumulative_values` (lnL either way) under return_lnI and exp() of it otherwise; + use_lnL only decides whether the log columns are written BESIDE it. An earlier version + recorded bool(use_lnL) here, which is correct on three of the four combinations and + silently wrong on return_lnI=True, use_lnL=False -- the mislabelled linear reading sends + every negative-lnL row to zero weight and logs the positive ones twice. + """ + src = open(os.path.join(_INTEGRATORS_DIR, 'mcsamplerEnsemble.py')).read() + assert 'integrand_is_log=bool(return_lnI)' in src, \ + 'the Ensemble backend no longer records what its integrand column holds' + assert 'integrand_is_log=bool(use_lnL)' not in src, \ + 'use_lnL says whether log columns were written, NOT what `integrand` holds' + src_mc = open(os.path.join(_INTEGRATORS_DIR, 'mcsampler.py')).read() + assert 'integrand_is_log=False' in src_mc, \ + 'mcsampler writes only linear columns and must say so' + + +def test_the_gpu_linear_entry_point_records_that_it_is_linear(): + """mcsamplerGPU.integrate() hands a use_lnL=True call off to integrate_log, so anything + reaching its record stored a linear `integrand` and no log columns at all. Leaving the + convention unrecorded there makes samples().log_likelihood() raise for the DEFAULT mode of + that backend -- the one case where the record's refusal to guess is a false alarm rather + than a caught defect.""" + src = open(os.path.join(_INTEGRATORS_DIR, 'mcsamplerGPU.py')).read() + # both rebind sites of the linear path: the retained record and the fair-draw one + assert src.count('integrand_is_log=False') == 2, \ + 'the GPU linear path must state its convention on BOTH the retained and fairdraw records' + + +def test_a_mislabelled_log_column_is_not_a_harmless_annotation(): + """Why the two findings above are defects and not bookkeeping: the same rows read under the + wrong convention are not approximately wrong, they are a different posterior.""" + lnL = np.array([-3.0, -1.0, 2.0, 4.0]) + cols = {'integrand': lnL, 'joint_prior': np.ones(4), 'joint_s_prior': np.ones(4)} + right = RvsRecord.retained(dict(cols), integrand_is_log=True).log_weights() + wrong = RvsRecord.retained(dict(cols), integrand_is_log=False).log_weights() + assert np.allclose(right, lnL) + assert np.isneginf(wrong[0]) and np.isneginf(wrong[1]) # negative lnL -> zero weight + assert np.allclose(wrong[2:], np.log(lnL[2:])) # and log() of a log on the rest + + +### +### THE BOUNDARY: `_rvs_record` is private to the samplers; everyone else calls samples() +### + +_ILE_LISA = os.path.join(os.path.dirname(os.path.abspath(__file__)), + '..', 'bin', 'integrate_likelihood_extrinsic_batchmode_lisa') + + +def _attribute_reads(src, attr): + """How many times this source touches `.attr` -> int. + + AST, not text. Two earlier attempts got this wrong in ways worth recording: + + * a plain substring search counts the COMMENTS that explain the hazard, which in these + files is most of the occurrences (the same false alarm PR #87 hit); + * stripping comments and strings then counting tokens MISSES `getattr(sampler, + '_rvs_record')` entirely -- the attribute name lives in a string literal there, and + that is precisely the form a consumer reaching inside would use. That version passed + against a deliberately reintroduced violation, i.e. it was worse than no test. + + So: attribute access where the object is not `self`, PLUS getattr/setattr/hasattr with the + name as a string constant and a non-`self` target. + """ + import ast as _ast + try: + tree = _ast.parse(src) + except SyntaxError: + return -1 # never let a parse failure read as "clean" + + def _is_self(node): + return isinstance(node, _ast.Name) and node.id == 'self' + + n = 0 + for node in _ast.walk(tree): + if isinstance(node, _ast.Attribute) and node.attr == attr and not _is_self(node.value): + n += 1 + elif isinstance(node, _ast.Call) and isinstance(node.func, _ast.Name) \ + and node.func.id in ('getattr', 'setattr', 'hasattr') and len(node.args) >= 2: + a = node.args[1] + name = a.value if isinstance(a, _ast.Constant) else None + if name == attr and not _is_self(node.args[0]): + n += 1 + return n + + +@pytest.mark.skipif(not os.path.exists(_ILE), reason='ILE executable not in this tree') +def test_the_ile_never_touches_the_private_record_attribute(): + """Consumers call samples(); the producer at the pooling site calls set_samples(). + + This is the property the whole design is for -- `_rvs` and `_rvs_record` are internal, and + a consumer reaching inside is how a caller ends up depending on which backend it has. + """ + n = _attribute_reads(open(_ILE).read(), '_rvs_record') + assert n == 0, \ + 'the ILE touches sampler._rvs_record in {} place(s); use samples()/set_samples()'.format(n) + + +def test_the_record_tests_use_the_public_api_too(): + """A test that reaches inside is still a consumer written against an internal, and it is + the one place where doing so looks harmless.""" + n = _attribute_reads(open(os.path.abspath(__file__)).read(), '_rvs_record') + assert n == 0, \ + 'this suite touches ._rvs_record in {} place(s); use samples()/set_samples()'.format(n) + + +@pytest.mark.skipif(not os.path.exists(_ILE_LISA), reason='LISA driver not in this tree') +def test_the_lisa_driver_is_not_quietly_left_behind(): + """It is a deliberate fork, so it may legitimately have none of this -- but "none" and + "half" are different, and half is how a fork rots. See the driver-drift work.""" + src = open(_ILE_LISA).read() + has_api = 'samples()' in src + has_private = _attribute_reads(src, '_rvs_record') > 0 + assert not has_private or has_api, \ + 'the LISA driver reaches into _rvs_record without using the public API' + + +@pytest.mark.parametrize('mod_name', ['mcsampler', 'mcsamplerAdaptiveVolume', + 'mcsamplerEnsemble', 'mcsamplerGPU', + 'mcsamplerNFlow', 'mcsamplerPortfolio']) +def test_only_the_owning_sampler_touches_its_own_record(mod_name): + """Inside a sampler, `self._rvs_record` is the producer writing its own attribute, which is + fine. What must not appear is one sampler reaching into another's.""" + src = open(os.path.join(_INTEGRATORS_DIR, '{}.py'.format(mod_name))).read() + n = _attribute_reads(src, '_rvs_record') + assert n == 0, \ + '{} touches a _rvs_record that is not its own, in {} place(s)'.format(mod_name, n) + + +### +### TIER 1 (VALIDATION_rvs_weight_migration.md): the independent third implementation +### +### shape_recovery.py -- the merge gate -- carries its OWN log_weights_from_rvs(), written +### independently of both ln_weights_from_rvs and RvsRecord.log_weights(). Comparing against it +### is the check that can falsify the migration rather than testing it against itself. +### +### It is a HEURISTIC, deliberately: it guesses the convention with +### `L if np.nanmin(L) < 0 else np.log(L + 1e-300)` and floors instead of masking. So the +### criterion is agreement on the in-support rows, not bit-identity -- and the fact that the +### gate has to guess at all is the clearest statement of why the record records instead. +### + +def _shape_recovery_module(): + import importlib.util + path = os.path.join(os.path.dirname(os.path.abspath(__file__)), + 'expensive_before_merging', 'integrators', 'shape_recovery.py') + if not os.path.exists(path): + return None + spec = importlib.util.spec_from_file_location('shape_recovery_for_test', path) + mod = importlib.util.module_from_spec(spec) + try: + spec.loader.exec_module(mod) + except Exception: + return None + return mod + + +def _ile_weight_fn(): + src = open(_ILE).read() + ns = {"numpy": np, "np": np, "_rvs_lnL_convention": lambda x=None: bool(x)} + exec(compile(src[src.index("def ln_weights_from_rvs"):src.index("def _pool_replica_rvs")], + "w", "exec"), ns) + return ns["ln_weights_from_rvs"] + + +@pytest.mark.skipif(not os.path.exists(_ILE), reason='ILE executable not in this tree') +@pytest.mark.parametrize('backend', ['AV', 'Ensemble_log', 'Ensemble_linear', 'mcsampler']) +def test_three_independent_weight_implementations_agree(backend): + """rec.log_weights() vs ln_weights_from_rvs vs the shape gate's own derivation.""" + sr = _shape_recovery_module() + if sr is None: + pytest.skip('shape_recovery.py not importable here') + import RIFT.integrators.mcsamplerAdaptiveVolume as AV + import RIFT.integrators.mcsamplerEnsemble as ENS + import RIFT.integrators.mcsampler as MC + + def log_t(rho=8.0): + x0 = 0.5 * np.ones(6); w = (0.5 / rho) * np.ones(6); m = 0.5 * rho ** 2 + + def f(*a, **k): + x = np.array([np.asarray(v, float).ravel() for v in a]).T + o = m - 0.5 * np.sum(((x - x0) / w) ** 2, axis=-1) + return np.where(o > m - 745.0, o, -np.inf) + return f + + def lin_t(rho=4.0): + x0 = 0.5 * np.ones(6); w = (0.5 / rho) * np.ones(6) + + def f(*a, **k): + x = np.array([np.asarray(v, float).ravel() for v in a]).T + return np.exp(-0.5 * np.sum(((x - x0) / w) ** 2, axis=-1)) + return f + + np.random.seed(11) + v = np.vectorize(lambda x: 1.0) + kw = dict(nmax=50000, neff=30, n=5000, no_protect_names=True, verbose=False, save_intg=True) + if backend == 'AV': + s = AV.MCSampler(n_chunk=5000); s.xpy = AV.xpy_default + s.identity_convert = AV.identity_convert + for n in NAMES6: + s.add_parameter(n, pdf=None, left_limit=0.0, right_limit=1.0, + prior_pdf=lambda x: np.ones(np.shape(x)), adaptive_sampling=True) + s.integrate_log(log_t(), *NAMES6, **kw); use_lnL = True + else: + mod = MC if backend == 'mcsampler' else ENS + s = mod.MCSampler() + for n in NAMES6: + s.add_parameter(n, v, prior_pdf=v, left_limit=0.0, right_limit=1.0, + adaptive_sampling=True) + if backend == 'Ensemble_log': + s.integrate(log_t(), *NAMES6, use_lnL=True, return_lnI=True, **kw); use_lnL = True + else: + s.integrate(lin_t(), *NAMES6, **kw); use_lnL = False + + rec = s.samples() + assert rec is not None, '{}: no record'.format(backend) + a = np.asarray(rec.log_weights(), dtype=float) + b = np.asarray(_ile_weight_fn()(rec.columns, use_lnL=use_lnL), dtype=float) + c = np.asarray(sr.log_weights_from_rvs(rec.columns), dtype=float) + + # canonical pair: exact + assert np.array_equal(np.nan_to_num(a, nan=-9e99, neginf=-9e99), + np.nan_to_num(b, nan=-9e99, neginf=-9e99)), \ + '{}: rec.log_weights() disagrees with ln_weights_from_rvs'.format(backend) + + # independent heuristic: agree on the rows that carry weight. Compare SHAPE (differences + # from the max), since an additive offset would cancel in every downstream normalization. + good = np.isfinite(a) & np.isfinite(c) + assert good.sum() >= 5, '{}: too few comparable rows ({})'.format(backend, int(good.sum())) + da = a[good] - np.max(a[good]) + dc = c[good] - np.max(c[good]) + assert np.allclose(da, dc, atol=1e-8), \ + '{}: the gate\'s independent derivation disagrees (max |diff| {:.3e})'.format( + backend, float(np.max(np.abs(da - dc)))) + + +@pytest.mark.skipif(not os.path.exists(_ILE), reason='ILE executable not in this tree') +def test_log_weights_matches_the_canonical_form_including_out_of_support_rows(): + """Randomized equivalence with ln_weights_from_rvs, across all three column families. + + THIS is the test with teeth, and the one above is not. `log_weights()` was first written as + `log_likelihood() + log_prior() - log_sampling_prior()`, which is wrong on the linear family: + the canonical form applies a CONJUNCTIVE keep-mask (`ig>0 & jp>0 & js>0`, whole row -inf), + while evaluating the terms independently gives `-inf - (-inf) = NaN`. A NaN weight poisons + every downstream sum; -inf is a real zero. + + Real sampler records never expose it -- their priors are positive -- so + `test_three_independent_weight_implementations_agree` PASSES with the bug reintroduced. + Verified, not assumed: that is why this fuzz exists rather than resting on the end-to-end + comparison, and why the out-of-support rows are sprinkled in deliberately. + """ + lwf = _ile_weight_fn() + rng = np.random.default_rng(3) + bad = [] + for _ in range(300): + n = int(rng.integers(3, 40)) + for kind, use, is_log in (('log', None, None), + ('linear', False, False), + ('linear-as-lnL', True, True)): + if kind == 'log': + cols = {'log_integrand': rng.normal(0, 5, n), + 'log_joint_prior': rng.normal(0, 1, n), + 'log_joint_s_prior': rng.normal(0, 1, n)} + else: + cols = {'integrand': rng.normal(0, 3, n), + 'joint_prior': rng.normal(0, 2, n), # NEGATIVE priors on purpose + 'joint_s_prior': rng.normal(0, 2, n)} + for k in list(cols): # and the nasty values + v = cols[k].copy() + v[rng.integers(0, n)] = np.nan + v[rng.integers(0, n)] = -np.inf + v[rng.integers(0, n)] = 0.0 + cols[k] = v + with np.errstate(invalid='ignore', divide='ignore'): + a = np.asarray(lwf(cols, use_lnL=bool(use)), dtype=float) + b = np.asarray(RvsRecord.retained(cols, integrand_is_log=is_log).log_weights(), + dtype=float) + f = lambda x: np.nan_to_num(x, nan=-9e99, posinf=9e99, neginf=-9e99) + if not np.array_equal(f(a), f(b)): + bad.append(kind) + assert not bad, 'log_weights() diverges from the canonical form on {} record(s): {}'.format( + len(bad), sorted(set(bad))) + + +### +### ADVERSARIAL REVIEW FINDINGS (2026-08-14) -- regressions for each +### + +def test_a_pooled_record_from_a_linear_backend_can_still_produce_weights(): + """REVIEW FINDING 1, the one that would have dropped events. + + _pool_replica_rvs keeps only the INTERSECTION of the replica keys, so pooling + adaptive_cartesian (or Ensemble without use_lnL) replicas yields a bare `integrand` column. + Built without a convention, log_weights() correctly refuses to guess -- and that ValueError + escapes the UNWRAPPED .dgrid exporter, out of analyze_event, into the per-event handler, + which skips the event and writes an empty .dat. Replicas + a linear backend + .dgrid was a + dropped event. + """ + cols = {'integrand': np.array([1.0, 2.0, 3.0, 4.0]), + 'joint_prior': np.ones(4), 'joint_s_prior': np.ones(4)} + unconventioned = RvsRecord.pooled(cols, [True, True], [2, 2]) + with pytest.raises(ValueError): + unconventioned.log_weights() # the record is right to refuse... + + # ...so the ILE must supply the convention, which it takes from the pre-pool record. + fixed = RvsRecord.pooled(cols, [True, True], [2, 2], integrand_is_log=False) + lw = fixed.log_weights() + assert np.all(np.isfinite(lw)) and len(lw) == 4 + + +@pytest.mark.skipif(not os.path.exists(_ILE), reason='ILE executable not in this tree') +def test_the_ile_passes_a_convention_when_it_builds_the_pooled_record(): + src = open(_ILE).read() + i = src.index('_RvsRecord.pooled(') + block = src[max(0, i - 1600):i + 400] + assert 'integrand_is_log=' in block, \ + 'the pooled record is built with no convention; a linear backend will raise' + assert 'rvs_integrand_is_lnL' in block, 'no fallback when the pre-pool record is absent' + + +@pytest.mark.skipif(not os.path.exists(_ILE), reason='ILE executable not in this tree') +def test_the_pooled_record_provenance_is_filtered_in_lockstep(): + """REVIEW FINDING 3: _pool_replica_rvs drops empty replicas together with their lnZ and + their resampled flag; the record's block lists must be filtered the same way or they + describe blocks the record does not contain.""" + src = open(_ILE).read() + i = src.index('_RvsRecord.pooled(') + block = src[max(0, i - 1600):i + 500] + assert '_keep_rec' in block, 'the record\'s block provenance is built from unfiltered lists' + + +@pytest.mark.skipif(not os.path.exists(_ILE), reason='ILE executable not in this tree') +def test_participation_is_not_confused_with_currently_having_a_record(): + """REVIEW FINDING 4: a replica that raised leaves _rvs_record None while the sampler is + still a full participant; keying on presence silently skips the pooled record.""" + src = open(_ILE).read() + i = src.index('def _sampler_keeps_records') + body = src[i:i + 1400] + assert 'isinstance(sampler, SamplerOutputMixin)' in body, \ + 'participation is still inferred from whether a record happens to be present' + + +def test_a_snapshotted_record_describes_the_columns_that_get_restored(): + """REVIEW FINDING 5: the restore installs a COPY of the column dict, so a record still + pointing at the original fails every identity check and does nothing at all.""" + # ONE namespace as globals: the helpers call each other, and functions resolve names in + # globals, so exec(code, globals, locals) leaves them unable to see one another. + src = open(_ILE).read() + start = src.index("def _rebound_record") + end = src.index("def _warm_seed_geometry") + ns = {"numpy": np, "np": np} + exec(compile(src[start:end], "ile_state", "exec"), ns) + + class _S(SamplerOutputMixin): + pass + s = _S() + s._rvs = {'log_integrand': np.zeros(3), 'log_joint_prior': np.zeros(3), + 'log_joint_s_prior': np.zeros(3)} + s.set_samples(RvsRecord.retained(s._rvs)) + s._rvs_is_fairdraw = False; s._rvs_is_pooled = False + s._warm_seed_reserve = None; s.portfolio_realizations = [] + + cold = dict(s._rvs) + state = ns["_snapshot_pass_state"](s, 1, 2, 3, {}, rvs=cold) + s._rvs = {'log_integrand': np.ones(9)} # the warm pass replaces it + s.set_samples(RvsRecord.fair_draw(s._rvs)) + ns["_restore_pass_state"](s, state) + + assert s.samples() is not None, 'the record was dropped on restore' + assert s.samples().columns is s._rvs, \ + 'the restored record does not describe the restored columns, so it is inert' + # which is exactly what _rvs_record_for's identity check asks (it is defined earlier in + # the file than the slice exec'd above, so the condition is restated rather than imported) + assert s.samples().columns is s._rvs + + +### +### The lnZ / Kish estimators, now record-aware +### + +def _state_ns(): + src = open(_ILE).read() + ns = {"numpy": np, "np": np, "_rvs_lnL_convention": lambda x=None: bool(x)} + exec(compile(src[src.index("def ln_weights_from_rvs"):src.index("def _warm_seed_geometry")], + "ile_est", "exec"), ns) + return ns + + +@pytest.mark.skipif(not os.path.exists(_ILE), reason='ILE executable not in this tree') +def test_the_estimators_give_the_same_answer_from_a_record_or_from_the_columns(): + """A source choice, not a semantics choice -- so both routes must agree exactly.""" + ns = _state_ns() + n = 60 + rng = np.random.default_rng(17) + cols = {'log_integrand': rng.normal(0, 4, n), + 'log_joint_prior': rng.normal(0, 1, n), + 'log_joint_s_prior': rng.normal(0, 1, n)} + rec = RvsRecord.retained(cols) + for fn, kw in (('_lnZ_of_rvs', dict(already_pooled=False)), ('_kish_neff_of_rvs', {})): + a = ns[fn](cols, record=rec, **kw) + b = ns[fn](cols, **kw) + assert a == pytest.approx(b, rel=1e-12), '{}: record and column routes differ'.format(fn) + + +@pytest.mark.skipif(not os.path.exists(_ILE), reason='ILE executable not in this tree') +def test_a_record_describing_other_columns_is_not_believed_by_the_estimators(): + """The identity guard, at the estimators too. _rvs dicts are copied and replaced all over + the ILE; a record pointing at a different dict must be ignored, not trusted.""" + ns = _state_ns() + rng = np.random.default_rng(18) + mine = {'log_integrand': rng.normal(0, 4, 40), + 'log_joint_prior': np.zeros(40), 'log_joint_s_prior': np.zeros(40)} + other = {'log_integrand': np.full(40, 99.0), + 'log_joint_prior': np.zeros(40), 'log_joint_s_prior': np.zeros(40)} + stale = RvsRecord.retained(other) # describes SOMETHING ELSE + got = ns['_lnZ_of_rvs'](mine, already_pooled=False, record=stale) + want = ns['_lnZ_of_rvs'](mine, already_pooled=False) + assert got == pytest.approx(want, rel=1e-12), \ + 'a record describing other columns was believed; identity guard missing' + assert got < 90.0, 'the stale record leaked into the estimate' + + +@pytest.mark.skipif(not os.path.exists(_ILE), reason='ILE executable not in this tree') +def test_the_estimators_share_one_weight_resolver(): + """Two copies of "prefer the record, else derive" would drift, which is the failure this + whole branch is about.""" + src = open(_ILE).read() + assert src.count('def _lw_of(') == 1 + for fn in ('def _lnZ_of_rvs', 'def _kish_neff_of_rvs'): + i = src.index(fn) + body = src[i:i + 1500] + assert '_lw_of(rvs, record, use_lnL)' in body, \ + '{} does not go through the shared resolver'.format(fn) + + +### +### INTERNAL RECORDS: we had to hand the structure back; that is not the same as publishing it +### + +def test_an_internal_record_cannot_be_published_through_samples(): + """The boundary that makes 'internal' mean something rather than being a naming convention. + + Replica pooling has to thread each block's record into _pool_replica_rvs so the block's + weights are derived with ITS convention. Having had to pass the structure around is not a + reason for a consumer to reach for it, so set_samples() refuses an internal record and the + public accessor can therefore never yield one. + """ + class _S(SamplerOutputMixin): + pass + s = _S() + pub = RvsRecord.retained(_cols(10)) + s.set_samples(pub) + assert s.samples() is pub + + internal = pub.as_internal() + assert internal.internal is True + with pytest.raises(ValueError) as e: + s.set_samples(internal) + assert 'INTERNAL' in str(e.value) + assert s.samples() is pub, 'the refused call must leave the public record untouched' + + +def test_as_internal_shares_the_data_and_changes_only_the_marker(): + """It is a view for threading, not a copy -- copying every replica's columns would + reintroduce the memory cost the reserve-by-reference decision avoided.""" + pub = RvsRecord.fair_draw(_cols(12), n_retained=999, reserve={'X': np.zeros((2, 2))}) + it = pub.as_internal() + assert it.columns is pub.columns + assert it.provenance is pub.provenance + assert it.reserve is pub.reserve + assert it.integrand_is_log == pub.integrand_is_log + assert pub.internal is False and it.internal is True + assert it.rows_are_resampled() == pub.rows_are_resampled() + assert it.n_retained() == 999 + + +def test_the_internal_marker_survives_a_snapshot(): + """Otherwise snapshot/restore would launder an internal record into a publishable one.""" + it = RvsRecord.retained(_cols(6)).as_internal() + assert it.snapshot().internal is True + + +@pytest.mark.skipif(not os.path.exists(_ILE), reason='ILE executable not in this tree') +def test_the_ile_threads_per_replica_records_and_marks_them_internal(): + src = open(_ILE).read() + assert 'def _internal_record_of' in src + i = src.index('_rep_records = [') + assert '_internal_record_of(sampler)' in src[i:i + 200], \ + 'per-replica records are captured without being marked internal' + j = src.index('_pool_replica_rvs(_rep_rvs') + assert 'records=_rep_records' in src[j:j + 500], \ + 'the per-replica records are not threaded into pooling' + # and pooling filters them in lockstep with the other per-replica lists + k = src.index('def _pool_replica_rvs') + body = src[k:k + 4000] + assert '_rec_list = [_rec_list[i] for i in _keep' in body, \ + 'the records are not filtered in lockstep with rep_rvs/rep_lnZ' + assert 'def _block_record' in body, 'no per-block identity guard on the threaded records' + + +@pytest.mark.skipif(not os.path.exists(_ILE), reason='ILE executable not in this tree') +def test_pooling_uses_a_block_record_only_when_it_describes_that_block(): + """The identity guard again, one level down: a record for replica 2 must not be used to + derive replica 1's lnZ just because the lists line up.""" + ns = {"numpy": np, "np": np, "_rvs_lnL_convention": lambda x=None: bool(x)} + src = open(_ILE).read() + exec(compile(src[src.index("def ln_weights_from_rvs"):src.index("def _warm_seed_geometry")], + "ile_pool", "exec"), ns) + + class _Conv(object): + @staticmethod + def identity_convert(x): + return x + rng = np.random.default_rng(21) + blocks = [] + for sd in (1, 2): + n = 30 + blocks.append({'log_integrand': rng.normal(0, 2, n), + 'log_joint_prior': np.zeros(n), 'log_joint_s_prior': np.zeros(n)}) + good = [RvsRecord.retained(b).as_internal() for b in blocks] + mismatched = [RvsRecord.retained(blocks[1]).as_internal(), + RvsRecord.retained(blocks[0]).as_internal()] # swapped on purpose + + kw = dict(rep_lnZ=[7.0, 9.0], already_resampled=[False, False], use_lnL=False) + a = ns['_pool_replica_rvs'](list(blocks), _Conv(), records=good, **kw) + b = ns['_pool_replica_rvs'](list(blocks), _Conv(), records=mismatched, **kw) + c = ns['_pool_replica_rvs'](list(blocks), _Conv(), records=None, **kw) + lw = lambda o: ns['ln_weights_from_rvs'](o, use_lnL=False) + assert np.allclose(lw(a), lw(c)), 'the record route changed the pooled weights' + assert np.allclose(lw(b), lw(c)), \ + 'a record describing ANOTHER block was used; the identity guard is missing' diff --git a/MonteCarloMarginalizeCode/Code/test/test_tracer_placement_gp.py b/MonteCarloMarginalizeCode/Code/test/test_tracer_placement_gp.py new file mode 100644 index 000000000..2b9effad2 --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/test/test_tracer_placement_gp.py @@ -0,0 +1,589 @@ +""" +Tests for the linear-mean GP fit and the optional lnL floor in +RIFT/misc/tracer_placement/fits/. + +Headline test: `test_gp_extrapolates_where_rf_goes_flat` builds a synthetic lnL +surface whose peak lies OUTSIDE the training hull -- the clipped-peak failure +that motivated the port -- and checks that gp_linmean keeps rising toward the +peak where the random forest is exactly flat. + +These intentionally avoid importing the RIFT package proper (RIFT/__init__.py +pulls in lalsimutils + lalsuite, which the placement engine does not need), by +putting RIFT/misc on sys.path and importing `tracer_placement` directly. That +is the same fallback import path the two tracer CLI tools use for local dev:: + + python test/test_tracer_placement_gp.py + pytest test/test_tracer_placement_gp.py + +or, with a self-contained environment that needs no lalsuite:: + + cd test/tracer_placement && pixi run test + +sklearn is needed only for the `rf` half of the comparison and scipy only for +`rbf`; those checks skip cleanly without them. Everything about the GP itself is +numpy-only. +""" + +import ast +import os +import shutil +import sys +import tempfile + +import numpy as np + +HERE = os.path.dirname(os.path.abspath(__file__)) +_MISC = os.path.normpath(os.path.join(HERE, "..", "RIFT", "misc")) +_BIN = os.path.normpath(os.path.join(HERE, "..", "bin")) +if _MISC not in sys.path: + sys.path.insert(0, _MISC) + +from tracer_placement import fits, samplers # noqa: E402 +from tracer_placement.fits._gp_linmean import LinearMeanGPFit # noqa: E402 + +try: + import sklearn # noqa: F401 + _HAVE_SKLEARN = True +except ImportError: + _HAVE_SKLEARN = False + +try: + import scipy # noqa: F401 + _HAVE_SCIPY = True +except ImportError: + _HAVE_SCIPY = False + +try: + import pytest + _skip_no_sklearn = pytest.mark.skipif( + not _HAVE_SKLEARN, reason="sklearn not installed; rf fit unavailable") +except ImportError: # pytest-free execution + pytest = None + + def _skip_no_sklearn(fn): + return fn + + +# --------------------------------------------------------------------------- # +# Synthetic surfaces +# --------------------------------------------------------------------------- # + +# The clipped-peak geometry: a Gaussian lnL ridge peaked at x = X_PEAK, but the +# grid we are allowed to train on only reaches x = X_EDGE. Inside the training +# box lnL rises monotonically with x and simply runs off the edge -- exactly +# R3's batch-0 situation at the v_outer wall. +X_PEAK, Y_PEAK = 3.0, 0.5 +X_EDGE = 1.0 + + +def _true_lnL(Z): + Z = np.atleast_2d(Z) + return -0.5 * ((Z[:, 0] - X_PEAK) ** 2 / 0.8 ** 2 + + (Z[:, 1] - Y_PEAK) ** 2 / 0.5 ** 2) + + +def _clipped_training_set(n=200, seed=0, noise=0.0): + """Draw a training grid confined to x in [0, X_EDGE] (peak is outside).""" + rng = np.random.default_rng(seed) + X = np.column_stack([rng.uniform(0.0, X_EDGE, n), + rng.uniform(0.0, 1.0, n)]) + Y = _true_lnL(X) + if noise: + Y = Y + noise * rng.normal(size=n) + sigma = np.full(n, max(noise, 1e-2)) + return X, Y, sigma + + +def _ray_toward_peak(x_values): + """Points marching from inside the hull out toward the true peak.""" + return np.column_stack([np.asarray(x_values, dtype=float), + np.full(len(x_values), Y_PEAK)]) + + +# --------------------------------------------------------------------------- # +# The headline argument: extrapolation past the training hull +# --------------------------------------------------------------------------- # + +@_skip_no_sklearn +def test_gp_extrapolates_where_rf_goes_flat(): + """gp_linmean chases a peak outside the training hull; rf cannot. + + This is the whole argument for adding the fit. The random forest is + piecewise-constant, so every point beyond the training hull falls in the + same boundary leaf and gets the same prediction -- placement sees zero + gradient and no reason to leave the box. The linear-mean GP carries the + fitted trend outward and keeps rising toward the true peak. + """ + X, Y, sigma = _clipped_training_set() + gp = fits.build("gp_linmean", X, Y, sigma=sigma) + rf = fits.build("rf", X, Y, sigma=sigma) + + inside = _ray_toward_peak([0.9]) + outside = _ray_toward_peak([1.5, 2.0, 2.5, 3.0]) + + rf_in = rf.predict(inside)[0] + rf_out = rf.predict(outside) + gp_in = gp.predict(inside)[0] + gp_out = gp.predict(outside) + + lnL_scale = float(np.ptp(Y)) + + # 1. rf is flat outside the hull: identical predictions and, what actually + # matters for placement, exactly zero gradient to climb. + assert np.ptp(rf_out) < 1e-9 * max(lnL_scale, 1.0), ( + "rf should be piecewise-constant outside the training hull, " + f"got spread {np.ptp(rf_out)}") + assert np.allclose(rf.grad(outside), 0.0), ( + "rf should offer no gradient outside the hull, got " + f"{rf.grad(outside)}") + assert np.all(np.abs(gp.grad(outside)[:, 0]) > 1e-3), ( + "gp_linmean should still have a gradient to climb outside the hull") + + # 2. gp_linmean keeps rising toward the peak, monotonically. + assert np.all(np.diff(gp_out) > 0), ( + f"gp_linmean should rise toward the peak outside the hull, got {gp_out}") + assert gp_out[-1] - gp_in > 0.5 * lnL_scale, ( + "gp_linmean extrapolation should gain a substantial fraction of the " + f"in-hull lnL range; got {gp_out[-1] - gp_in:g} vs range {lnL_scale:g}") + + # 3. Stated as placement sees it: maximizing the surrogate over a box that + # extends past the old edge moves the GP's argmax outside, while rf's + # surface is flat there so it offers no improvement at all. + grid = np.column_stack([np.linspace(0.0, 3.5, 141), + np.full(141, Y_PEAK)]) + outside_mask = grid[:, 0] > X_EDGE + gp_grid = gp.predict(grid) + rf_grid = rf.predict(grid) + assert grid[np.argmax(gp_grid), 0] > X_EDGE, ( + "gp_linmean's best point should lie outside the sampled region") + rf_gain = rf_grid[outside_mask].max() - rf_grid[~outside_mask].max() + assert rf_gain <= 1e-9, ( + f"rf should see no improvement outside the hull, got gain {rf_gain}") + + +def test_linear_mean_extrapolates_where_const_mean_reverts(): + """The mean function, not the kernel, is what buys extrapolation. + + Same GP, same kernel, same data: with mean="const" the surrogate relaxes + back toward a flat prior away from the data (the zero-mean-GP failure CIP's + --lnL-shift-prevent-overflow help text warns about); with mean="linear" it + follows the trend. Runs without sklearn. + """ + X, Y, sigma = _clipped_training_set() + gp_lin = LinearMeanGPFit(X, Y, sigma=sigma, mean="linear") + gp_const = LinearMeanGPFit(X, Y, sigma=sigma, mean="const") + + ray = _ray_toward_peak([0.9, 1.5, 2.0, 2.5, 3.0]) + lin = gp_lin.predict(ray) + const = gp_const.predict(ray) + + assert np.all(np.diff(lin) > 0), f"linear mean should keep rising: {lin}" + # The constant-mean fit decays back to the training mean, i.e. it gives up + # the gain it had at the hull edge. + assert const[-1] < const[0], f"const mean should revert away from data: {const}" + assert lin[-1] > const[-1] + 0.5 * float(np.ptp(Y)) + + +def test_uncertainty_grows_outside_the_hull(): + """predict_with_std is the calibrated sigma samplers.ucb asks for.""" + X, Y, sigma = _clipped_training_set(noise=0.05, seed=3) + gp = fits.build("gp_linmean", X, Y, sigma=sigma) + assert gp.has_uncertainty is True + assert gp.smooth_gradient is True + + _, s_train = gp.predict_with_std(X) + _, s_far = gp.predict_with_std(_ray_toward_peak([2.5, 3.0])) + assert np.median(s_train) < np.min(s_far), ( + "GP sigma must be smaller on training points than in the unsampled " + f"frontier; got median {np.median(s_train):g} vs far {s_far}") + # Far from any data the posterior std saturates at the signal amplitude. + assert np.all(s_far <= np.sqrt(gp.sf2) * (1 + 1e-8)) + + +# --------------------------------------------------------------------------- # +# GP mechanics +# --------------------------------------------------------------------------- # + +def test_gp_interpolates_training_data(): + """With small observation noise the fit reproduces its training values.""" + X, Y, _ = _clipped_training_set(n=60, seed=1) + gp = LinearMeanGPFit(X, Y, sigma=np.full(len(Y), 1e-3), sigma_floor=1e-3) + assert gp.train_rms < 0.02 * float(np.ptp(Y)), gp.train_rms + assert np.allclose(gp.predict(X), Y, atol=0.05 * float(np.ptp(Y))) + + +def test_analytic_grad_matches_finite_difference(): + X, Y, sigma = _clipped_training_set(n=80, seed=2) + gp = LinearMeanGPFit(X, Y, sigma=sigma) + Z = np.array([[0.4, 0.6], [0.9, 0.2], [2.0, 0.5]]) + g = gp.grad(Z) + eps = 1e-5 + fd = np.zeros_like(Z) + for k in range(Z.shape[1]): + zp = Z.copy(); zp[:, k] += eps + zm = Z.copy(); zm[:, k] -= eps + fd[:, k] = (gp.predict(zp) - gp.predict(zm)) / (2 * eps) + assert np.allclose(g, fd, rtol=1e-4, atol=1e-5), (g, fd) + + +def test_shapes_and_one_dimensional_input(): + X, Y, sigma = _clipped_training_set(n=40, seed=4) + gp = LinearMeanGPFit(X, Y, sigma=sigma) + for Z, n_expected in ((np.array([0.5, 0.5]), 1), (X[:7], 7)): + mu = gp.predict(Z) + m2, s2 = gp.predict_with_std(Z) + assert mu.shape == (n_expected,) + assert m2.shape == (n_expected,) and s2.shape == (n_expected,) + assert np.allclose(mu, m2) + assert np.all(np.isfinite(s2)) and np.all(s2 >= 0) + assert gp.grad(Z).shape == (n_expected, 2) + + # A genuinely 1-D parameter space must work too (RIFT runs those). + X1 = np.linspace(0, 1, 30)[:, None] + gp1 = LinearMeanGPFit(X1, np.sin(3 * X1[:, 0])) + assert gp1.predict(np.array([[0.5]])).shape == (1,) + + +def test_prediction_chunking_is_seamless(): + """predict_with_std chunks internally; results must not depend on that.""" + X, Y, sigma = _clipped_training_set(n=50, seed=5) + gp = LinearMeanGPFit(X, Y, sigma=sigma) + rng = np.random.default_rng(0) + Z = rng.uniform(-1, 4, size=(5000, 2)) # > the internal 2048 chunk + mu, sd = gp.predict_with_std(Z) + mu_ref = gp.predict(Z) + assert np.allclose(mu, mu_ref) + assert np.all(np.isfinite(sd)) + + +def test_bad_inputs_are_rejected_loudly(): + X, Y, _ = _clipped_training_set(n=20, seed=6) + try: + LinearMeanGPFit(X, Y, mean="cubic") + raise AssertionError("expected ValueError for unknown mean") + except ValueError: + pass + try: + LinearMeanGPFit(X, Y[:-1]) + raise AssertionError("expected ValueError for mismatched lengths") + except ValueError: + pass + Y_bad = Y.copy(); Y_bad[3] = -np.inf + try: + LinearMeanGPFit(X, Y_bad) + raise AssertionError("expected ValueError for non-finite lnL") + except ValueError as e: + assert "lnl_floor_delta" in str(e) # points at the supported remedy + + +def test_nonpositive_length_scale_is_refused(): + """ls=0 silently produced all-NaN predictions and ls<0 silently gave a + DIFFERENT fit than asked for (the sign is squared away). Both are + silent-wrong, so the constructor must refuse them.""" + X, Y, _ = _clipped_training_set(n=30, seed=14) + for bad in (0.0, -1.0, np.nan, np.inf): + try: + LinearMeanGPFit(X, Y, length_scale=bad) + raise AssertionError(f"expected ValueError for length_scale={bad}") + except ValueError as e: + assert "length_scale" in str(e) + gp = LinearMeanGPFit(X, Y, length_scale=0.5) + assert np.all(np.isfinite(gp.predict(X))) + + +def test_large_candidate_pools_stay_chunked(): + """Every public evaluator must chunk. An unchunked (m, n) kernel block at + UCB's pool size is hundreds of MB on its own -- enough to blow a modest + Condor memory request.""" + import tracemalloc + rng = np.random.default_rng(0) + X = rng.uniform(0, 1, (1500, 3)) + gp = LinearMeanGPFit(X, X.sum(axis=1)) + Z = rng.uniform(0, 1, (20000, 3)) + n_block = 1500 * 20000 * 8 # what one unchunked block would cost + for name in ("predict", "predict_with_std", "grad"): + tracemalloc.start() + getattr(gp, name)(Z) + peak = tracemalloc.get_traced_memory()[1] + tracemalloc.stop() + assert peak < 0.5 * n_block, ( + f"{name} peaked at {peak/1e6:.0f} MB; an unchunked block would be " + f"{n_block/1e6:.0f} MB, so this is not chunking") + + +def test_warns_when_the_linear_mean_is_underdetermined(): + """With fewer points than mean coefficients, lstsq returns the min-norm + hyperplane -- an arbitrary pick among infinitely many. This fit exists to + extrapolate along that hyperplane, so it must not do so quietly.""" + import io + import contextlib + rng = np.random.default_rng(1) + d = 5 + for n, expect_warning in ((3, True), (d + 1, True), (40, False)): + X = rng.uniform(0, 1, (n, d)) + Y = X[:, 0] * 3.0 + err = io.StringIO() + with contextlib.redirect_stderr(err): + LinearMeanGPFit(X, Y) + got = "mean function is" in err.getvalue() + assert got is expect_warning, (n, err.getvalue()) + if expect_warning: + assert "extrapolation" in err.getvalue().lower() + # mean="const" has one coefficient, so it is not subject to this at all. + err = io.StringIO() + with contextlib.redirect_stderr(err): + LinearMeanGPFit(rng.uniform(0, 1, (3, d)), rng.normal(size=3), mean="const") + assert "mean function is" not in err.getvalue() + + +def test_duplicate_points_do_not_break_the_cholesky(): + """Repeated grid rows are common in RIFT unions; jitter must absorb them.""" + X, Y, sigma = _clipped_training_set(n=30, seed=7) + X = np.vstack([X, X[:5]]) + Y = np.concatenate([Y, Y[:5]]) + sigma = np.concatenate([sigma, sigma[:5]]) + gp = LinearMeanGPFit(X, Y, sigma=sigma) + assert np.all(np.isfinite(gp.predict(X))) + + +# --------------------------------------------------------------------------- # +# Dispatch registration +# --------------------------------------------------------------------------- # + +def test_dispatch_registers_gp_linmean(): + X, Y, sigma = _clipped_training_set(n=30, seed=8) + for name in ("gp_linmean", "GP_LINMEAN", "gp-linmean"): + assert isinstance(fits.build(name, X, Y, sigma=sigma), LinearMeanGPFit) + try: + fits.build("no_such_fit", X, Y) + raise AssertionError("expected ValueError for unknown method") + except ValueError: + pass + + +def test_gp_kwargs_pass_through_dispatch(): + X, Y, sigma = _clipped_training_set(n=30, seed=9) + gp = fits.build("gp_linmean", X, Y, sigma=sigma, + mean="const", length_scale=0.7) + assert gp.mean_kind == "const" + assert gp.length_scale == 0.7 + + +# --------------------------------------------------------------------------- # +# Task 2: the optional lnL floor +# --------------------------------------------------------------------------- # + +def test_lnl_floor_off_by_default_is_a_pass_through(): + """Legacy behaviour must be bit-for-bit unchanged: same object, untouched.""" + Y = np.array([1.0, -1e9, 3.0]) + assert fits.apply_lnl_floor(Y, None) is Y + + +def test_lnl_floor_clamps_without_dropping_points(): + Y = np.array([10.0, 9.0, -1e9, 8.0, -np.inf]) + out = fits.apply_lnl_floor(Y, 100.0) + assert len(out) == len(Y), "the floor clamps, it does not cut" + assert out.min() == -90.0 # max(Y)=10 -> floor 10-100 + assert np.array_equal(out[:2], Y[:2]) # good points untouched + assert np.all(np.isfinite(out)) + + for bad in (0.0, -5.0, np.inf): + try: + fits.apply_lnl_floor(Y, bad) + raise AssertionError(f"expected ValueError for delta={bad}") + except ValueError: + pass + + # NaN is a failed evaluation: same kind of anchor as a catastrophic one. + assert fits.apply_lnl_floor(np.array([1.0, np.nan, 3.0]), 10.0)[1] == -7.0 + + # +inf is not something a floor can rescue. Letting it through used to + # fail downstream with a message telling the user to apply the floor they + # had just applied. + try: + fits.apply_lnl_floor(np.array([1.0, np.inf, 3.0]), 10.0) + raise AssertionError("expected ValueError for +inf lnL") + except ValueError as e: + assert "+inf" in str(e) + + +def test_lnl_floor_rescues_a_gp_fit_wrecked_by_an_outlier(): + """The reason to floor rather than cut: a single -1e9 point otherwise + inflates the residual scatter so much that the kernel term is numerically + irrelevant and the surrogate degenerates to its mean function.""" + X, Y, sigma = _clipped_training_set(n=60, seed=10) + Y_bad = Y.copy() + Y_bad[0] = -1e9 # catastrophic model failure + + gp_raw = fits.build("gp_linmean", X, Y_bad, sigma=sigma) + gp_floored = fits.build("gp_linmean", X, Y_bad, sigma=sigma, + lnl_floor_delta=50.0) + + good = np.ones(len(Y), dtype=bool); good[0] = False + err_raw = np.sqrt(np.mean((gp_raw.predict(X[good]) - Y[good]) ** 2)) + err_floored = np.sqrt(np.mean((gp_floored.predict(X[good]) - Y[good]) ** 2)) + assert err_floored < 0.05 * err_raw, (err_floored, err_raw) + + # The floored point is still in the fit as an anchor: the surrogate knows + # that corner of the space is bad rather than never having heard of it. + assert gp_floored.predict(X[:1])[0] < Y[good].min() + + +def test_lnl_floor_applies_to_every_fit_method(): + X, Y, sigma = _clipped_training_set(n=40, seed=11) + Y_bad = Y.copy(); Y_bad[0] = -1e9 + methods = ["quadratic", "polynomial", "gp_linmean"] + if _HAVE_SKLEARN: + methods.append("rf") + if _HAVE_SCIPY: + methods.append("rbf") + for m in methods: + f = fits.build(m, X, Y_bad, sigma=sigma, lnl_floor_delta=50.0) + assert np.all(np.isfinite(f.predict(X))), m + + +# --------------------------------------------------------------------------- # +# Integration: UCB placement, and the two CLI wrappers +# --------------------------------------------------------------------------- # + +def test_ucb_placement_with_gp_surrogate_leaves_the_sampled_region(): + """End-to-end through samplers.ucb: with a GP surrogate whose trend points + out of the box, UCB should place points beyond the old edge.""" + X, Y, sigma = _clipped_training_set(n=120, seed=12) + gp = fits.build("gp_linmean", X, Y, sigma=sigma) + prior_box = np.array([[0.0, 3.5], [0.0, 1.0]]) # extended in x + X_out, info = samplers.ucb_place( + X[:40], surrogate=gp, prior_box=prior_box, + rng=np.random.default_rng(0), kappa=2.0, + n_candidates=4000, polish_steps=5) + assert X_out.shape == (40, 2) + assert np.all(np.isfinite(X_out)) + assert np.all(X_out[:, 0] >= prior_box[0, 0] - 1e-9) + assert np.all(X_out[:, 0] <= prior_box[0, 1] + 1e-9) + assert info["polish_strategy"] == "gradient" + assert np.mean(X_out[:, 0] > X_EDGE) > 0.5, ( + "UCB on a linear-mean GP should mostly place outside the old hull") + + +def _parser_choices_via_ast(path, flag): + """Read an argparse `choices=` tuple out of a source file without importing + it. util_ParameterTracerUpdate.py imports lalsimutils/lalsuite at module + scope, which this test deliberately does not require.""" + with open(path) as f: + tree = ast.parse(f.read()) + for node in ast.walk(tree): + if not (isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and node.func.attr == "add_argument"): + continue + if not (node.args and isinstance(node.args[0], ast.Constant) + and node.args[0].value == flag): + continue + for kw in node.keywords: + if kw.arg == "choices": + return [ast.literal_eval(e) for e in kw.value.elts] + return [] + return None + + +def test_both_cli_tools_offer_gp_linmean_and_the_floor(): + for tool in ("util_HyperparameterTracerUpdate.py", "util_ParameterTracerUpdate.py"): + path = os.path.join(_BIN, tool) + choices = _parser_choices_via_ast(path, "--tracer-fit-method") + assert choices is not None, f"{tool}: no --tracer-fit-method" + assert "gp_linmean" in choices, (tool, choices) + assert _parser_choices_via_ast(path, "--tracer-lnl-floor-delta") is not None, ( + f"{tool}: --tracer-lnl-floor-delta not defined") + + +def test_hyperpipe_passes_the_floor_flag_through(): + """The hyperpipe drives the tracer via a yaml-key -> CLI-flag table; a new + flag is unreachable from a config unless it is listed there. Read the table + statically (util_RIFT_hyperpipe.py needs hydra to import).""" + with open(os.path.join(_BIN, "util_RIFT_hyperpipe.py")) as f: + tree = ast.parse(f.read()) + # Several stages define a `setting_flags` table; take the puff one. + table = None + for node in ast.walk(tree): + if (isinstance(node, ast.Assign) and len(node.targets) == 1 + and isinstance(node.targets[0], ast.Name) + and node.targets[0].id == "setting_flags"): + candidate = dict(ast.literal_eval(node.value)) + if "tracer-fit-method" in candidate: + table = candidate + assert table is not None, "puff setting_flags table not found" + assert table.get("tracer-lnl-floor-delta") == "--tracer-lnl-floor-delta" + assert table.get("tracer-fit-method") == "--tracer-fit-method" + + +def test_hyperparameter_tool_end_to_end_with_gp(): + """Run the hyperpipe CLI wrapper for real on a small .dat grid. + + (The event-level twin needs lalsuite for its XML I/O, so it is covered by + the parser check above rather than an end-to-end run.)""" + sys.path.insert(0, _BIN) + try: + import importlib.util as ilu + spec = ilu.spec_from_file_location( + "util_HyperparameterTracerUpdate", + os.path.join(_BIN, "util_HyperparameterTracerUpdate.py")) + tool = ilu.module_from_spec(spec) + spec.loader.exec_module(tool) + finally: + sys.path.remove(_BIN) + assert tool._TRACER_OK, "tracer engine not importable from the CLI tool" + + X, Y, sigma = _clipped_training_set(n=60, seed=13) + Y[0] = -1e9 # exercise the floor too + rows = np.column_stack([Y, sigma, X]) + tmpdir = tempfile.mkdtemp() + try: + fin = os.path.join(tmpdir, "grid.dat") + fout = os.path.join(tmpdir, "grid_out.dat") + np.savetxt(fin, rows, header="lnL sigma_lnL p1 p2") + + tool.main(["--inj-file", fin, "--inj-file-out", fout, + "--parameter", "p1", "--parameter", "p2", + "--update-method", "ucb", "--tracer-fit-method", "gp_linmean", + "--tracer-lnl-floor-delta", "50", + "--ucb-n-candidates", "2000", "--rng-seed", "0"]) + + out = np.loadtxt(fout) + finally: + shutil.rmtree(tmpdir, ignore_errors=True) + assert out.shape[1] == rows.shape[1] + assert len(out) == len(rows) + assert np.all(np.isfinite(out)) + assert np.all(out[:, 0] == 0) and np.all(out[:, 1] == 0) # puffball convention + + +# --------------------------------------------------------------------------- # + +if __name__ == "__main__": + import traceback + fails = 0 + for name, fn in sorted(globals().items()): + if not (name.startswith("test_") and callable(fn)): + continue + if not _HAVE_SKLEARN and name == "test_gp_extrapolates_where_rf_goes_flat": + print(f"SKIP {name} (no sklearn)") + continue + try: + fn() + print(f"ok {name}") + except Exception: + fails += 1 + print(f"FAIL {name}") + traceback.print_exc() + # Print the headline numbers so the argument is visible, not just asserted. + if _HAVE_SKLEARN: + X, Y, sigma = _clipped_training_set() + gp = fits.build("gp_linmean", X, Y, sigma=sigma) + rf = fits.build("rf", X, Y, sigma=sigma) + ray = _ray_toward_peak([0.9, 1.5, 2.0, 2.5, 3.0]) + print("\n x (peak at %.1f, training hull ends at %.1f)" % (X_PEAK, X_EDGE)) + print(" x: " + " ".join(f"{v:8.3f}" for v in ray[:, 0])) + print(" true lnL: " + " ".join(f"{v:8.3f}" for v in _true_lnL(ray))) + print(" gp_linmean:" + " ".join(f"{v:8.3f}" for v in gp.predict(ray))) + print(" rf: " + " ".join(f"{v:8.3f}" for v in rf.predict(ray))) + sys.exit(1 if fails else 0) diff --git a/MonteCarloMarginalizeCode/Code/test/tracer_placement/.gitignore b/MonteCarloMarginalizeCode/Code/test/tracer_placement/.gitignore new file mode 100644 index 000000000..b7ef69afe --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/test/tracer_placement/.gitignore @@ -0,0 +1,8 @@ +# This suite's lock is deliberately NOT tracked. +# +# The root pixi project commits its lock because it pins the production RIFT +# stack. This one is a disposable test environment that runs in CI and on +# assorted developer and access-point machines; a committed lock there just +# churns, goes stale, and pins platforms nobody in that set is using. Let pixi +# re-solve. test/hyperpipe/ tracks no lock either. +pixi.lock diff --git a/MonteCarloMarginalizeCode/Code/test/tracer_placement/README.md b/MonteCarloMarginalizeCode/Code/test/tracer_placement/README.md new file mode 100644 index 000000000..8d9a832fe --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/test/tracer_placement/README.md @@ -0,0 +1,93 @@ +# RIFT.misc.tracer_placement test environment + +Self-contained [pixi](https://pixi.sh) environment for the tracer-placement +engine (`Code/RIFT/misc/tracer_placement/`) and the fit-side behaviour of the +two tracer drop-in tools, so the engine can be tested from any RIFT clone +without a lalsuite install and without polluting your global Python. + +The suite itself lives one level up, with the rest of the RIFT tests: +`Code/test/test_tracer_placement_gp.py`. + +## Quick run + +```sh +# one-time, if you don't have pixi: +curl -fsSL https://pixi.sh/install.sh | bash + +cd MonteCarloMarginalizeCode/Code/test/tracer_placement +pixi run test # full pytest suite +``` + +Auxiliary entry points: + +```sh +pixi run test-minimal # pytest-free run (the suite has its own __main__) +pixi run demo # same, and prints the extrapolation table below +pixi run which-suite # confirm the paths resolved correctly +``` + +## Why this is separate from `test/hyperpipe/` + +`test/hyperpipe/` installs the full lalsuite stack because +`import RIFT.hyperpipe.*` pays for `RIFT/__init__.py`, which imports +`lalsimutils` unconditionally. The tracer engine has no such dependency: the +core is numpy-only, `rf` adds scikit-learn and `rbf` adds scipy, and the suite +loads `tracer_placement` directly off `RIFT/misc` rather than importing the +`RIFT` package. So this environment is python + numpy + scipy + scikit-learn + +pytest and installs in about a minute. + +`PYTHONPATH` is deliberately *not* pointed at `Code/`, so a stray +`import RIFT.` fails loudly here instead of half-working. + +`pixi.lock` is **not** tracked (see `.gitignore` here). The root pixi project +commits its lock because it pins the production RIFT stack; this one is a +disposable test environment that runs in CI and on assorted developer and +access-point machines, where a committed lock only churns and goes stale. Let +pixi re-solve. `test/hyperpipe/` tracks no lock either. + +The one thing this buys asymmetric coverage on: `util_HyperparameterTracerUpdate.py` +(.dat I/O, numpy-only) is run end-to-end, while `util_ParameterTracerUpdate.py` +(XML I/O via `lalsimutils`) is checked by static parser inspection. Use +`test/hyperpipe/` or the root pixi project if you need to run the event-level +tool for real. + +## What the suite proves + +| Group | What it proves | +|---|---| +| `test_gp_extrapolates_where_rf_goes_flat` | The headline argument for `gp_linmean`. On a synthetic lnL surface whose peak lies outside the training hull, `rf` is exactly flat with zero gradient and gains nothing outside, while the GP rises monotonically toward the peak and its argmax over the wider box lands outside the sampled region. | +| `test_linear_mean_extrapolates_where_const_mean_reverts` | Isolates the *mean function* as the cause: same kernel, same data, `mean="const"` reverts toward a flat prior away from data. Runs without sklearn. | +| `test_uncertainty_grows_outside_the_hull` | `predict_with_std` is the calibrated sigma `samplers/ucb.py` needs: small on training points, saturating at `sqrt(sf2)` in the unsampled frontier. | +| `test_analytic_grad_matches_finite_difference` | The analytic gradient (used by UCB's `_polish_gradient`) matches finite differences. | +| GP mechanics | Training-data interpolation, output shapes, 1-D parameter spaces, seamless internal chunking of `predict_with_std`, loud rejection of bad input, duplicate training rows absorbed by the Cholesky jitter. | +| Dispatch | `gp_linmean` is registered (including the hyphenated spelling), unknown methods still raise, constructor kwargs pass through `build()`. | +| lnL floor | Default `None` is a pass-through (legacy bit-for-bit); the floor clamps without dropping points; it rescues a GP wrecked by a single -1e9 outlier; it applies across every fit method. | +| Integration | UCB end-to-end on a GP surrogate places outside the old hull; both tracer CLI tools expose `gp_linmean` and `--tracer-lnl-floor-delta`; the hyperpipe yaml-key → CLI-flag table passes the new flag through; a live run of `util_HyperparameterTracerUpdate.py` with `--tracer-fit-method gp_linmean --tracer-lnl-floor-delta 50`. | + +`pixi run demo` prints the numbers behind the headline test — peak at x = 3.0, +training data confined to x <= 1.0: + +``` + x: 0.900 1.500 2.000 2.500 3.000 + true lnL: -3.445 -1.758 -0.781 -0.195 -0.000 + gp_linmean: -3.446 -1.375 0.664 2.619 4.554 + rf: -3.494 -3.144 -3.144 -3.144 -3.144 +``` + +The `rf` row wobbles in the last digits between runs (see the `random_state` +note below); what does not wobble, and is what the test asserts, is that it is +constant across the four out-of-hull columns. + +## When something fails + +* **`ModuleNotFoundError: tracer_placement`**: the suite resolves + `RIFT/misc/tracer_placement` from its own `__file__`, so this means the test + file has been moved away from `Code/test/`. `pixi run which-suite` should + print both paths. +* **A `predict_with_std` / Cholesky failure on a real grid** is usually + duplicate or near-duplicate training rows. The fit escalates jitter six times + before giving up; if it does give up, the error names the likely cause. +* **`rf` results are not reproducible** between runs even with `--rng-seed`: + that is a known pre-existing gap — `fits/_rf.py` does not set + `random_state` on the `RandomForestRegressor`. Not something this suite + asserts against. diff --git a/MonteCarloMarginalizeCode/Code/test/tracer_placement/pixi.toml b/MonteCarloMarginalizeCode/Code/test/tracer_placement/pixi.toml new file mode 100644 index 000000000..26596b42d --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/test/tracer_placement/pixi.toml @@ -0,0 +1,76 @@ +# Pixi project for the RIFT.misc.tracer_placement test suite. +# +# Location: $RIFT_ROOT/MonteCarloMarginalizeCode/Code/test/tracer_placement/ +# Suite: $RIFT_ROOT/MonteCarloMarginalizeCode/Code/test/test_tracer_placement_gp.py +# +# Deliberately LIGHT. Unlike test/hyperpipe/, this environment has NO lalsuite: +# the tracer placement engine is pure numpy (plus sklearn for the `rf` fit and +# scipy for `rbf`), and the test suite loads `tracer_placement` directly off +# RIFT/misc rather than importing the RIFT package, whose __init__ would drag in +# lalsimutils and the whole LAL chain. Keeping it out makes `pixi install` a +# ~1-minute job instead of a multi-GB solve, which is the point: there should be +# no excuse for shipping this engine untested. +# +# The consequence is that the two tracer CLI tools are covered asymmetrically: +# util_HyperparameterTracerUpdate.py (.dat I/O, numpy-only) is exercised +# end-to-end, while util_ParameterTracerUpdate.py (XML I/O via lalsimutils) is +# checked by static parser inspection. Use the hyperpipe/root env if you need to +# run the event-level tool for real. +# +# Tasks +# ----- +# pixi run test # full pytest suite +# pixi run test-minimal # pytest-free run (the file has its own __main__) +# pixi run demo # print the extrapolation table that motivates gp_linmean +# pixi run which-suite # confirm the paths resolved correctly +# +# First-time setup +# ---------------- +# curl -fsSL https://pixi.sh/install.sh | bash # one-time +# cd $RIFT_ROOT/MonteCarloMarginalizeCode/Code/test/tracer_placement +# pixi run test +# +# pixi.lock is NOT tracked here -- this is a disposable test environment that +# runs in CI and on assorted machines, so let pixi re-solve. See ./.gitignore. + +[workspace] +name = "rift-tracer-placement-test" +version = "0.1.0" +description = "Test environment for RIFT.misc.tracer_placement (fits + samplers)." +authors = ["RIFT developers"] +channels = ["conda-forge"] +platforms = ["linux-64", "osx-64", "osx-arm64"] + +[dependencies] +python = ">=3.11" +# The engine core is numpy-only by design; these two are what the optional +# fits need. sklearn -> fits/_rf.py, scipy -> fits/_rbf.py. +numpy = "*" +scikit-learn = "*" +scipy = "*" +pytest = "*" + +# Resolve paths relative to this pixi.toml so the project works from any RIFT +# clone (no hardcoded user path). The layout is: +# +# $RIFT_ROOT/MonteCarloMarginalizeCode/Code/test/tracer_placement/pixi.toml +# ^^^^^^^^^^^^^^^^ +# PIXI_PROJECT_ROOT +# +# so going up four levels lands at $RIFT_ROOT. +# +# NOTE: PYTHONPATH is deliberately NOT set to $RIFT_PY. Putting it there would +# let a stray `import RIFT.` succeed at collection time and then fail +# on lalsuite, which is absent here on purpose. The suite resolves +# RIFT/misc/tracer_placement from its own __file__ instead. +[activation.env] +RIFT_ROOT = "$PIXI_PROJECT_ROOT/../../../.." +RIFT_PY = "$PIXI_PROJECT_ROOT/../.." +RIFT_BIN = "$PIXI_PROJECT_ROOT/../../bin" +RIFT_TEST = "$PIXI_PROJECT_ROOT/.." + +[tasks] +test = "pytest -v $RIFT_TEST/test_tracer_placement_gp.py" +test-minimal = "python $RIFT_TEST/test_tracer_placement_gp.py" +demo = "python $RIFT_TEST/test_tracer_placement_gp.py" +which-suite = "echo RIFT_ROOT=$RIFT_ROOT && ls -la $RIFT_PY/RIFT/misc/tracer_placement/fits/ && ls -la $RIFT_TEST/test_tracer_placement_gp.py"