#!/usr/bin/env python
#
# FOLLOWUP TEST (if known injection)
#    util_PrintInjection.py --inj mdc.xml.gz --event 0 --verbose
#   util_PrintInjection.py --inj maxpt_zero_noise.xml.gz.xml.gz --event 0
"""
Integrate the extrinsic parameters of the prefactored likelihood function.
"""

import sys
import json
import functools
from optparse import OptionParser, OptionGroup

import numpy
import numpy as np
import os
try:
  import cupy
  xpy_default=cupy
  identity_convert = cupy.asnumpy
  identity_convert_togpu = cupy.asarray
  identity_convert_lnL= identity_convert # see later, will be assigned something different if cupy active AND AC  AND gpu
  junk_to_check_installed = cupy.array(5)  # this will fail if GPU not installed correctly
  if not('RIFT_LOWLATENCY' in os.environ):
    print(cupy.show_config())  # print provenance/debugging information

    # Check memory allocation
    mem_info = cupy.cuda.Device().mem_info  # memory available in bytes
    n_mb_total = mem_info[1]/1024./1024.
    n_mb_free = mem_info[0]/1024/1024.
    print(  " cupy memory [total, available] {} {} Mb".format(n_mb_total,n_mb_free))
    # Add failure mode test if memory insufficient!
    if n_mb_free < 3000:
      print( "  - low cupy memory - ")
  cupy_success=True
except:
  print( ' no cupy')
#  import numpy as cupy  # will automatically replace cupy calls with numpy!
  xpy_default=numpy  # just in case, to make replacement clear and to enable override
  identity_convert = lambda x: x  # trivial return itself
  identity_convert_togpu = lambda x: x
  identity_convert_lnL= lambda x:x # see later
  cupy_success=False

import lal
from igwn_ligolw import utils,  ligolw
#from igwn_ligolw.utils import process
import glue.lal

import RIFT.lalsimutils as lalsimutils
from RIFT.likelihood.time_interp_choice import CROSSOVER_GUIDANCE as _CROSSOVER_GUIDANCE
from RIFT.likelihood.time_interp_choice import TIME_INTERP_DEFAULT
# Q_TIME_PREGRID_CHOICES (RIFT/likelihood/q_time_pregrid.py) is the SINGLE definition of the
# legal --q-time-pregrid-factor set; this driver calls the shared validator below rather than
# retyping its own tuple+message, so the pipeline-side mirror (q_time_pregrid.py) and this
# driver cannot silently drift apart (PR #291 review, MAJOR #2: this used to be an independent
# literal here, and a pipeline-side docstring claimed drift was impossible while nothing
# enforced it).
from RIFT.likelihood.q_time_pregrid import validate_q_time_pregrid_factor
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,
# cupy when mcsamplerGPU.draw_simplified() calls it with a device array), so one definition serves
# every sampler.
from RIFT.integrators.mcsampler import (clip_angle_limits, cosine_sampler_limits,
                                        distance_limit_range, distance_sampler_kwargs,
                                        ret_dec_samp_vector, ret_dec_samp_cdf_inv_vector,
                                        ret_cos_samp_vector, ret_cos_samp_cdf_inv_vector)
import RIFT.misc.sky_rotations as sky_rotations
try:
    import RIFT.integrators.mcsamplerEnsemble as mcsamplerEnsemble
    mcsampler_gmm_ok = True
except:
    print(" No mcsamplerEnsemble ")
    mcsampler_gmm_ok = False
try:
    import RIFT.integrators.mcsamplerGPU as mcsamplerGPU
    mcsampler_gpu_ok = True
except:
    print( " No mcsamplerGPU ")
    mcsampler_gpu_ok = False
try:
    import RIFT.integrators.mcsamplerAdaptiveVolume as mcsamplerAdaptiveVolume
    mcsampler_AV_ok = True
except:
    print(" No mcsamplerAV ")
    mcsampler_AV_ok = False
mcsampler_Portfolio_ok=False
try:
    if not('RIFT_LOWLATENCY' in os.environ):
      import RIFT.integrators.mcsamplerPortfolio as mcsamplerPortfolio
      mcsampler_Portfolio_ok = True
    else:
      mcsampler_Portfolio_ok = False
except:
    print(" No mcsamplerPortolfio ")

import RIFT.likelihood.priors_utils as priors_utils
import RIFT.misc.xmlutils as xmlutils


class EvenBivariateLinearInterpolator:
    def __init__(self, x0, dx, y0, dy, f):
        self._x0 = x0
        self._dx = dx
        self._y0 = y0
        self._dy = dy
        self._fgrid = xpy_default.asarray(f)

        self._dx_inv = 1.0 / self._dx
        self._dy_inv = 1.0 / self._dy

        self._N, self._M = self._fgrid.shape

    def __call__(self, x, y):
        # Compute the fractional indices into the lookup table where the free
        # parameters lie.
        i_mid = self._dx_inv * (x - self._x0)
        j_mid = self._dy_inv * (y - self._y0)

        # Compute the floor and ceiling of the fractional indices to get the
        # indices of the boundaries of the bin `x` and `y` lie in.
        # NOTE: In the case where `x` or `y` lie directly on a boundary, the
        # floor and ceiling will be equal, but the output is written in such a
        # way that we'd still get the correct result.
        i_lo = xpy_default.floor(i_mid).astype(int)
        j_lo = xpy_default.floor(j_mid).astype(int)
        i_hi = xpy_default.ceil(i_mid).astype(int)
        j_hi = xpy_default.ceil(j_mid).astype(int)

        # Compute just the fractional part of each index from the low and high
        # points.
        p = i_mid - i_lo
        q = j_mid - j_lo
        p_ = 1-p
        q_ = 1-q

        # Compute the interpolated result.
        f_approx =  p_*q_ * self._fgrid[i_lo,j_lo]
        f_approx += p *q_ * self._fgrid[i_hi,j_lo]
        f_approx += p_*q  * self._fgrid[i_lo,j_hi]
        f_approx += p *q  * self._fgrid[i_hi,j_hi]

        return f_approx


__author__ = "Evan Ochsner <evano@gravity.phys.uwm.edu>, Chris Pankow <pankow@gravity.phys.uwm.edu>, R. O'Shaughnessy"



#
# Pinnable parameters -- for command line processing
#
LIKELIHOOD_PINNABLE_PARAMS = ["right_ascension", "declination", "psi", "distance", "phi_orb", "t_ref", "inclination"]

def get_pinned_params(opts):
    """
    Retrieve a dictionary of user pinned parameters and their pin values.
    """
    return dict([(p,v) for p, v in opts.__dict__.items() if p in LIKELIHOOD_PINNABLE_PARAMS and v is not None]) 

def get_unpinned_params(opts, params):
    """
    Retrieve a set of unpinned parameters.
    """
    return params - set([p for p, v in opts.__dict__.items() if p in LIKELIHOOD_PINNABLE_PARAMS and v is not None])

def zero_like(*args,**kwargs):
  if len(kwargs)>0:
    arg0 = kwargs['psi']
  else:
    arg0 = args[0]
  return xpy_default.zeros(len(arg0))

def unit_like(*args,**kwargs):
  if len(kwargs)>0:
    arg0 = kwargs['psi']
  else:
    arg0 = args[0]
  return xpy_default.ones(len(arg0))


def sampler_param_tuple(sampler,args):
  param_list = sampler.params_ordered
  return tuple( [param_list.index(x) for x in args])

#
# Option parsing
#

optp = OptionParser()
optp.add_option("--check-good-enough", action='store_true', help="If active, tests if a file 'ile_good_enough' in the current directory exists and has content of nonzero length. Terminates with 'success' if the file exists and has nonzero length ")
optp.add_option( "--zero-likelihood", action='store_true', help="Run with exactly zero likelihood.  Prior test (e.g., alternative distance priors)")
optp.add_option("-c", "--cache-file", default=None, help="LIGO cache file containing all data needed.")
optp.add_option("-C", "--channel-name", action="append", help="instrument=channel-name, e.g. H1=FAKE-STRAIN. Can be given multiple times for different instruments.")
optp.add_option("-p", "--psd-file", action="append", help="instrument=psd-file, e.g. H1=H1_PSD.xml.gz. Can be given multiple times for different instruments.")
optp.add_option("-k", "--skymap-file", help="Use skymap stored in given FITS file.")
optp.add_option("-x", "--coinc-xml", help="gstlal_inspiral XML file containing coincidence information.")
optp.add_option("-I", "--sim-xml", help="XML file containing parameter grid to be evaluated")
optp.add_option( "--sim-grid", help="ascii file with labels containing parameter grid to be evaluated, potentially including parameters passed to external libraries or to the waveform generator/cal/systematics as extra arguments.  ")
optp.add_option("-E", "--event", default=0,type=int, help="Event number used for this run")
optp.add_option( "--random-event", action='store_true', help="Pick a RANDOM event from the file. Dangerous - watch out for oversampling fast events")
optp.add_option("--n-events-to-analyze", default=1,type=int, help="Number of events to analyze from this XML")
optp.add_option("--soft-fail-event-range",action='store_true',help='Soft failure (exit 0) if event ID is out of range. This happens in pipelines, if we have pre-built a DAG attempting to analyze more points than we really have')
optp.add_option("-f", "--reference-freq", type=float, default=100.0, help="Waveform reference frequency. Required, default is 100 Hz.")
optp.add_option("--fmin-template", dest='fmin_template', type=float, default=40, help="Waveform starting frequency.  Default is 40 Hz. Also equal to starting frequency for integration") 
optp.add_option("--fmin-template-correct-for-lmax",action='store_true',help="Modify amount of data selected, waveform starting frequency to account for l-max, to better insure all requested modes start within the targeted band")
optp.add_option("--internal-waveform-taper",default=None, help="lalsimulation taper option string name ")
optp.add_option("--internal-use-gwpy",action='store_true', help="Use gwpy for low-level io")
optp.add_option("--fmin-ifo", action='append' , help="Minimum frequency for each IFO. Implemented by setting the PSD=0 below this cutoff. Use with care.") 
#optp.add_option("--nr-params",default=None, help="List of specific NR parameters and groups (and masses?) to use for the grid.")
#optp.add_option("--nr-index",type=int,default=-1,help="Index of specific NR simulation to use [integer]. Mass used: mtot= m1+m2")
optp.add_option('--nr-group', default=None,help="If using a *ssingle specific simulation* specified on the command line, provide it here")
optp.add_option('--nr-param', default=None,help="If using a *ssingle specific simulation* specified on the command line, provide it here")
optp.add_option("--nr-lookup",action='store_true', help=" Look up parameters from an NR catalog, instead of using the approximant specified")
optp.add_option("--nr-lookup-group",action='append', help="Restriction on 'group' for NR lookup")
optp.add_option("--nr-hybrid-use",action='store_true',help="Enable use of NR (or ROM!) hybrid, using --approx as the default approximant and with a frequency fmin")
optp.add_option("--nr-hybrid-method",default="taper_add",help="Hybridization method for NR (or ROM!).  Passed through to LALHybrid. pseudo_aligned_from22 will provide ad-hoc higher modes, if the early-time hybridization model only includes the 22 mode")
optp.add_option("--rom-group",default=None)
optp.add_option("--rom-param",default=None)
optp.add_option("--rom-use-basis",default=False,action='store_true',help="Use the ROM basis for inner products.")
optp.add_option("--rom-limit-basis-size-to",default=None,type=int)
optp.add_option("--rom-integrate-intrinsic",default=False,action='store_true',help='Integrate over intrinsic variables. REQUIRES rom_use_basis at present. ONLY integrates in mass ratio as present')
optp.add_option("--nr-perturbative-extraction",default=False,action='store_true')
optp.add_option("--nr-perturbative-extraction-full",default=False,action='store_true')
optp.add_option("--nr-use-provided-strain",default=False,action='store_true')
optp.add_option("--no-memory",default=False,action='store_true', help="At present, turns off m=0 modes. Use with EXTREME caution only if requested by model developer")
optp.add_option("--restricted-mode-list-file",default=None,help="A list of ALL modes to use in likelihood. Incredibly dangerous. Only use when comparing with models which provide restricted mode sets, or otherwise to isolate the effect of subsets of modes on the whole")
optp.add_option("--use-gwsignal",default=False,action='store_true',help='Use gwsignal. In this case the approx name is passed as a string to the lalsimulation.gwsignal interface')
optp.add_option("--use-gwsignal-lmax-nyquist",default=None,type=int,help='Passes lmax_nyquist integer to the gwsignal waveform interface')
optp.add_option("--use-external-EOB",default=False,action='store_true')
optp.add_option("--maximize-only",default=False, action='store_true',help="After integrating, attempts to find the single best fitting point")
optp.add_option("--dump-lnL-time-series",default=False, action='store_true',help="(requires --sim-xml) Dump lnL(t) at the injected parameters")
optp.add_option("-a", "--approximant", default="TaylorT4", help="Waveform family to use for templates. Any approximant implemented in LALSimulation is valid.")
optp.add_option("-A", "--amp-order", type=int, default=0, help="Include amplitude corrections in template waveforms up to this e.g. (e.g. 5 <==> 2.5PN), default is Newtonian order.")
optp.add_option("--l-max", type=int, default=2, help="Include all (l,m) modes with l less than or equal to this value.")
optp.add_option("-s", "--data-start-time", type=float, default=None, help="GPS start time of data segment. If given, must also give --data-end-time. If not given, sane start and end time will automatically be chosen.")
optp.add_option("-e", "--data-end-time", type=float, default=None, help="GPS end time of data segment. If given, must also give --data-start-time. If not given, sane start and end time will automatically be chosen.")
optp.add_option("--data-integration-window-half",default=75*1e-3,type=float,help="Only change this window size if you are an expert. The window for time integration is -/+ this quantity around the event time")
optp.add_option("--internal-data-storage-window-half",default=0.15,type=float,help="Only change this window size if you are an expert. This is the haf-size of the window used to store data internally during the precompute step")
optp.add_option("-F", "--fmax", type=float, help="Upper frequency of signal integration. Default is use PSD's maximum frequency.")
optp.add_option("--srate",default=16384,type=int,help="Sampling rate. Change ONLY IF YOU ARE ABSOLUTELY SURE YOU KNOW WHAT YOU ARE DOING.")
optp.add_option("--srate-internal",default=None,type=int,help="If provided, internal-use sampling rate. If not provided, all calculations performed with 'srate'. Change ONLY IF YOU ARE ABSOLUTELY SURE YOU KNOW WHAT YOU ARE DOING.")
optp.add_option("-t", "--event-time", type=float, help="GPS time of the event --- probably the end time. Required if --coinc-xml not given.")
optp.add_option("-i", "--inv-spec-trunc-time", type=float, default=8., help="Timescale of inverse spectrum truncation in seconds (Default is 8 - give 0 for no truncation)")
optp.add_option("-w", "--window-shape", type=float, default=0, help="Shape of Tukey window to apply to data (default is no windowing)")
optp.add_option("--psd-window-shape", type=float, default=0, help="Shape of Tukey window that *was* applied to the PSD being passed. If nonzero, we will rescale the PSD by the ratio of window shape results")
optp.add_option("-m", "--time-marginalization", action="store_true", help="Perform marginalization over time via direct numerical integration. Default is false.")
#optp.add_option("--n-fairdraw-extrinsic-samples",default=None,type=int,help="Extracts a concrete number of fair draw extrinsic samples, bounded above by n_eff")
optp.add_option("--resample-time-marginalization",action='store_true', help="If time-marginalizaiton is true (and should almost always be true), at the end export step use resampling. REQUIRES using fairdraw-extrinsic-output")
optp.add_option("--srate-resample-time-marginalization",type=int, default=None, help="For --time-posterior-export grid under the historical Simpson quadrature, interpolate lnL(t) onto a lattice at this rate before drawing. Band-limited quadrature derives its own resolution and mandates a continuous draw, so the combination is refused.")
optp.add_option("--time-posterior-export", type="choice", choices=["auto", "continuous", "grid"], default="auto",
                help="How --resample-time-marginalization exports geocenter time. auto (default) draws continuously from the interpolated lnL(t) posterior when the active likelihood exposes a faithful arbitrary-time evaluator, otherwise preserves the grid; continuous requests an off-grid draw and is refused on unsupported likelihood paths; grid is the explicit legacy compatibility mode. --time-marginalization-quadrature bandlimited always resolves to continuous and refuses grid because sub-sample integration carries a sub-sample export contract.")
optp.add_option("--psi-marginalization", action="store_true", default=False, help="Opt-in: analytically marginalize the polarization angle psi over its uniform [0,pi) prior with factored_likelihood.NetworkLogLikelihoodPolarizationMarginalized, instead of sampling it. Only reachable on the legacy scalar (non-vectorized, non-GPU, non-time-marginalized) likelihood path, which is the only call site this function fits; REFUSED (not ignored) with --time-marginalization, --vectorized, --gpu, --distance-marginalization, --rotation-slow, --freqresponse, calibration marginalization, an explicit --interpolate-time, --sampler-method GMM/portfolio, --internal-rotate-phase, --limit-psi, and --zero-likelihood. Default is false (unreachable previously; see issue tracking marginalization-audit). The exported 'psi'/'polarization' column is NaN, NOT a sample: psi is integrated out, so there is no per-sample draw to report, and downstream PE-sample converters copy that column verbatim. The reported lnL carries the SAME psi prior mass the sampled-psi path carries (1/pi over (0,2 pi), i.e. 2), so lnZ is comparable with ordinary rows in the same all.net; note that mass is not 1, and the RIFT JAX driver uses psi in [0,pi] instead.")
optp.add_option("-d", "--distance-marginalization", action="store_true", help="Perform marginalization over distance via a look-up table. Default is false.")
optp.add_option("-l", "--distance-marginalization-lookup-table", default=None, help="Look-up table for distance marginalization.")
optp.add_option("--calibration-envelope-directory",default=None, help="Name of directory")
optp.add_option("--calibration-n-realizations", default=100, type=int, help="Number of realizations to use for calmarg, recommend 100")
optp.add_option("--calibration-spline-count", default=10,type=int)
optp.add_option("--calibration-fused-kernel", action="store_true", default=False, help="Opt-in: use the fused GPU kernel (Option C) for in-loop calibration marginalization. GPU only, no phase marginalization; with distance marginalization it uses the fused distmarg kernel. Falls back to the loop method (Option B) otherwise.")
optp.add_option("--calibration-conjugate-phase", action="store_true", default=False, help="Opt-in calmarg phase-convention fix: apply conj(C) instead of C to the data when building the per-realization rholms, so the recovered calibration PHASE tracks the correct sign (matches the template-side <d|C h> convention).  Needed only for a phase-UNmarginalized or large-phase treatment; under phase marginalization with small phase it changes lnL by <0.05 nats.  Independent of the per-realization self-term amplitude fix (|C|^2 is conjugation-invariant), which is on by default when calibration marginalization is active.")
optp.add_option("--calibration-global-norm", action="store_true", default=False, help="Opt-in cheaper calmarg route: use the calibration-INDEPENDENT template norm <h|h> for every realization instead of the complete per-realization self-term <C_c h|C_c h>.  Skips the amplitude-basis precompute (an SVD of {|C_c|^2} plus rank-M(M+1)/2 weighted template blocks), which can dominate the per-intrinsic-point cost for long low-mass templates and large --calibration-n-realizations.  The dropped term is the amplitude self-term, whose evidence bias scales as ~0.5 rho^4 sigma_A^2 (amplitude-only, phase-independent): negligible at low/moderate SNR unless the amplitude envelope is wide, but growing as rho^4, so NOT for loud sources or wide amplitude envelopes.")
optp.add_option("--calibration-proposal-breadcrumb",default=None, help="Opt-in (Option C / adaptive pilot): path to a breadcrumb .npz (RIFT.calmarg.breadcrumbs) carrying a LEARNED Gaussian proposal over cal spline nodes. When set, the cal realizations are drawn from that proposal instead of the broad prior, and the marginalization carries Phase-0 importance weights log(prior/proposal) so it stays unbiased. Requires --calibration-envelope-directory (the prior).")
optp.add_option("--calibration-dump-responsibilities",default=None, help="Opt-in (Option C / adaptive pilot): path to write per-cal-realization log-responsibilities (length n_cal), accumulated over the evaluated grid, plus the cal node draws. This is the pilot's output, fitted into a proposal by util_CalPilotFit.py. No effect on the returned likelihood.")
optp.add_option("--calibration-pilot-extrinsic",default=256,type=int, help="Pilot only: number of uniform-prior extrinsic samples used to extrinsic-marginalize the per-realization cal responsibility at each intrinsic point. Cal is ~extrinsic-independent, so a modest batch suffices.")
optp.add_option("--calibration-mc-error-extrinsic",default=8192,type=int, help="Calmarg error budget: CAP on the number of extrinsic-prior samples used to estimate the calibration Monte-Carlo contribution to the lnL error (per-realization responsibilities a_c -> Var(lnZ) ~= n_cal*Var_c(a_c)), added IN QUADRATURE to the reported sigma column.  The batch is ADAPTIVE: it starts small and doubles until the estimate stabilizes or this cap is reached.  Distance is drawn from the RUN'S distance prior (sampler prior / --d-prior; with a PINNED distance the probe runs at that fixed value and warns that the estimate is conservative).  The extrinsic sampler's variance cannot see the spread over the (fixed) cal draw set, so without this term the reported error badly understates the truth whenever the cal n_eff is small.  Set 0 to disable (restores the old, extrinsic-only sigma).")
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-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).")
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("--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.")
optp.add_option("--calibration-neff-cal-target",default=10,type=float, help="Calmarg ADAPTIVE draw count: after the cal-block precompute, probe the effective number of contributing cal draws (neff_cal) at this intrinsic point; while it is below this target, DOUBLE the cal draw set (drawing fresh independent realizations and appending their precomputed blocks) up to --calibration-n-realizations-max.  Set 0 to disable (fixed --calibration-n-realizations).")
optp.add_option("--calibration-n-realizations-max",default=0,type=int, help="Cap for the adaptive cal draw count (see --calibration-neff-cal-target).  Default 0 = 8x --calibration-n-realizations.")
optp.add_option("--calibration-burn-in-neff",default=None,type=float, help="Opt-in: before the production cal-marginalized integration, BURN IN the extrinsic sampler on the cheap ZERO-CAL (n_cal=1) likelihood until this effective sample count, then switch to the full cal-marginalized likelihood. The extrinsic posterior is ~cal-independent. CAVEAT: the AV sampler RESETS between integrate() calls (no seedable AV yet), so this gives AV no speedup (correctness-safe only). It can warm-start GMM/portfolio (model reuse). Awaiting a seedable / boundary-shifting AV; see DESIGN_adaptive_driver.md. No effect unless calmarg is active.")
optp.add_option("--calibration-burn-in-nmax",default=None,type=int, help="Cap on the number of samples drawn during the zero-cal burn-in (default: the run's --n-max). Keeps the burn-in bounded if it cannot reach --calibration-burn-in-neff.")
optp.add_option("--calibration-export-posterior",action='store_true',default=False, help="Opt-in (final fairdraw export, calmarg active): for each fair-draw output sample, draw ONE calibration realization in proportion to its posterior weight (L_c * w_c, from the per-realization likelihood components) and write a SELF-CONTAINED sibling <output>_<event>_cal.dat with the FULL draw -- intrinsic + extrinsic + the drawn realization's spline-node values as labeled columns cal_<IFO>_amp_<k>/cal_<IFO>_phase_<k>.  The recovered cal posterior is then those columns, plottable with the standard tooling.  Requires --calibration-envelope-directory; retains the cal node vectors.")
optp.add_option("--extrinsic-proposal-breadcrumb",default=None, help="Opt-in (GMM sampler): SEED the extrinsic GMM sampler from a learned proposal breadcrumb (RIFT.calmarg.extrinsic_handoff): the per-group GMMs from a previous iteration's posterior pre-fill gmm_dict, so the sampler starts on the posterior instead of cold.  The extrinsic posterior barely moves iteration-to-iteration.  Groups matched by parameter name; missing groups fall back to the default.")
optp.add_option("--extrinsic-proposal-output",default=None, help="Opt-in (GMM sampler): after the integration, fit the run's extrinsic posterior samples to a per-group GMM and WRITE it as a proposal breadcrumb (to seed a later iteration via --extrinsic-proposal-breadcrumb).")
optp.add_option("--extrinsic-proposal-adapt",action='store_true',default=False, help="With --extrinsic-proposal-breadcrumb: let the SEEDED extrinsic GMM groups keep adapting (re-fit each iteration).  Default OFF = the seeded groups are FROZEN: a handed-off proposal (especially from a different, better-converged sampler) is trusted as-is, since the GMM's own adaptation is fragile on sharp ILE peaks (a bad batch re-fit triggers _reset and discards the seed).  Freeze keeps the good seed; enable adapt only if the source posterior may have drifted.")
optp.add_option("--vectorized", action="store_true", help="Perform manipulations of lm and timeseries using numpy arrays, not LAL data structures.  (Combine with --gpu to enable GPU use, where available)")
optp.add_option("--gpu", action="store_true", help="Perform manipulations of lm and timeseries using numpy arrays, CONVERTING TO GPU when available. You MUST use this option with --vectorized (otherwise it is a no-op). You MUST have a suitable version of cupy installed, your cuda operational, etc")
optp.add_option("--force-gpu-only", action="store_true", help="Hard fail if no GPU present (assessed by cupy not loading)")
optp.add_option("--rotation-slow", action="store_true", help="[Path A] Slow-rotation likelihood: account for the sidereal time-dependence of the antenna pattern F(t) over the signal (harmonic modulation).  Requires --vectorized; supports --gpu (n_cal=1, no glitch/cal marg).  May be combined with --freqresponse for long, loud 3G/BNS-like signals.")
optp.add_option("--rotation-n-harmonics", type=int, default=2, help="Number of sidereal harmonics for --rotation-slow (antenna pattern needs 2, i.e. n=-2..2).")
optp.add_option("--rotation-p-max", type=int, default=0, help="[Path B] Max delay-derivative order for --rotation-slow (0 = amplitude drift only; >=1 adds propagation-delay drift).")
optp.add_option("--freqresponse", action="store_true", help="[Path D] Finite-size (frequency-dependent) detector-response likelihood: account for the finite light-travel-time transfer across the arms (matters for 3G/CE-ET).  Requires --vectorized; supports --gpu (n_cal=1, no glitch/cal marg).  May be combined with --rotation-slow; the compound bank is substantially more expensive and intended for long, loud sources.")
optp.add_option("--freqresponse-qmax", type=int, default=4, help="Highest power of the arm projection retained for --freqresponse (basis size Qmax+2).  Higher for larger fL/c (heavier systems / higher fmax).")
optp.add_option("--freqresponse-arm-length", default=None, help="Arm-length override [m] for --freqresponse.  Either a single float applied to ALL detectors (e.g. 40000 for 40-km CE), or per-detector 'C1=40000,E1=10000,...' (needed for mixed CE+ET networks, since LAL's cached C1 arm is a placeholder).  Default: each detector's native LAL arm length.")
optp.add_option("--check-slowrot-pmax", action="store_true", default=False, help="Estimate required pmax from the response U,V bank and warn if --rotation-p-max is too small.")
optp.add_option("--check-finite-size-Qmax", "--check-finite-size-qmax", dest="check_finite_size_Qmax", action="store_true", default=False, help="Estimate required finite-size Qmax and warn if --freqresponse-qmax is too small.")
optp.add_option("--choose-slowrot-pmax", action="store_true", default=False, help="Choose the least pmax satisfying the response error budget.")
optp.add_option("--choose-slowrot-Qmax", "--choose-finite-size-Qmax", dest="choose_slowrot_Qmax", action="store_true", default=False, help="Choose the least finite-size Qmax satisfying the response error budget.")
optp.add_option("--response-order-snr", type=float, default=None, help="Target network SNR for response-order checks/choice (required when active).")
optp.add_option("--response-order-lnL-tol", type=float, default=0.1, help="Allowed worst scanned Asimov likelihood loss (default 0.1).")
optp.add_option("--response-order-sky-samples", type=int, default=128, help="Deterministic full-prior angular design size (default 128).")
optp.add_option("--response-order-p-reference", type=int, default=2, help="Highest p used by the diagnostic reference bank (default 2).")
optp.add_option("--response-order-Q-reference", "--response-order-q-reference", dest="response_order_Q_reference", type=int, default=8, help="Highest Q used by the diagnostic reference bank (default 8).")
optp.add_option("--response-order-max-bank-gib", type=float, default=4.0, help="Refuse a diagnostic whose dense U,V planning estimate exceeds this many GiB (default 4).")
optp.add_option("--force-xpy", action="store_true", help="Use the xpy code path.  Use with --vectorized --gpu to use the fallback CPU-based code path. Useful for debugging.")
optp.add_option("-o", "--output-file", help="Save result to this file.")
optp.add_option("-O", "--output-format", default='xml', help="[xml|hdf5]")
optp.add_option("-S", "--save-samples", action="store_true", help="Save sample points to output-file (sparse sim_inspiral XML). Requires --output-file to be defined. NOTE: the XML carries lnL only, not the importance weight -- it does NOT persist the joint prior / sampling prior, so it must not be reweighted by likelihood for a weighted-posterior/shape check. For that, use the ASCII per-sample outputs --extrinsic-proposal-output (full log-weight) or --calibration-export-posterior.")
optp.add_option("--save-samples-process-params", action="store_true", help="XML output retains process_params table, Default is not to do this")
optp.add_option("-L", "--save-deltalnL", type=float, default=float("Inf"), help="Threshold on deltalnL for points preserved in output file.  Requires --output-file to be defined")
optp.add_option("-P", "--save-P", type=float,default=0.1, help="Threshold on cumulative probability for points preserved in output file.  Requires --output-file to be defined")
optp.add_option("--e-freq", type=int,default=1, help="Used specifically for TEOBResumS to define when eccentricity either at periapstron (0), average (1), o\r apastron (2) initial frequency")
optp.add_option("--internal-hard-fail-on-error",action='store_true',help='If true, fails with exit code 1 if any point is unsuccessful')
optp.add_option("--internal-soft-fail-on-cuda-error",action='store_true',help='If true, returns with exit code 0 on any CUDA error. Use with care (e.g., if many jobs failing)')
optp.add_option("--internal-make-empty-file-on-error",action='store_true',help='If true, failed points generate empty output file. Protects against OSG workflow problems')
optp.add_option("--internal-waveform-fd-L-frame",action='store_true',help='If true, passes extra_waveform_kwargs = {fd_L_frame=True} to lalsimutils hlmoft. Impacts outputs of ChooseFDWaveform calls only.')
optp.add_option("--internal-waveform-fd-no-condition",action='store_true',help='If true, adds extra_waveform_kwargs = {no_condition=True} to lalsimutils hlmoft. Impacts outputs of ChooseFDWaveform calls only. Provided to enable controlled tests of conditioning impact on PE')
optp.add_option("--internal-waveform-extra-lalsuite-args",type=str,default=None)
optp.add_option("--internal-waveform-extra-kwargs",type=str,default=None)
optp.add_option("--internal-precompute-ignore-threshold",default=None,type=float)
optp.add_option("--verbose",action='store_true')
optp.add_option("--save-EOB-parameters", action="store_true")
optp.add_option("--save-hyperbolic", action="store_true")
optp.add_option('--force-hyperbolic-22', default=False, action='store_true', help='Forces just the 22 modes for hyperbolic waveforms')
optp.add_option("--save-eccentricity", action="store_true")
optp.add_option("--save-meanPerAno", action="store_true")
#
# Add the integration options
#
integration_params = OptionGroup(optp, "Integration Parameters", "Control the integration with these options.")
# Default is actually None, but that tells the integrator to go forever or until n_eff is hit.
integration_params.add_option("--n-max", type=int, help="Total number of samples points to draw. If this number is hit before n_eff, then the integration will terminate. Default is 'infinite'.",default=1e7)
integration_params.add_option("--n-eff", type=int, default=100, help="Total number of effective samples points to calculate before the integration will terminate. Default is 100")
integration_params.add_option("--fairdraw-extrinsic-output", action='store_true' , help="Output is fair draw, rather than being comprehensive")
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.  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)")
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("--no-adapt-after-first",action='store_true',help="Disables adaptation after first iteration with significant lnL")
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.")
integration_params.add_option("--adapt-floor-level", type=float, default=0.1, help="Floor to use with weights (likelihood integrand) when doing adaptive sampling. This is necessary to ensure the *sampling* prior is non zero during adaptive sampling and to prevent overconvergence. Default is 0.1 (no floor)")
integration_params.add_option("--adapt-adapt",action='store_true',help="Adapt the tempering exponent")
integration_params.add_option("--adapt-log",action='store_true',help="Use a logarithmic tempering exponent")
integration_params.add_option("--internal-gmm-correlate-all",action='store_true',help="GMM sampler: use a SINGLE full-dimension GMM group instead of the default (sky)(distance,inclination)(psi,phi) pairing. The default pairing targets quadrupole-dominated binaries with a large sky ring; a product of per-group GMMs cannot represent cross-group correlations (e.g. sky-phase), and for a strongly-localized single-peak source the factored proposal can stall at the prior. Component count from --internal-gmm-sky-components (default 2 in this mode).")
integration_params.add_option("--internal-gmm-sky-components",type=int,default=None,help="GMM sampler: number of mixture components for the (ra,dec) group (default 4, sized for a large sky ring; use 1-2 for a well-localized single peak, e.g. 3+ IFOs / high SNR). With --internal-gmm-correlate-all, sets the single full-dimension group's component count.")
integration_params.add_option("--internal-gmm-phase-components",type=int,default=None,help="GMM sampler: number of mixture components for the (psi,phi_orb) group (default 4; use 1-2 for a single dominant phase peak).")
integration_params.add_option("--internal-gmm-adaptive-components",action='store_true',help="GMM sampler (FLEXIBLE allocation): choose each adaptive group's component count from the DATA by BIC each chunk (GMM.fit_gmm_adaptive), instead of the hard-coded per-group counts (sky=4,dist-incl=2,...) that target quadrupole/large-sky-ring binaries.  BIC allocates more components only where the importance-weighted cloud is genuinely non-Gaussian (e.g. a curved distance-inclination arc) and stays at k=1 for a single blob; a defensive tail component (see --internal-gmm-defensive-frac) keeps the importance weights bounded.  Cap per group via --internal-gmm-max-components.")
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=None,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'. DEFAULT CHANGED 2026-09-02 from 'nearest' to %r (issue #233); the value is time_interp_choice.TIME_INTERP_DEFAULT, shared with the jax driver's --interp so the two cannot ship opposite defaults again. THIS CHANGES RESULTS for anyone who did not pass --interpolate-time; pass '--interpolate-time nearest' to reproduce a pre-2026-09-02 run. 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: an EXPLICIT request is REFUSED, not ignored, if the configuration cannot honour it, while the DEFAULT falls back to 'nearest' with a printed reason rather than turning a working configuration into a startup error. Measured tables and limitations: RIFT/likelihood/DESIGN_q_window_stencil.md. (Default=%s)" % (TIME_INTERP_DEFAULT, _CROSSOVER_GUIDANCE, TIME_INTERP_DEFAULT))
integration_params.add_option("--q-time-pregrid-factor", default=1, type=int,
  help="OPT-IN ordinary-NoLoop Q pregrid.  Value 8 reflects each finite cut Q window, FFT-interpolates it onto an 8x finer grid once after packing, and uses four-tap cubic interpolation for detector arrival times while leaving the geocentric time-integration grid at the data deltaT.  Default 1 preserves current behavior and memory.  Other factors are refused until separately validated.")
integration_params.add_option("--time-marginalization-quadrature", default="simpson", type=str, help="Rule for the TIME integral of the marginalized likelihood: 'simpson' (default, historical), 'bandlimited', or 'peak-local'. 'simpson' integrates exp(lnL(t)) with Simpson's rule at the FIXED spacing deltaT=1/srate. That spacing is a property of the DATA; the integrand's width is a property of the SIGNAL -- after angle marginalization exp(lnL(t)) is a near-Gaussian peak of width sigma_t = 1/(2 pi rho sigma_f) -- so resolving it needs srate >~ 2 pi sigma_f rho, a requirement that GROWS LINEARLY WITH SNR and that production does not meet. MEASURED on a 35+30 Msun SEOBNRv4 H1L1V1 injection at rho=40 (sigma_t = 61.2 us): rigidly scanning the grid phase over 2*deltaT moves the reported lnL by 1.649 / 0.385 / 0.0095 nats at srate 4096 / 8192 / 16384. Simpson makes an under-resolved peak WORSE than trapezoid, not better: (4T_h - T_2h)/3 carries the coarser T_2h and inherits its 2h alias. 'bandlimited' costs no extra likelihood evaluations and no extra precompute: kappa(t) is band-limited below Nyquist by construction and rho_sq is time-independent on this path, so the samples already computed determine the continuous integrand exactly, and one zero-padded FFT per row recovers it. Against a converged dense reference at srate 4096, rho=40: -0.007 nats, versus +0.745 for Simpson at the same grid phase. THERE IS DELIBERATELY NO RESOLUTION OPTION: the refinement factor is derived from the measured peak width and re-asserted on the refined grid. Cost scales with that factor and is paid only where the integrand actually demands it (a well-resolved peak derives a factor of 1 and costs nothing). Requires --time-marginalization --vectorized --gpu (--force-xpy is accepted), excludes --rotation-slow / --freqresponse / calibration marginalization, and is REFUSED, not ignored, if the configuration cannot honour it. Rationale, measured tables and exclusions: RIFT/likelihood/time_marginalization_quadrature.py. 'peak-local' is the same argument with the refined grid placed only where the integrand has support, because the dense rule refines the WHOLE window to a peak whose width shrinks as 1/rho -- it works hardest exactly where the peak occupies least of the domain. kappa's extrema are ENUMERATED on a small, SNR-INDEPENDENT upsample (kappa is band-limited at Nyquist, so enumerating it is not a function of SNR); an interval of a few sigma_t is built around each; overlapping intervals are MERGED into disjoint ones (without which the shared region is double-counted, measured +1.6 nats at rho~6); and each merged interval is integrated at its own derived spacing. The mass left OUTSIDE the intervals is bounded per row and CHECKED, so the truncation is not an assumption -- a row whose bound is not small enough, or whose local grid would cost more than the dense one, is given the 'bandlimited' value rather than an approximation with a caveat. Accuracy is that of 'bandlimited' by construction and is measured against it (max 1.9e-11 nats over 4000 extrinsic rows). COST: measured through this code path on CPU at n_extrinsic 4000, npts 614, it is NOT the prototype's headline figure -- that was measured with an analytic kappa in hand, where evaluating the interpolant at an arbitrary time was free, and here it is not. See RIFT/likelihood/DESIGN_time_marginalization_peak_local.md for the measured table. Same prerequisites and same exclusions as 'bandlimited', PLUS: 'peak-local' REFUSES phase marginalization. That is a deliberate scope cut -- production marginalizes over distance, not phase, and under phase marginalization the time peak's Laplace width picks up an (I1/I0)(|kappa|/D) factor that does not reduce, so the local spacing is no longer derivable from rho_sq and the curvature alone. 'bandlimited' still supports it. (Default=simpson)")
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.")
integration_params.add_option("--d-min", default=1,type=float,help="Minimum distance in volume integral. Used to SET THE PRIOR; changing this value changes the numerical answer.")
integration_params.add_option("--declination-cosine-sampler",action='store_true',help="If specified, the parameter used for declination is cos(dec), not dec")
integration_params.add_option("--inclination-cosine-sampler",action='store_true',help="If specified, the parameter used for inclination is cos(dec), not dec")
integration_params.add_option("--limit-right-ascension",default=None,help="Restrict RA sampling AND prior to 'LO,HI' [rad] (truth-centered zoom box). Narrows the extrinsic prior like --d-min/--d-max do for distance; keep the box large vs the posterior so credible regions are unaffected.  Not compatible with --internal-sky-network-coordinates (the sampled sky angles are then in a rotated frame).")
integration_params.add_option("--limit-declination",default=None,help="Restrict declination sampling AND prior to 'LO,HI' [rad].  Always given in radians of DECLINATION: with --declination-cosine-sampler the box is transformed internally to the sampled coordinate sin(dec).  Not compatible with --internal-sky-network-coordinates.")
integration_params.add_option("--limit-inclination",default=None,help="Restrict inclination sampling AND prior to 'LO,HI' [rad].  Always given in radians of INCLINATION: with --inclination-cosine-sampler the box is transformed internally to the sampled coordinate cos(iota), which reverses the limit order.")
integration_params.add_option("--limit-psi",default=None,help="Restrict polarization psi sampling AND prior to 'LO,HI' [rad].")
integration_params.add_option("--limit-distance",default=None,help="Restrict distance SAMPLING to 'LO,HI' [Mpc], WITHOUT changing the prior or its normalization.  Unlike --d-min/--d-max (which SET the prior and therefore change the numerical answer) and unlike the angular --limit-* boxes (which narrow the prior SUPPORT, so lnZ drops by the prior mass outside), this is a change of SAMPLING prior only: the distance prior keeps the normalization it has over the full [--d-min,--d-max], so the reported lnZ needs no correction and is directly comparable to a full-range run and between samplers.  Intended for high amplitude, where the distance posterior narrows as 1/rho and a box tracking it restores the resolution the quadrature was wasting -- keep the box comfortably wider than the posterior, because likelihood OUTSIDE it is simply not integrated.  WHAT 'no correction' MEANS IN PRACTICE, measured end to end rather than argued (real data, S250114ax, rho ~ 82, AV, 39 runs): the evidence the box actually TRUNCATES is bounded by the posterior mass outside it, 0.003 nats for a box holding all but 0.3 per cent of the draws -- but the lnZ difference you will OBSERVE against a full-range run is larger and of the opposite sign, +0.37 +- 0.11 nats, because it is the FULL-RANGE run's own sampling bias, which the box removes (with --no-adapt-distance, where that bias is unmistakable, the full-range run loses 4.16 nats and the box recovers 3.81).  So: comparable to the sampler's own systematic, not to machine precision, and the narrowed run is the more accurate of the two.  THE BENEFIT IS A THRESHOLD IN AMPLITUDE, NOT A GENERAL IMPROVEMENT: repeating the same measurement on S240920dw at rho 41.4 gives +0.13 +- 0.08 nats, consistent with zero, because there the full-range sampler is already healthy (n_ESS 594 vs 101 at rho 82, Pareto k-hat 0.26 vs 0.55, nothing collapsed) and the box has no bias to remove -- it even costs a little.  Use it where the full-range run is in trouble; below that it is neutral to slightly negative.  Evidence: RIFT_roboto_paper analyses/limit_distance_e2e/.  Must lie inside [--d-min,--d-max].  REFUSED (not ignored) with --distance-marginalization (no distance sampler exists: the marginal is an analytic integral over [--d-min,--d-max]), with --d-prior-redshift (the sampled coordinate is redshift, not Mpc) and with --internal-reparam-dl-incl (the sampled axis is D_eff, not d_L).")
integration_params.add_option("--internal-rotate-phase", action='store_true',help="If specified, the integration sampler uses phase_p ==phi+psi and phase_m == phi-psi as sampling coordinates, both ranging from 0 to 4 pi.  The prior is twice as large.")
integration_params.add_option("--internal-sky-network-coordinates",action='store_true',help="If specified, perform integration in sky coordinates aligned with the first two IFOs provided")
integration_params.add_option("--internal-sky-network-coordinates-raw",action='store_true',help="If specified, does not attempt to organize IFO network sensibly, uses them AS PROVIDED IN ORDER.")
integration_params.add_option("--manual-logarithm-offset",type=float,default=0,help="Target value of logarithm lnL. Integrand is reduced by exp(-manual_logarithm_offset).  Important for high-SNR sources!   Should be set dynamically")
integration_params.add_option("--auto-logarithm-offset",action='store_true',help="Use the 'guess_snr' field returned in the precompute stage to change --manual-logarithm-offset for each event.")
integration_params.add_option("--internal-use-lnL",action='store_true',help="likelihood returns lnL, and integrator integrates lnL")
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="Portfolio member sampler, one of AV / GMM / AC (adaptive_cartesian_gpu) or a discovered plugin. Repeat the option per member, or give one comma-separated list; both forms may be mixed. An unrecognized name is an error.")
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-policy knobs (only meaningful with --sampler-method portfolio).  A member
# whose balance weight drops below --portfolio-freeze-wt normally stops updating its proposal;
# these control that.  Defaults (None here) mean "use the sampler's built-in default".
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-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-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-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-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.")
# Portfolio DRAW-ALLOCATION policy (adaptive-probe): OPT-IN (default off).  Concentrates the draw
# budget on the member with the highest per-chunk n_ess, with round-robin probing.  Unbiased for
# any allocation (q_mix).  Helps on strongly-correlated targets, but the n_ess signal rewards
# self-consistency and STARVES a slow-contracting VARAHA/AV member on real high-SNR events, so it
# is not the default -- see DESIGN_portfolio_freeze_policy.md.
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-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-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-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("--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-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-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("--sampler-xpy",default=None,help="numpy|cupy  if the adaptive_cartesian_gpu sampler is active, use that.")
# Integrator warm-start / reuse (bootstrap AV, persist/reuse a trained NF flow).  All
# default off.  A warm start only affects the initial PROPOSAL, never the integral.
integration_params.add_option("--sampler-warmstart-samples",default=None,help="AV only: ASCII file (named columns) of prior extrinsic samples used to warm-start the adaptive-volume live region.  Intended for the CHERRY-PICKED-PILOT workflow: after iteration 0, run ONE ILE at the best (highest-lnL / CIP-MAP) point with --save-samples (~tens of KB for a single point), then warm-start every subsequent point from it.  Do NOT --save-samples the whole grid (disk) and do NOT pick the pilot at random (a poor fit endangers the grid) -- pick the best point.  Columns matched to the sampler's extrinsic parameters by name; the coverage-floor + inflation margins below keep a shifted peak from biasing.")
integration_params.add_option("--sampler-warmstart-cover-frac",type=float,default=0.5,help="Coverage floor for --sampler-warmstart-samples (default 0.5): fraction of full-prior coverage mixed in so a mismatched pilot degrades to cold rather than biasing.  0.5 is the MEASURED-safe floor (test_AV_warmstart_safety.py, 20 calibration seeds: max |bias| 0.164, max degradation vs cold 0.113); 0.1 is genuinely under-covered (1.1-1.7 in log bias across seeds) and raising --n-max does not rescue it, because the runs terminate on n_eff first.  Lower it only for SAME-problem reuse, where the peak is already in the seed.")
integration_params.add_option("--sampler-warmstart-inflate",type=float,default=1.5,help="Handoff safety margin for --sampler-warmstart-samples (default 1.5): widen the pilot seed about its mean to cover the peak shift between the pilot point and this 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-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("--nf-flow-load",default=None,help="NF only: load a pre-trained normalizing flow (.pt from --nf-flow-save); with --n-adapt 0 this reuses it directly (skips training), otherwise it is polished.")
integration_params.add_option("--nf-flow-save",default=None,help="NF only: after integration, serialize the trained normalizing flow (.pt) for reuse across ILE instances.")
integration_params.add_option("--sampler-sequential-warmstart",action='store_true',help="AV only: when a worker analyzes several intrinsic points (--n-events-to-analyze>1), warm-start each point's extrinsic integral from the previous point's converged high-likelihood samples.  Points are processed in their given order (NOT reordered), so a truncated/failed worker still drops a spatially-unbiased subset.  A coverage floor (see --sampler-sequential-warmstart-cover-frac) keeps a poorly-matched transfer from ever biasing the result.")
integration_params.add_option("--sampler-sequential-warmstart-cover-frac",type=float,default=0.5,help="Coverage floor for --sampler-sequential-warmstart: fraction of full-prior coverage mixed into the seed so the warm live volume always contains a cold start (a mis-matched proposal then only costs efficiency, never bias).  Default 0.5, the measured-safe floor (see --sampler-warmstart-cover-frac); 0.1 is under-covered.")
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("--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-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-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).")
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-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.")
integration_params.add_option("--internal-reparam-dl-incl",action="store_true",help="Sample the DISTANCE axis as an effective distance D_eff = d_L / A(iota), with A(iota)=sqrt(((1+cos^2 i)/2)^2 + cos^2 i) the leading (l=|m|=2) inclination amplitude.  This axis-aligns the distance<->inclination degeneracy (L depends mostly on A(iota)/d_L), decorrelating the two broad directions so the sampler wraps them efficiently.  The likelihood reconstructs physical d_L=D_eff*A(iota); the measure correction is PRIOR-AGNOSTIC -- ln p(d_L) - ln p(D_eff) + ln A(iota), using the ACTUAL --d-prior (dist_prior_pdf), so it is correct for Euclidean, cosmo, cosmo_sourceframe, pseudo_cosmo alike (normalization cancels in the ratio; reduces to +3 ln A only for Euclidean).  The physical d_L bound is enforced.  NOT compatible with --d-prior-redshift (errors out).  Estimator stays unbiased (validate vs baseline posterior).")
integration_params.add_option("--extrinsic-proposal-field",default=None,help="AV only (L3): path to a ProposalField (.npz built by util_BuildProposalField.py from a previous ILE iteration).  Each intrinsic point warm-starts its extrinsic integral from the field's nearest entry.  Cross-problem reuse, so a coverage floor + an inflation margin are applied (see the two options below); a stale/mismatched field can only cost efficiency, never bias.")
integration_params.add_option("--extrinsic-proposal-field-cover-frac",type=float,default=0.5,help="Coverage floor for --extrinsic-proposal-field handoff (default 0.5, the measured-safe floor -- see --sampler-warmstart-cover-frac; 0.1 is under-covered).")
integration_params.add_option("--extrinsic-proposal-field-inflate",type=float,default=1.5,help="Handoff safety margin for --extrinsic-proposal-field: widen the imported seed by this factor about its mean to cover the peak shift between the neighbouring intrinsic point and this one (default 1.5).")
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")
optp.add_option_group(integration_params)

#
# Add the intrinsic parameters
#
intrinsic_params = OptionGroup(optp, "Intrinsic Parameters", "Intrinsic parameters (e.g component mass) to use.")
intrinsic_params.add_option("--pin-distance-to-sim",action='store_true', help="Pin *distance* value to sim entry. Used to enable source frame reconstruction with NR.")
intrinsic_params.add_option("--mass1", type=float, help="Value of first component mass, in solar masses. Required if not providing coinc tables.")
intrinsic_params.add_option("--mass2", type=float, help="Value of second component mass, in solar masses. Required if not providing coinc tables.")
intrinsic_params.add_option("--spin1z", type=float, help="Value of first component spin (aligned with angular momentum), dimensionless.")
intrinsic_params.add_option("--spin2z", type=float, help="Value of second  component spin (aligned with angular momentum), dimensionless.")
intrinsic_params.add_option("--eff-lambda", type=float, help="Value of effective tidal parameter. Optional, ignored if not given.")
intrinsic_params.add_option("--deff-lambda", type=float, help="Value of second effective tidal parameter. Optional, ignored if not given")
intrinsic_params.add_option("--export-eos-index",action='store_true')
intrinsic_params.add_option("--export-marginal-distance-grid",action='store_true')
intrinsic_params.add_option("--export-distance-slices",type=int,default=0,help="If >0, after main extrinsic integration emit a per-event .dslice file with rows of fixed-d extrinsic-marginalized likelihoods. Total rows = --n-distance-slice-core + --n-distance-slice-wing. Requires --internal-use-lnL and no --distance-marginalization.")
intrinsic_params.add_option("--n-distance-slice-core",type=int,default=0,help="Core slices via importance-reweight on existing Omega samples (cheap). If 0 and --export-distance-slices>0, defaults to ceil(K*0.6).")
intrinsic_params.add_option("--distance-slice-all-fresh",action="store_true",default=False,help="Emit ALL K slices as FRESH fixed-d full integrations (no importance-reweight core). Placement = posterior-d quantiles. Use this when the main-loop n_eff is small (e.g. 50): the reweight core is then starved (same MC noise as the .dgrid fair-draw histogram), whereas each fresh slice is an honest Omega-only integral at fixed d. Overrides --n-distance-slice-core / --n-distance-slice-wing.")
intrinsic_params.add_option("--distance-slice-randomize",action="store_true",default=False,help="(all-fresh only) Draw the K fresh-slice distances at RANDOM posterior-d quantiles per intrinsic instead of fixed equi-probable quantiles. With K=1 this makes the single slice a fair-draw of d from THAT intrinsic's posterior, so over the intrinsic grid the slices sample (intrinsic,d) jointly -- cheap (~1 slice/intrinsic) dense coverage for a continuous AD surrogate -- rather than pinning every point to the median.")
intrinsic_params.add_option("--n-distance-slice-wing",type=int,default=0,help="Wing slices via fresh Omega-only integrations at pinned distance (covers tails ~7 nats below peak). If 0 and --export-distance-slices>0, defaults to K - core.")
intrinsic_params.add_option("--distance-slice-wing-nmax",type=int,default=20000,help="Sample budget per wing fresh integration. NOT a hard cap: AV tests its budget BEFORE drawing a whole block, so the real ceiling is ceil(nmax/chunk) blocks -- up to nearly a full --distance-slice-chunk over this value, plus a small bin-rounding remainder. At the defaults (20000 with chunk 10000) it is effectively exact; at chunk 15000 a slice draws ~30000. Keep this a whole multiple of the block size if per-slice cost matters.")
intrinsic_params.add_option("--distance-slice-wing-neff",type=int,default=30,help="n_eff target per wing fresh integration.")
intrinsic_params.add_option("--distance-slice-chunk",type=int,default=None,help="AV block size for each fresh per-slice integration. DEFAULT: inherit --n-chunk, i.e. run the slice path at the same block size as the main extrinsic loop (10000) instead of a private number; this replaces a hardcoded 2000. An inherited value below 10000 is raised to 10000 (AV's live volume only contracts and each cycle's threshold comes from nsel=min(1000,0.1*n_chunk) samples, so a smaller block makes every threshold a permanent support cut decided by too few samples; 10000 is where nsel saturates) -- this matters because the input-skymap path sets --n-chunk 500. Setting this flag explicitly overrides both, unclamped. Measured on a real event, 2000 -> 10000 takes rms(dlnL) 3.58 -> 0.35 and sigma-understatement 38.8x -> 4.0x for +9% cost, and removes the 'cap-burner' slices (3.1% of slices, 40.9% of all evaluations) that exhaust n-max without reaching the n_eff target. Larger is not uniformly better: 40000 was worse than 10000-15000 on hard targets. Must be >=1. MEMORY: these integrations are host-side EVEN ON GPU JOBS (each block's likelihood is copied back by _to_cpu and the pinned-d arrays are built with numpy), so system RAM scales with this on every run -- budget >=8 GB request_memory at 10000 for CPU exports, and re-check GPU requests rather than assuming they are immune. Interacts with --distance-slice-wing-nmax, which is a block-granular budget, not a hard cap.")
intrinsic_params.add_option("--distance-slice-skip-threshold",type=float,default=1.0,help="Absolute lnL scale: if the PEAK lnL across core slices is below this many nats, treat the event as effectively undetected and skip wing integrations. (lnL is a likelihood ratio vs noise, so this is an absolute detectability cut, not a relative-spread test.)")
intrinsic_params.add_option("--distance-slice-wing-delta-lnL",type=float,default=7.0,help="Target lnL drop below peak used to place wing slice centers: wings span from the core edge out to where the parabolic lnL(1/d) model falls this many nats below peak (default 7 ~ prior weight <1e-3 outside). Falls back to log-uniform full-range placement if the parabolic fit is degenerate.")
optp.add_option_group(intrinsic_params)


#
# Add options to integrate over intrinsic parameters.  Same conventions as util_ManualOverlapGrid.py.  
# Parameters have special names, and we adopt priors that use those names.
# NOTE: Only 'q' implemented
#
intrinsic_int_params = OptionGroup(optp, "Intrinsic integrated parameters", "Intrinsic parameters to integrate over. ONLY currently used with ROM version")
intrinsic_int_params.add_option("--parameter",action='append')
intrinsic_int_params.add_option("--parameter-range",action='append',type=str)
intrinsic_int_params.add_option("--adapt-intrinsic",action='store_true')
optp.add_option_group(intrinsic_int_params)

#
# Add the pinnable parameters
#
pinnable = OptionGroup(optp, "Pinnable Parameters", "Specifying these command line options will pin the value of that parameter to the specified value with a probability of unity.")
for pin_param in LIKELIHOOD_PINNABLE_PARAMS:
    option = "--" + pin_param.replace("_", "-")
    pinnable.add_option(option, type=float, help="Pin the value of %s." % pin_param)
optp.add_option_group(pinnable)

def _normalize_interpolate_time_argv(argv):
    out = []
    i = 0
    while i < len(argv):
        out.append(argv[i])
        if argv[i] == "--interpolate-time" and (i + 1 == len(argv) or argv[i + 1].startswith("--")):
            out.append("True")
        i += 1
    return out

opts, args = optp.parse_args(_normalize_interpolate_time_argv(sys.argv[1:]))

_response_order_active = any((opts.check_slowrot_pmax, opts.check_finite_size_Qmax,
                              opts.choose_slowrot_pmax, opts.choose_slowrot_Qmax))
if (opts.check_slowrot_pmax or opts.choose_slowrot_pmax) and not opts.rotation_slow:
    raise ValueError("pmax check/choice requires --rotation-slow")
if (opts.check_finite_size_Qmax or opts.choose_slowrot_Qmax) and not opts.freqresponse:
    raise ValueError("Qmax check/choice requires --freqresponse")
if _response_order_active and opts.response_order_snr is None:
    raise ValueError("response-order check/choice requires --response-order-snr")
if opts.check_slowrot_pmax and opts.choose_slowrot_pmax:
    raise ValueError("choose either --check-slowrot-pmax or --choose-slowrot-pmax")
if opts.check_finite_size_Qmax and opts.choose_slowrot_Qmax:
    raise ValueError("choose either --check-finite-size-Qmax or --choose-slowrot-Qmax")
if ((opts.check_slowrot_pmax or opts.choose_slowrot_pmax)
        and (opts.check_finite_size_Qmax or opts.choose_slowrot_Qmax)
        and (opts.check_slowrot_pmax or opts.check_finite_size_Qmax)
        and (opts.choose_slowrot_pmax or opts.choose_slowrot_Qmax)):
    raise ValueError("do not mix check and choose controls in one compound response scan")
if opts.response_order_lnL_tol <= 0 or opts.response_order_sky_samples < 8:
    raise ValueError("response order requires positive lnL tolerance and at least 8 sky samples")
if opts.response_order_p_reference < 0 or opts.response_order_Q_reference < 0:
    raise ValueError("response diagnostic reference orders must be nonnegative")
if (not np.isfinite(opts.response_order_max_bank_gib)
        or opts.response_order_max_bank_gib <= 0):
    raise ValueError("--response-order-max-bank-gib must be finite and positive")

def _truthy_option(value):
    if isinstance(value, bool):
        return value
    if value is None:
        return False
    return str(value).strip().lower() in ("1", "true", "t", "yes", "y", "on")

_TI_LEGACY_BOOLEAN = ("1", "true", "t", "yes", "y", "on",
                      "0", "false", "f", "no", "n", "off", "none")
# WAS THE STENCIL ASKED FOR, OR INHERITED?  Every guard below distinguishes the two, so this
# must be decided on IDENTITY (`is None`), before any string coercion: str(None) is 'none',
# which is a legal explicit spelling meaning 'nearest', so a string test cannot tell an
# omitted flag from "--interpolate-time none" and would silently disarm the explicit path.
opts._interp_time_from_default = opts.interpolate_time is None
if opts._interp_time_from_default:
    # DEFAULT CHANGED 2026-09-02: 'nearest' -> time_interp_choice.TIME_INTERP_DEFAULT (issue
    # #233).  PROVISIONAL until the guards further down have run -- a default may be downgraded
    # back to 'nearest' where an explicit request would be refused.
    opts._noloop_time_interp = TIME_INTERP_DEFAULT
else:
    _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)))
opts.q_time_pregrid_factor = validate_q_time_pregrid_factor(opts.q_time_pregrid_factor)
if opts.q_time_pregrid_factor == 8:
    if not opts.vectorized or opts.rotation_slow or opts.freqresponse or opts.calibration_envelope_directory:
        raise NotImplementedError(
            "--q-time-pregrid-factor 8 is currently restricted to ordinary vectorized "
            "NoLoop without rotation, frequency-dependent response, or calibration marginalization")
    if not opts._interp_time_from_default and opts._noloop_time_interp != "cubic":
        raise ValueError(
            "--q-time-pregrid-factor 8 uses four-tap cubic interpolation; remove the "
            "explicit --interpolate-time option or set it to cubic")
    opts._q_pregrid_fallback_interp = opts._noloop_time_interp
    opts._noloop_time_interp = "cubic"
    print(" Q_lm pregrid: ENABLED factor=8 boundary=even-reflection arrival_stencil=cubic "
          "integration_grid=unchanged")
# 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")
from RIFT.likelihood.time_posterior import resolve_time_posterior_export_mode
# THE DEFAULT STENCIL MUST NOT SILENTLY CHANGE THE EXPORT.  resolve_time_posterior_export_mode
# maps `auto` (the --time-posterior-export default) to 'continuous' for ANY stencil other than
# 'nearest', so once the stencil default stopped being 'nearest' the same one-line change would
# also have flipped the fair-draw time export of every --resample-time-marginalization run.  That
# is not a free relabelling: continuous export re-evaluates the whole likelihood on a >=4x denser
# time grid and can raise MemoryError from validate_time_posterior_working_set, so it would have
# turned working runs into failing ones and changed t_ref in the output of the rest.
#
# The export therefore keys on an EXPLICIT stencil only.  Asking for a stencil still opts you into
# the better export, and '--time-posterior-export continuous' still works on its own; inheriting
# the default gets the historical 'grid' export, bit-for-bit.
_ti_for_export = 'nearest' if opts._interp_time_from_default else opts._noloop_time_interp
opts._time_posterior_export = resolve_time_posterior_export_mode(
    opts.time_posterior_export, _ti_for_export,
    continuous_available=not (opts.rotation_slow or opts.freqresponse))
# 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
    # NoLoop-with-rotation reuses the baseline fused Q_inner_product kernel per elementary
    # template on GPU (same memory footprint as the baseline).
    if not opts.vectorized:
        raise ValueError("--rotation-slow requires --vectorized")
    # The calibration/glitch-marginalization exclusion used to be a guard here, keyed on an
    # option that does not exist, so it never fired.  It now lives in the central gate below
    # (RIFT/calmarg/option_compat.py), keyed on --calibration-envelope-directory and run once
    # opts.gpu is final.  History: DESIGN_calmarg_in_loop.md, "Option-compatibility gate".
    if getattr(opts, 'distance_marginalization', False) or getattr(opts, 'phase_marginalization', False):
        raise ValueError("--rotation-slow does not yet support distance/phase marginalization")

if opts.freqresponse:
    # Path D finite-size response: wired into BOTH the CPU-vectorized and GPU (xpy) branches;
    # the NoLoop reuses the baseline fused Q_inner_product kernel per basis weight p on GPU
    # (same memory footprint as the baseline), mirroring --rotation-slow.
    if not opts.vectorized:
        raise ValueError("--freqresponse requires --vectorized")
    # Same never-firing calibration guard as --rotation-slow above, with the same history;
    # superseded by the central gate below.  See RIFT/calmarg/option_compat.py.
    if getattr(opts, 'distance_marginalization', False) or getattr(opts, 'phase_marginalization', False):
        raise ValueError("--freqresponse does not yet support distance/phase marginalization")

# cosmo d prior tools for interpolation:  not used normally, but set if needed
final_scipy_interpolate=None
if 'cosmo' in opts.d_prior:
  if not(cupy_success):
    import scipy.interpolate
    final_scipy_interpolate = scipy.interpolate
  else:
    import cupyx.scipy.interpolate
    final_scipy_interpolate = cupyx.scipy.interpolate
    


# good enough file: terminate always with success if present, don't try any more work
if opts.check_good_enough:
  fname = 'ile_good_enough'
  if os.path.isfile(fname):
#    dat = np.loadtxt(fname,dtype=str)
    if os.path.getsize(fname)  > 0:
      print(" Good enough file valid: terminating ILE")
      sys.exit(0)
    else:
      print(" Good enough file ZERO LENGTH, continuing")


# Parse tapering request, if present.
taper_default =lalsimutils.lsu_TAPER_NONE
if opts.internal_waveform_taper and 'TAPER' in opts.internal_waveform_taper:
  if hasattr(lalsimutils.lalsim, opts.internal_waveform_taper):
    taper_default = getattr(lalsimutils.lalsim, opts.internal_waveform_taper)

#
# Failure modes
#
ok_lnL_methods = ['GMM', 'adaptive_cartesian', 'adaptive_cartesian_gpu','AV','portfolio']
if opts.internal_use_lnL and not(opts.sampler_method  in ok_lnL_methods ):
  print(" OPTION MISMATCH : --internal-use-lnL not compatible with", opts.sampler_method, " can only use ", ok_lnL_methods)
  sys.exit(99)
# if we are on a GPU and using a GPU-accelerated likelihood, don't send the likelihood data back from the GPU needlessly
if cupy_success and opts.gpu and opts.sampler_method == 'adaptive_cartesian_gpu':
  identity_convert_lnL  = lambda x:x


# FACTORED LIKELIHOOD LATER, SO WE CAN SET OPTIONS TO CONTROL IMPORT OF SUPERFLUOUS PACKAGES
if not(opts.use_gwsignal):
  os.environ['RIFT_NO_GWSIGNAL'] = 'True'
import RIFT.likelihood.factored_likelihood as factored_likelihood
import RIFT.likelihood.factored_likelihood_with_rotation as factored_likelihood_with_rotation
import RIFT.likelihood.factored_likelihood_freqresponse as factored_likelihood_freqresponse
import RIFT.likelihood.factored_likelihood_rotating_freqresponse as factored_likelihood_rotating_freqresponse

if opts.use_gwsignal and not(factored_likelihood.has_GWS):
  print(" HARD FAILURE: this node could not import gwsignal ! ")
  sys.exit(98)


if opts.resample_time_marginalization:
  import scipy.special
if opts.resample_time_marginalization and not(opts.fairdraw_extrinsic_output):
  raise Exception(" Resampled time output requires --fairdraw-extrinsic-output ")
if opts.resample_time_marginalization and opts.distance_marginalization:
  raise ValueError(
      "--resample-time-marginalization does not support distance/phase "
      "marginalization; refusing before the expensive integration")

# Fairdraw is NOT YET IMPLEMENTED for these other integrators!
#if opts.fairdraw_extrinsic_output:
#  if opts.sampler_method == 'adaptive_cartesian_gpu':
#    raise Exception(" Fairdraw not available  for this sampler")
#  if opts.sampler_method == 'GMM':
#    raise Exception(" Fairdraw not available  for this sampler")


def _reparam_A_of_incl(incl_rad, xpy=numpy):
    """Leading (l=|m|=2) inclination amplitude A(iota)=sqrt(((1+cos^2 i)/2)^2 + cos^2 i).
    Used by --internal-reparam-dl-incl to map the effective distance D_eff <-> physical d_L
    (d_L = D_eff * A(iota)).  A in [0.5 (edge-on), sqrt(2) (face-on)]."""
    ci = xpy.cos(incl_rad)
    return xpy.sqrt(((1.0 + ci*ci)/2.0)**2 + ci*ci)

_REPARAM_A_MIN = 0.5           # A(iota=pi/2), edge-on
_REPARAM_A_MAX = numpy.sqrt(2.0)  # A(iota=0), face-on
_REPARAM_LNF = 0.0            # ln(physical-range prior mass fraction); set at setup for --internal-reparam-dl-incl

supplemental_ln_likelihood= None
supplemental_ln_likelhood_prep=None
supplemental_ln_likelhood_parsed_ini=None
# Supplemental likelihood factor. Must have identical call sequence to 'likelihood_function'. Called with identical raw inputs (including cosines/etc)
if opts.supplementary_likelihood_factor_code and opts.supplementary_likelihood_factor_function:
  print(" EXTERNAL SUPPLEMENTARY LIKELIHOOD FACTOR : {}.{} ".format(opts.supplementary_likelihood_factor_code,opts.supplementary_likelihood_factor_function))
  __import__(opts.supplementary_likelihood_factor_code)
  external_likelihood_module = sys.modules[opts.supplementary_likelihood_factor_code]
  supplemental_ln_likelihood = getattr(external_likelihood_module,opts.supplementary_likelihood_factor_function)
  name_prep = "prepare_"+opts.supplementary_likelihood_factor_function
  if hasattr(external_likelihood_module,name_prep):
    supplemental_ln_likelhood_prep=getattr(external_likelihood_module,name_prep)
    # Check for and load in ini file associated with external library
    if opts.supplementary_likelihood_factor_ini:
      import configparser as ConfigParser
      config = ConfigParser.ConfigParser()
      config.optionxform=str # force preserve case! 
      config.read(opts.supplementary_likelihood_factor_ini)
      supplemental_ln_likelhood_parsed_ini=config

if opts.distance_marginalization:
  lookup_table = np.load(opts.distance_marginalization_lookup_table)
  opts.maximize_only  = False

if opts.gpu is None:
  opts.gpu = False  # None can be treated differently from false?

if opts.gpu and opts.force_gpu_only and not cupy_success:
  print("GPU requested but no GPU/cupy found: Hard fail by request")
  sys.exit(35)  # unique code to make resuming due to this error easier to identify

if opts.gpu and xpy_default is numpy:
    print( " Override --gpu  (not available);  use --force-xpy to require the identical code path is used (with xpy =[np|cupy]")
    opts.gpu=False
    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
# THE DEFAULT IS DOWNGRADED WHERE A REQUEST IS REFUSED, and the two must not be conflated.
#
# The refusal below is the right answer to "I asked for sinc and this configuration will quietly
# run something else": it protects a comparison campaign from being run against a flag that did
# nothing.  It is the WRONG answer to an inherited default -- as a default it would convert every
# configuration in the list above from working to a startup ValueError, with no command line
# changed anywhere, which is a far larger blast radius than the accuracy the default buys.
#
# So: an EXPLICIT --interpolate-time is refused exactly as before (no behaviour change at all for
# anyone who passes the flag), and a DEFAULT falls back to 'nearest' -- the historical value, so
# the fallback is a no-op relative to today -- with the reason printed.  The fallback is announced
# rather than silent because a stencil that is not running is the one thing the log has to say.
def fused_calmarg_in_use(opts, calibration_marginalization=None):
    """WILL A FUSED CALIBRATION KERNEL ACTUALLY RUN?  ONE definition, two call sites.

    THIS FUNCTION EXISTS BECAUSE THE CONDITION WAS RE-DERIVED AND DRIFTED, TWICE IN A DAY, IN THE
    SAME DIRECTION, WHILE A REVIEWER WAS LOOKING AT IT.  The stencil guard below needs the answer
    at startup; `use_fused_calmarg` needs it again at dispatch time.  Written as two expressions
    they went `flag` -> `flag and envelope` -> `flag and envelope and path`, each round adding a
    conjunct the other site already had in effect.  The drift is INVISIBLE by construction: an
    over-broad predicate downgrades the inherited stencil to 'nearest', which is exactly the
    historical behaviour, so nothing fails and no log line looks wrong.  Two copies of a condition
    drift; one does not.  (Four P2 findings on PR #237, of which three were this predicate.)

    A fused kernel runs only where ALL of these hold.  Each is a REACHABILITY fact about the
    driver, verified against the call sites, not a guess:

      * calibration marginalization is configured -- otherwise n_cal stays 1;
      * --calibration-fused-kernel was passed -- it is opt-in;
      * --calibration-n-realizations > 1 -- factored_likelihood returns from its `n_cal == 1`
        branch before `cal_method` is read at all, so with one realization 'fused' is inert;
      * NOT --rotation-slow and NOT --freqresponse -- both REPLACE the likelihood.  The
        non-distmarg dispatch puts the only `cal_method='fused'` call site in the `else` of
        `if opts.rotation_slow: ... elif opts.freqresponse: ...`, and the two distmarg call
        sites are unreachable because both options refuse distance marginalization outright at
        startup;
      * NOT --calibration-dump-responsibilities -- the pilot evaluates with an explicit
        `cal_method='loop'` and `return_cal_components=True` (which the library also refuses to
        fuse) and then RETURNS before the production integration is ever built.

    The stencil is deliberately NOT a conjunct: whether the run is on 'nearest' is the question
    the callers are answering, not part of "could a kernel run here at all".

    WHAT THE EARLY CALL CANNOT SEE, stated rather than left to be rediscovered.  At startup
    `calibration_marginalization` does not exist yet -- it is set several hundred lines later, at
    the `if opts.calibration_envelope_directory:` block, from a False initial value.  Passing
    None here substitutes `bool(opts.calibration_envelope_directory)`, which is that block's own
    condition, so it is a restatement and not an approximation.  It is also CHECKED: the late
    call site compares its result against the early one and refuses if they disagree, so a change
    to how `calibration_marginalization` is derived fails loudly instead of silently re-opening
    the same drift.  Nothing else in the predicate is unavailable at startup -- every other term
    is a command-line option.
    """
    if calibration_marginalization is None:
        calibration_marginalization = bool(opts.calibration_envelope_directory)
    return (bool(calibration_marginalization)
            and bool(opts.calibration_fused_kernel)
            and int(getattr(opts, 'calibration_n_realizations', 0) or 0) > 1
            and not bool(opts.rotation_slow)
            and not bool(opts.freqresponse)
            and not bool(opts.calibration_dump_responsibilities))


_fused_calmarg_would_run = fused_calmarg_in_use(opts)
# (reason, remedy) pairs, NOT bare reasons.  The two downgrades have DIFFERENT remedies and one
# shared sentence would have to lie about one of them: a prerequisite downgrade becomes a REFUSAL
# if you name the stencil explicitly, while the fused-kernel downgrade does NOT -- an explicit
# stencil there is ACCEPTED and the fused kernel is dropped with a notice.  The single message
# used until 2026-09-03 told everyone they would be refused, which was wrong for the second case.
# Second P2 review finding on PR #237, and the reason these are printed one line per reason.
_stencil_downgrades = []
if opts._noloop_time_interp != 'nearest' and not _stencil_is_honoured:
    _stencil_downgrades.append((
        "this configuration cannot honour a sub-sample stencil: missing %s"
        % ", ".join(_stencil_missing),
        "add the missing option(s) -- --gpu accepts --force-xpy if no device is present -- or "
        "pass '--interpolate-time %s' explicitly to be REFUSED rather than downgraded"
        % TIME_INTERP_DEFAULT))
# THE FUSED CALIBRATION KERNEL IMPLEMENTS 'nearest' ONLY, deliberately (see
# DESIGN_q_window_stencil.md 9).  The three NoLoop call sites already fall back to cal_method
# ='loop' when the stencil is not 'nearest', and the distmarg sites additionally drop the
# cal_distmarg table -- so a non-'nearest' DEFAULT would silently move every
# --calibration-fused-kernel run off the kernel it explicitly asked for, changing both its cost
# and its distance-marginalization path.  An explicit stencil still does that (unchanged, and the
# user named both flags); the default stays out of it.
if (opts._noloop_time_interp != 'nearest' and _fused_calmarg_would_run
        and not _stencil_downgrades):
    _stencil_downgrades.append((
        "--calibration-fused-kernel selects a fused kernel that implements 'nearest' only",
        "pass '--interpolate-time %s' explicitly to KEEP the stencil and give up the fused "
        "kernel -- that combination is ACCEPTED, not refused: calibration marginalization runs "
        "the 'loop' method and the driver says so -- or pass '--interpolate-time nearest' to "
        "make the current behaviour explicit" % TIME_INTERP_DEFAULT))
if _stencil_downgrades and opts._interp_time_from_default:
    for _dg_reason, _dg_remedy in _stencil_downgrades:
        print(" Q_lm stencil DEFAULT %r NOT APPLIED -- %s. Falling back to 'nearest' (the "
              "pre-2026-09-02 default), so this run is unchanged. To change that: %s."
              % (opts._noloop_time_interp, _dg_reason, _dg_remedy))
    opts._noloop_time_interp = 'nearest'
    # Both of these were derived from the provisional default and must follow it down.
    # The FIRST is load-bearing and is NOT redundant with the stencil reset above: it is the
    # plain boolean handed to FactoredLogLikelihoodTimeMarginalized(..., interpolate=...) on the
    # NON-VECTORIZED path (:3645, :4002), which is the likelihood that actually runs whenever a
    # downgrade is triggered by a missing --vectorized.  Without it an omitted --interpolate-time
    # would switch every such run onto the legacy path's unrelated cubic interpolation.  Pinned by
    # test_batchmode_stencil_default.test_the_downgrade_also_takes_the_legacy_scalar_path_down_with_it.
    # The SECOND is BELT-AND-BRACES, not a guard: this block only runs when the stencil came from
    # the default, and in that case _ti_for_export was already 'nearest', so the recomputed value
    # is always the value already stored.  Kept so the two derived quantities are reset in one
    # place if _ti_for_export's rule ever changes; no test can distinguish it (verified by
    # mutation, 2026-09-03 -- deleting it leaves the whole gate green).
    opts._legacy_interpolate_time = False
    opts._time_posterior_export = resolve_time_posterior_export_mode(
        opts.time_posterior_export, 'nearest',
        continuous_available=not (opts.rotation_slow or opts.freqresponse))
if opts._noloop_time_interp != 'nearest' and _fused_calmarg_would_run:
    # SAY SO.  This combination is not refused -- the user named both flags and the stencil is
    # the one that is honoured -- but until now the loss of the fused kernel was silent at all
    # three call sites, which contradicts this option's own "REFUSED, not ignored" promise.
    # Gated on _fused_calmarg_would_run rather than on the flag, for the same reason as the
    # downgrade above: with no calibration envelope there is no fused kernel to lose, and a
    # "NOT USED" notice about a kernel that was never going to run is noise that trains readers
    # to ignore the line.
    print(" --calibration-fused-kernel: NOT USED. The fused calibration kernels implement the "
          "'nearest' stencil only (DESIGN_q_window_stencil.md 9), and --interpolate-time %r is "
          "in force, so calibration marginalization runs the 'loop' method instead (and the "
          "distmarg variants drop the cal_distmarg table). Pass '--interpolate-time nearest' to "
          "keep the fused kernel." % (opts._noloop_time_interp,))
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)))
if (opts.resample_time_marginalization and
        opts._time_posterior_export == "continuous" and
        (not opts.gpu or opts.rotation_slow or opts.freqresponse)):
    raise NotImplementedError(
        "continuous time-posterior export is not implemented for "
        "the active likelihood path: it requires the maintained NoLoop "
        "GPU/--force-xpy evaluator without --rotation-slow/--freqresponse, so "
        "the exported draw uses the same likelihood as integration; use "
        "--time-posterior-export grid")
# --time-marginalization-quadrature: same refuse-don't-ignore discipline as the stencil guard
# above, and for the same reason -- an accuracy option that silently does nothing is worse than
# one that is unavailable, because a comparison campaign can be run against it and believed.
opts._time_quadrature = str(opts.time_marginalization_quadrature).strip().lower()
factored_likelihood.time_quadrature_module.validate_time_quadrature(opts._time_quadrature)
_tq_prereqs = (
    ('--time-marginalization', bool(opts.time_marginalization)),
    ('--vectorized', bool(opts.vectorized)),
    ('--gpu (accepts --force-xpy)', bool(opts.gpu)),
    ('not --rotation-slow (time-DEPENDENT rho_sq: the band-limited argument does not hold)',
     not bool(opts.rotation_slow)),
    ('not --freqresponse (separate likelihood, not audited for this)',
     not bool(opts.freqresponse)),
    ('no calibration marginalization (--calibration-envelope-directory)',
     not bool(opts.calibration_envelope_directory)),
)
# Phase marginalization is refused by 'peak-local' ONLY -- 'bandlimited' supports it
# and must not regress.  It is not a plain CLI boolean: it is a property of the
# distance-marginalization lookup table, which is already loaded above, so the check
# has to read it from there.  Checking it HERE, at startup, is the point: the
# likelihood also refuses it, but by then the run is under way.
_tq_phase_marg = bool(opts.distance_marginalization) and bool(
    lookup_table["phase_marginalization"])
if opts._time_quadrature == 'peak-local' and _tq_phase_marg:
    _tq_prereqs = _tq_prereqs + ((
        "not phase marginalization (the peak-local width picks up an (I1/I0)(|kappa|/D) "
        "factor that does not reduce; use --time-marginalization-quadrature bandlimited, "
        "which supports it)", False),)
_tq_missing = [name for name, ok in _tq_prereqs if not ok]
if opts._time_quadrature != 'simpson' and _tq_missing:
    raise ValueError(
        "--time-marginalization-quadrature %r was requested, but this configuration cannot "
        "honour it: %s. Refusing rather than running the historical Simpson quadrature while "
        "reporting that you asked for something else."
        % (opts._time_quadrature, "; ".join(_tq_missing)))
# --psi-marginalization: analytic polarization-angle marginalization, reachable only on the
# legacy SCALAR likelihood path (factored_likelihood.NetworkLogLikelihoodPolarizationMarginalized
# has no vectorized/GPU/NoLoop counterpart) and only alone -- it marginalizes psi ALONE, with no
# joint sum over time, so it cannot stand in for --time-marginalization's likelihood.  Refuse
# rather than silently ignore, same discipline as --time-marginalization-quadrature above.
if opts.psi_marginalization:
    _psi_marg_prereqs = (
        ('not --time-marginalization (the analytic psi marginal has no joint time-sum form)',
         not bool(opts.time_marginalization)),
        ('not --vectorized (the analytic marginal exists only on the legacy scalar path)',
         not bool(opts.vectorized)),
        ('not --gpu (same restriction: scalar path only)', not bool(opts.gpu)),
        ('not --distance-marginalization (the scalar path this needs does not implement the '
         'distance lookup table)', not bool(opts.distance_marginalization)),
        ('not --rotation-slow (separate likelihood, not audited for this)',
         not bool(opts.rotation_slow)),
        ('not --freqresponse (separate likelihood, not audited for this)',
         not bool(opts.freqresponse)),
        ('no calibration marginalization (--calibration-envelope-directory)',
         not bool(opts.calibration_envelope_directory)),
        ('default --interpolate-time (the scalar path always evaluates rholm(t) through its '
         'own interpolator, independent of the sinc/cubic/nearest NoLoop stencil choice, so an '
         'explicit request here would be silently ignored)', opts.interpolate_time is None),
        ('not --sampler-method GMM/portfolio (their adaptive phi/psi pairing indexes "psi" '
         'directly in sampler.params, which this option removes -- ValueError at sampler setup, '
         'not audited as a combination)',
         opts.sampler_method not in ('GMM', 'portfolio')),
        ('not --internal-rotate-phase (its phi_orb/psi joint reparam reads a per-sample psi '
         'that no longer exists; the exported "psi"/"polarization" columns would be a silently '
         'wrong reconstruction rather than the fiducial placeholder this option writes)',
         not bool(opts.internal_rotate_phase)),
        ('not --limit-psi (there is no psi sampler left to apply the box to; the box would be '
         'silently ignored)', not bool(opts.limit_psi)),
        ('not --zero-likelihood (its debug likelihood keys results off a "psi" kwarg that this '
         'option removes from the call signature)', not bool(opts.zero_likelihood)),
    )
    _psi_marg_missing = [name for name, ok in _psi_marg_prereqs if not ok]
    if _psi_marg_missing:
        raise ValueError(
            "--psi-marginalization was requested, but this configuration cannot honour it: %s. "
            "Refusing rather than silently running the ordinary sampled-psi likelihood while "
            "reporting that psi was analytically marginalized." % "; ".join(_psi_marg_missing))
    print(" Polarization angle: ANALYTICALLY MARGINALIZED (--psi-marginalization). psi is "
          "removed from the sampled extrinsic parameters; "
          "NetworkLogLikelihoodPolarizationMarginalized replaces it with an exact quadrature "
          "over its uniform [0, pi) prior at each extrinsic point.")
if opts._time_quadrature == 'bandlimited':
    if opts.time_posterior_export == 'grid':
        raise ValueError(
            "--time-marginalization-quadrature bandlimited mandates a continuous "
            "conditional-posterior time draw; --time-posterior-export grid would "
            "discard the sub-sample result")
    if opts.srate_resample_time_marginalization is not None:
        raise ValueError(
            "--time-marginalization-quadrature bandlimited derives its own time "
            "resolution and exports a continuous draw; drop the conflicting fixed "
            "--srate-resample-time-marginalization lattice")
    opts._time_posterior_export = 'continuous'
if opts._time_quadrature == 'peak-local' and opts.resample_time_marginalization:
    # REFUSED, for the same refuse-don't-ignore reason as everything else on this path, and
    # this one is easy to miss because BOTH available answers are silently wrong.
    #
    # 'grid' export draws `t_ref` from the original coarse `lnLt` bins.  peak-local exists to
    # resolve a peak whose width is far below that spacing, so the integral would be
    # sub-sample accurate and the exported time would be quantised to the very grid the option
    # was introduced to escape -- the resolution is computed and then discarded.  With
    # --interpolate-time nearest, `auto` resolves to 'grid'; since 2026-09-02 the stencil
    # default is TIME_INTERP_DEFAULT, but a DEFAULT-derived stencil is deliberately not fed to
    # the export resolver (see _ti_for_export above), so 'grid' is still the DEFAULT outcome and
    # this refusal is still the one a default configuration meets.
    #
    # 'continuous' export is not available either: it needs `return_time_draw`, which requires
    # a validated dense reconstruction over the whole window.  peak-local by construction never
    # forms one -- it evaluates only near the peaks -- and the library refuses the combination
    # for exactly that reason.
    #
    # So there is nothing correct to do here, and the honest move is to say so rather than pick
    # the quieter of two wrong answers.  'bandlimited' serves this and is one flag away.
    raise ValueError(
        "--time-marginalization-quadrature peak-local does not support "
        "--resample-time-marginalization.  A 'grid' export would draw t_ref from the coarse "
        "lnLt bins, discarding the sub-sample resolution this quadrature exists to recover, "
        "and a 'continuous' export needs the dense reconstruction over the whole window that "
        "peak-local deliberately never forms.  Use "
        "--time-marginalization-quadrature bandlimited, which exports a validated continuous "
        "draw, or drop --resample-time-marginalization.")
# One assignment, inherited by every DiscreteFactoredLogLikelihoodViaArrayVectorNoLoop call site.
factored_likelihood.TIME_QUADRATURE_DEFAULT = opts._time_quadrature
# Announce the value READ BACK OUT of the module, not the one parsed from the
# command line.  Those are the same string only if the assignment above actually
# happened, and a banner built from `opts` reports what was ASKED FOR rather than
# what is in force -- so deleting the assignment leaves the flag inert while the
# banner still says it is honoured.  Reading back is what makes the printed line,
# and the tests that assert on it, load-bearing.
print(" Time-marginalization quadrature: {} (from --time-marginalization-quadrature {!r}); "
      "honoured by this configuration: {}".format(
          factored_likelihood.TIME_QUADRATURE_DEFAULT,
          opts.time_marginalization_quadrature, not _tq_missing))

print(" Q_lm sub-sample time stencil: {} (from --interpolate-time {}); honoured by this "
      "configuration: {} [time_marginalization={} vectorized={} gpu={} rotation_slow={} "
      "freqresponse={}]; legacy scalar path interpolate={}".format(
          opts._noloop_time_interp,
          ("DEFAULT %r" % TIME_INTERP_DEFAULT if opts._interp_time_from_default
           else repr(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))
# THE BAND-LIMITED QUADRATURES WERE MEASURED AGAINST 'nearest', WHICH IS NO LONGER THE DEFAULT.
#
# 'bandlimited' reconstructs the integrand THE CODE ACTUALLY FORMS.  That is the true kappa(t)
# only for the 'nearest' gather; with 'cubic' or 'sinc' the gathered values are a fixed FIR
# filter applied to Q, still band-limited, so the reconstruction stays exact -- but exact for the
# FILTERED function, and the stencil's own bias then dominates.  Measured at srate 4096, peak
# lnL ~5300 (time_marginalization_quadrature.py): with 'nearest' this path is +0.0002 nats against
# an analytic truth where Simpson is -521; with 'sinc' it is -2.29 where Simpson is +1.28, and
# over a scan of seeds and grid phases Simpson wins about half the cases.  'peak-local' inherits
# this: its accuracy is DEFINED against 'bandlimited' (max 1.9e-11 nats), so a stencil bias in
# the reference is a stencil bias in it.
#
# AND THE PAIRING IS NOT RARE -- IT IS UNIVERSAL.  The quadrature's prerequisites
# (--time-marginalization --vectorized --gpu) are a strict SUBSET of the stencil's honoured set,
# so EVERY run that opts into a band-limited quadrature without naming a stencil now gets the
# default one.  An explicit accuracy option must not be moved into an unvalidated regime in
# silence: same discipline as the --calibration-fused-kernel notice above.  NOT a downgrade --
# the quadrature is not wrong, its ADVANTAGE is unestablished here -- and not a refusal, because
# either stencil is a legitimate choice.  Third P2 review finding on PR #237;
# DESIGN_q_window_stencil.md 9.6.4 called for this notice and records the underlying measurement
# as still open.
if (opts._time_quadrature in ('bandlimited', 'peak-local')
        and opts._noloop_time_interp != 'nearest'):
    print(" --time-marginalization-quadrature %s: ADVANTAGE NOT ESTABLISHED with the "
          "%s Q_lm stencil %r. Its measured accuracy (+0.0002 nats against an analytic truth "
          "where Simpson is -521) is for 'nearest'; with 'sinc' the same comparison is -2.29 "
          "nats where Simpson is +1.28, and Simpson wins about half a scan of seeds and grid "
          "phases -- once a stencil is in force its own bias dominates the quadrature error. "
          "The run is not wrong and nothing is being downgraded; the PAIRING is unmeasured "
          "(RIFT/likelihood/DESIGN_q_window_stencil.md 9.6.4, open item). Pass "
          "'--interpolate-time nearest' to reproduce the regime these numbers were measured in."
          % (opts._time_quadrature,
             "DEFAULT" if opts._interp_time_from_default else "explicitly requested",
             opts._noloop_time_interp))
if opts.resample_time_marginalization:
    print(" Time-posterior export: {} (from --time-posterior-export {!r})".format(
          opts._time_posterior_export, opts.time_posterior_export))
    if (opts._time_posterior_export == "continuous" and
            opts.srate_resample_time_marginalization):
        print(" Time-posterior export: continuous draws supersede "
              "--srate-resample-time-marginalization; use "
              "--time-posterior-export grid for the requested lattice.")

# CALIBRATION-MARGINALIZATION OPTION COMPATIBILITY.  Same refuse-don't-ignore discipline as the
# stencil and quadrature gates above, applied to the one option that switches in-loop calibration
# marginalization on (--calibration-envelope-directory) and to the opt-ins that are read only
# under it.  Placed HERE because opts.gpu is final only after the cupy availability downgrade
# above, and a `--gpu` that has been downgraded to numpy is one of the configurations that drops
# calibration on the floor.  Nothing expensive has run yet: no frames, no PSDs, no precompute.
#
# This gate only REFUSES.  It never changes what an accepted configuration computes.
import RIFT.calmarg.option_compat as calibration_option_compat
calibration_option_compat.refuse_incompatible_calibration_options(opts)


manual_avoid_overflow_logarithm=opts.manual_logarithm_offset
manual_avoid_overflow_logarithm_default =  manual_avoid_overflow_logarithm

deltaT = None
deltaT_internal=None
fSample= opts.srate # change sampling rate
if not(fSample is None):
  deltaT =1./fSample
if not (opts.srate_internal is None):
  deltaT_internal = 1./opts.srate_internal


# Load in restricted mode set, if available
restricted_mode_list=None
if not(opts.restricted_mode_list_file is None):
    modes =numpy.loadtxt(opts.restricted_mode_list_file,dtype=int) # columns are l m.  Must contain all. Only integers obviously
    restricted_mode_list = [ (l,m) for l,m in modes]
    print( " RESTRICTED MODE LIST target :", restricted_mode_list)

intrinsic_param_names = opts.parameter
valid_intrinsic_param_names = ['q']
if intrinsic_param_names:
 for param in intrinsic_param_names:
    
    # Check if in the valid list
    if not(param in valid_intrinsic_param_names):
            print( ' Invalid param ', param, ' not in ', valid_intrinsic_param_names)
            sys.exit(1)
    param_ranges = []
    if len(intrinsic_param_names) == len(opts.parameter_range):
        param_ranges = numpy.array(map(eval, opts.parameter_range))
        # Rescale mass-dependent ranges to SI units
        for p in ['mc', 'm1', 'm2', 'mtot']:
          if p in intrinsic_param_names:
            indx = intrinsic_param_names.index(p)
            param_ranges[indx]= numpy.array(param_ranges[indx])* lal.MSUN_SI



# Check both or neither of --data-start/end-time given
if opts.data_start_time is None and opts.data_end_time is not None:
    raise ValueError("You must provide both or neither of --data-start-time and --data-end-time.")
if opts.data_end_time is None and opts.data_start_time is not None:
    raise ValueError("You must provide both or neither of --data-start-time and --data-end-time.")

#
# Import NR grid
#
NR_template_group=None
NR_template_param=None
if opts.nr_group and opts.nr_param:
    import NRWaveformCatalogManager3 as nrwf
    NR_template_group = opts.nr_group
    if nrwf.internal_ParametersAreExpressions[NR_template_group]:
        NR_template_param = eval(opts.nr_param)
    else:
        NR_template_param = opts.nr_param


#
# Hardcoded variables
#
template_min_freq = opts.fmin_template # minimum frequency of template
#t_ref_wind = 50e-3 # Interpolate in a window +/- this width about event time. 
t_ref_wind = opts.data_integration_window_half
T_safety = 2. # Safety buffer (in sec) for wraparound corruption

#
# Inverse spectrum truncation control
#
T_spec = opts.inv_spec_trunc_time
if T_spec == 0.: # Do not do inverse spectrum truncation
    inv_spec_trunc_Q = False
    T_safety += 8. # Add a bit more safety buffer in this case
else:
    inv_spec_trunc_Q = True

#
# Integrator options
#
n_max = opts.n_max # Max number of extrinsic points to evaluate at
n_eff = opts.n_eff # Effective number of points evaluated

#
# Initialize the RNG, if needed
#
# 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:
    from RIFT.integrators.seeding import seed_everything
    seed_everything(opts.seed)


if opts.event_time is not None:
    event_time = glue.lal.LIGOTimeGPS(opts.event_time)
    print( "Event time from command line: %s" % str(event_time))
else:
    print( " Error! ")
    sys.exit(1)

#
# Template descriptors
#

fiducial_epoch = lal.LIGOTimeGPS()
# Keep the exact pair as well as the historical float view.  A float64 near a
# current GPS epoch has a 0.12--0.24 microsecond spacing, coarser than some of the
# derived band-limited grids; reconstructing XML nanoseconds from that float
# would silently throw away the sub-sample draw at the final serialization step.
fiducial_epoch_seconds = int(event_time.seconds)
fiducial_epoch_nanoseconds = int(event_time.nanoseconds)
fiducial_epoch = fiducial_epoch_seconds + 1e-9*fiducial_epoch_nanoseconds   # compatibility view

# Struct to hold template parameters
P_list = None
P=None # force allocation so I can use the preferred event later
grid_in = None
if opts.sim_xml:
    print( "====Loading injection XML:", opts.sim_xml, opts.event, " =======")
    P_list = lalsimutils.xml_to_ChooseWaveformParams_array(str(opts.sim_xml))
    if not(opts.random_event):
      if  len(P_list) < opts.event: 
        #+opts.n_events_to_analyze:
        print( " Event list of range; soft exit")
        sys.exit(0)
      n_event_max= np.min([len(P_list), opts.event+opts.n_events_to_analyze])
      P_list = P_list[opts.event:n_event_max]
    else:
      P_list = P_list[np.random.choice( np.range(len(P_list)), size=opts.n_events_to_analyze, replace=False) ]
    if len(P_list) ==0:
      print(" No events to analyze, terminating with success ")
      sys.exit(0)
    for P in P_list:
        P.radec =False  # do NOT propagate the epoch later
        P.fref = opts.reference_freq
        P.fmin = template_min_freq
        P.tref = fiducial_epoch  # the XML table
        m1 = P.m1/lal.MSUN_SI
        m2 =P.m2/lal.MSUN_SI
        lambda1, lambda2 = P.lambda1,P.lambda2
        P.dist = factored_likelihood.distMpcRef * 1.e6 * lal.PC_SI   # use *nonstandard* distance
        P.phiref=0.0
        P.psi=0.0
        P.incl = 0.0       # only works for aligned spins. Be careful.
        P.fref = opts.reference_freq
        if opts.approximant != "TaylorT4": # not default setting
            P.approx = lalsimutils.lalsim.GetApproximantFromString(opts.approximant)  # allow user to override the approx setting. Important for NR followup, where no approx set in sim_xml!
        if opts.approximant == "EccentricTD":
            P.phaseO = 3

    P = P_list[0]  # Load in the physical parameters of the injection.  
elif opts.sim_grid:
  print( "====Loading injection grid file:", opts.sim_grid, opts.event, " =======")
  # ----------------------------------------------------------------------
  # Hyperpipeline-format grid (opt-in via env var or auto-detected).
  # The legacy --sim-grid reader does setattr(P, 'm1', val) with no unit
  # conversion, relying on the heuristic `if P.m1 < 1e15: P.m1 *= MSUN_SI`
  # below to recover.  Hyperpipeline mode dispatches to hyperpipeline_io's
  # reader instead, which applies the on-disk -> SI conversion declared in
  # PARAM_DISK_TO_SI (m1, m2: solar mass -> kg; distance: Mpc -> m).  The
  # < 1e15 heuristic is preserved as a no-op safety net for both paths.
  # The legacy genfromtxt branch is kept byte-identical for backward
  # compatibility with existing --sim-grid files.
  # ----------------------------------------------------------------------
  from RIFT.misc import hyperpipeline_io as _hpio
  _hpip_grid = _hpio.is_active() or _hpio.sniff(opts.sim_grid)
  if _hpip_grid:
      print(" Hyperpipeline ASCII grid detected for ", opts.sim_grid)
      _all_arr, _hdr = _hpio.read_table(opts.sim_grid)
      grid_in_full = _all_arr
      grid_names = _hdr
      grid_names_params = list(set(grid_names).intersection(set(lalsimutils.valid_params)))
      grid_indx_params = [grid_names.index(x) for x in grid_names_params]
      if len(grid_in_full) <= opts.event:
          print(" No events left to analyze, out of range")
          sys.exit(0)
      n_event_max = np.min([len(grid_in_full), opts.event+opts.n_events_to_analyze])
      grid_in = grid_in_full[opts.event:n_event_max]
      _all_P, _ = _hpio.read_grid_to_P_list(
          opts.sim_grid,
          P_factory=lalsimutils.ChooseWaveformParams,
          lal_module=lal,
          valid_params=lalsimutils.valid_params)
      P_list_seed = _all_P[opts.event:n_event_max]
  else:
   grid_in = np.genfromtxt(opts.sim_grid,names=True)
   grid_names = grid_in.dtype.names
   grid_names_params = list(set(grid_names).intersection(set(lalsimutils.valid_params))) # only do internal loop over valid parameters
   grid_indx_params = [grid_names.index(x) for x in grid_names_params]
   if len(grid_in) < opts.event:
    print(" No events lft to analyze, out of ramge")
    sys.exit(0)
   n_event_max= np.min([len(grid_in), opts.event+opts.n_events_to_analyze])
   grid_in = grid_in[opts.event:n_event_max]
   P_list_seed = []
   # Should call a convert_vector_coordinates call to get m1, m2, etc all in a grid, to make it faster. Assume user is friendly/sane
   for indx in range(opts.n_events_to_analyze):
    P = lalsimutils.ChooseWaveformParams()
    for indx, name  in enumerate(grid_names_params):
      if hasattr(P, name): # fast version
        setattr(P,name,  float(grid_in[grid_names_params[indx]]))
      else: # slightly slower version
        P.assign_param(name, grid_in[grid_names_params[indx]])
    P_list_seed.append(P)
  # Common per-P setup (formerly inlined in the legacy for-loop; both
  # paths now share it so hyperpipeline-loaded P_lists get the same
  # radec / fref / fmin / tref / distance overrides).
  P_list = []
  for P in P_list_seed:
    # Set required properties for all - note distance must be fixed here !
    P.radec =False  # do NOT propagate the epoch later
    P.fref = opts.reference_freq
    P.fmin = template_min_freq
    P.tref = fiducial_epoch  # the XML table
    # fix mass scale
    if P.m1 < 1e15:  #
      P.m1 *= lal.MSUN_SI
      P.m2 *= lal.MSUN_SI
    m1 = P.m1/lal.MSUN_SI
    m2 =P.m2/lal.MSUN_SI
    lambda1, lambda2 = P.lambda1,P.lambda2
    P.dist = factored_likelihood.distMpcRef * 1.e6 * lal.PC_SI   # use *nonstandard* distance
    P_list.append(P)
  P = P_list[0]
  P.print_params()
else:
 if opts.mass1 is not None and opts.mass2 is not None:
    m1, m2 = opts.mass1, opts.mass2
 else:
    raise RuntimeError('Missing --mass1 or --mass2')
 s1z=s2z=0.
 if opts.spin1z is not None:
    s1z = opts.spin1z
 if opts.spin2z is not None:
    s2z = opts.spin2z


 lambda1, lambda2 = 0, 0
 if opts.eff_lambda is not None:
        lambda1, lambda2 = lalsimutils.tidal_lambda_from_tilde(m1, m2, opts.eff_lambda, opts.deff_lambda or 0)
 P = lalsimutils.ChooseWaveformParams(
        approx = lalsimutils.lalsim.GetApproximantFromString(opts.approximant),
    fmin = template_min_freq,
    radec = False,   # do NOT propagate the epoch later
    incl = 0.0,       # only works for aligned spins. Be careful.
    phiref = 0.0,
    theta = 0.0,
    phi = 0.0,
    psi = 0.0,
    m1 = m1 * lal.MSUN_SI,
    m2 = m2 * lal.MSUN_SI,
    lambda1 = lambda1,
    lambda2 = lambda2,
    s1z = s1z,
    s2z = s2z,
    ampO = opts.amp_order,
    fref = opts.reference_freq,
    tref = fiducial_epoch,
    dist = factored_likelihood.distMpcRef * 1.e6 * lal.PC_SI,
    taper= taper_default
    )
 P_list= [P]

# User requested bounds for data segment
if not (opts.data_start_time == None) and  not (opts.data_end_time == None):
    start_time =  opts.data_start_time
    end_time =  opts.data_end_time
    print( "Fetching data segment with start=", start_time)
    print( "                             end=", end_time)

# Automatically choose data segment bounds so region of interest isn't corrupted
# FIXME: Use estimate, instead of painful full waveform generation call here.
else:
    approxTmp = P.approx
    LmaxEff = 2
    if opts.fmin_template_correct_for_lmax:
      LmaxEff=opts.l_max
    T_tmplt = lalsimutils.estimateWaveformDuration(P,LmaxEff) + 4  # Much more robust than the previous (slow, prone-to-crash approach)
#     if (m1+m2)<30:
#         P.approx = lalsimutils.lalsim.TaylorT4  # should not impact length much.  IMPORTANT because EOB calls will fail at the default sampling rate
#         htmplt = lalsimutils.hoft(P)   # Horribly wasteful waveform gneeration solely to estimate duration.  Will also crash if spins are used.
#         P.approx = approxTmp
#         T_tmplt = - float(htmplt.epoch)    # ASSUMES time returned by hlm is a RELATIVE time that does not add tref. P.radec=False !
#         print fiducial_epoch, event_time, htmplt.epoch
#     else:
#         print " Using estimate for waveform length in a high mass regime: beware!"
#         T_tmplt = lalsimutils.estimateWaveformDuration(P) + 4
    T_seg = T_tmplt + T_spec + T_safety # Amount before and after event time
#    if opts.use_external_EOB:
#        T_seg *=2   # extra safety factor
    start_time = float(event_time) - T_seg
    end_time = float(event_time) + T_seg
    print( "Fetching data segment with start=", start_time)
    print( "                             end=", end_time)
    print( "\t\tEvent time is: ", float(event_time))
    print( "\t\tT_seg is: ", T_seg)

#
# Load in data and PSDs
#
data_dict, psd_dict = {}, {}

for inst, chan in map(lambda c: c.split("="), opts.channel_name):
    print( "Reading channel %s from cache %s" % (inst+":"+chan, opts.cache_file))
    data_dict[inst] = lalsimutils.frame_data_to_non_herm_hoff(opts.cache_file,
            inst+":"+chan, start=start_time, stop=end_time,
            window_shape=opts.window_shape,deltaT=deltaT,deltaT_internal=deltaT_internal,use_gwpy=opts.internal_use_gwpy)
    print( "Frequency binning: %f, length %d" % (data_dict[inst].deltaF,
            data_dict[inst].data.length) )
# set global sampling rate, now that we have done import
if deltaT_internal:
  deltaT=deltaT_internal
  fSample = 1./deltaT   # make sure reassign, for time resampling argument safety
    
flow_ifo_dict = {}
if opts.fmin_ifo:
 for inst, freq_str in map(lambda c: c.split("="), opts.fmin_ifo):
    freq_low_here = float(freq_str)
    print( "Reading low frequency cutoff for instrument %s from %s" % (inst, freq_str), freq_low_here)
    flow_ifo_dict[inst] = freq_low_here

for inst, psdf in map(lambda c: c.split("="), opts.psd_file):
    print( "Reading PSD for instrument %s from %s" % (inst, psdf))
    psd_dict[inst] = lalsimutils.get_psd_series_from_xmldoc(psdf, inst)

    deltaF = data_dict[inst].deltaF
    psd_dict[inst] = lalsimutils.resample_psd_series(psd_dict[inst], deltaF)
    print( "PSD deltaF after interpolation %f" % psd_dict[inst].deltaF)

    # Implement PSD window rescaling: see T1900249
    #   Idea:   PSD was *computed* using one window (not corrected for) and we want to remove that scale facto
    #              PSD used in noise with another window duration. We need to correct the PSD for the revised window duration
    #   We *assume* the PSD is not 'corrected' for the psd_window_shape factor (i.e., *not* trying to estimate a PSD idealized to correct for this windowing scale factor)
    if opts.psd_window_shape > 0 or opts.window_shape > 0:
      # Note this is just a constant scale factor in the likelihood, but it *can* have some effect on parameters if one windowing size is small (as it stretches/compresses the likleihood)
      window_fac_psd = lalsimutils.psd_windowing_factor(opts.psd_window_shape, len(psd_dict[inst].data.data))  # assume the windowing factor IS accounted for, and we have to undo it
      window_fac_data = lalsimutils.psd_windowing_factor(opts.window_shape, len(data_dict[inst].data.data))
      psd_dict[inst].data.data *= window_fac_data/window_fac_psd   # scale the windowing factor to be appropriate for our actual data window: the PSD was measured using a windowing different than the one we use

    # implement cutoff.  
    if inst in flow_ifo_dict.keys():
        if isinstance(psd_dict[inst], lal.REAL8FrequencySeries):
            psd_fvals = psd_dict[inst].f0 + deltaF*numpy.arange(psd_dict[inst].data.length)
            psd_dict[inst].data.data[ psd_fvals < flow_ifo_dict[inst]] = 0 # 
        else:
            print( 'FAIL on PSD import')
            sys.exit(1)
#        elif isinstance(psd_dict[inst], pylal.xlal.datatypes.real8frequencyseries.REAL8FrequencySeries):  # for backward compatibility
#            psd_fvals = psd_dict[inst].f0 + deltaF*numpy.arange(len(psd_dict[inst].data))
#            psd_dict[inst].data[psd_fvals < ifo_dict[inst]] =0


    assert psd_dict[inst].deltaF == deltaF

    # Highest freq. at which PSD is defined
    # if isinstance(psd_dict[inst],
    #         pylal.xlal.datatypes.real8frequencyseries.REAL8FrequencySeries):
    #     fmax = psd_dict[inst].f0 + deltaF * (len(psd_dict[inst].data) - 1)
    if isinstance(psd_dict[inst], lal.REAL8FrequencySeries):
        fmax = psd_dict[inst].f0 + deltaF * (psd_dict[inst].data.length - 1)

    # Assert upper limit of IP integral does not go past where PSD defined
    assert opts.fmax is None or opts.fmax<= fmax
    # Allow us to target a smaller upper limit than provided by the PSD. Important for numerical PSDs that turn over at high frequency
    if opts.fmax and opts.fmax < fmax:
        fmax = opts.fmax # fmax is now the upper freq. of IP integral

# Ensure data and PSDs keyed to same detectors
if sorted(psd_dict.keys()) != sorted(data_dict.keys()):
    print >>sys.stderr, "Got a different set of instruments based on data and PSDs provided."

# Ensure waveform has same sample rate, padded length as data
#
# N.B. This assumes all detector data has same sample rate, length
#
# data_dict holds 2-sided FrequencySeries, so their length is the same as
# that of the original TimeSeries that was FFT'd = Nsamples
# Also, deltaF = 1/T, with T = the duration (in sec) of the original TimeSeries
# Therefore 1/(data.length*deltaF) = T/Nsamples = deltaT
P.deltaT = 1./ (data_dict[list(data_dict.keys())[0]].data.length * deltaF)
P.deltaF = deltaF
for Psig in P_list:
  Psig.deltaT = P.deltaT
  Psig.deltaf = P.deltaF


#
# Load in calibration realizations, if desired
#
calibration_marginalization=False
calibration_realization_dict = {}
calibration_log_weights = None   # Phase-0 importance weights log(prior/proposal); None => uniform (prior draws)
_calpilot = None                 # pilot bookkeeping (node draws + prior) when dumping responsibilities
_calpilot_logresp_list = []      # per-intrinsic-point per-realization log-responsibilities, accumulated
# Cal node vectors retained for --calibration-export-posterior (final fairdraw cal-posterior columns):
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
    draw_prior_realizations_with_nodes, same prior as create_realizations) so the final
    fairdraw can emit the recovered cal posterior; otherwise just build the realizations."""
    global calibration_realization_dict, calibration_nodes, calibration_node_dets, calibration_n_nodes_amp
    import RIFT.calmarg.generate_realizations as _genr
    if opts.calibration_export_posterior:
        _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, opts.calibration_n_realizations,
            fmin_ifo=cal_fmin_ifo, rng=np.random.default_rng(getattr(opts,'seed',None)))
        calibration_realization_dict = _ret['realizations']
        calibration_nodes = _ret['nodes']; calibration_node_dets = list(_ret['dets']); calibration_n_nodes_amp = int(_ret['n_nodes_amp'])
    else:
        for ifo in psd_dict:
            fname = opts.calibration_envelope_directory + "/" + ifo + ".txt"
            calibration_realization_dict[ifo] = _genr.create_realizations(fname, 1./P.deltaF, P.deltaT, cal_fmin_ifo[ifo], fmax, opts.calibration_spline_count, opts.calibration_n_realizations)
if opts.calibration_envelope_directory:
#  t_ref_wind = opts.calibration_n_realizations * t_ref_wind  # changes length of buffer, should produce longer window. DOES NOT WORK PROPERLY
  import RIFT.calmarg.generate_realizations
  calibration_marginalization=True
  # per-detector low-frequency cutoff used to lay down the cal spline
  cal_fmin_ifo = {}
  for ifo in psd_dict:
     cal_fmin_ifo[ifo] = flow_ifo_dict[ifo] if opts.fmin_ifo else opts.fmin_template
  # Graceful fallback: a seeded run is given a per-iteration breadcrumb path, but the
  # FIRST iteration (and any iteration before a pilot has run) has no breadcrumb yet.  If
  # the path is missing OR EMPTY, fall back to the broad prior -- so the SAME fixed ILE args
  # work across all DAG iterations.  The iteration-0 placeholder is a 0-byte file (created so
  # OSG file transfer of cal_consolidated_$(macroiterationprev).npz does not fail); on OSG it
  # IS transferred in, so it exists but is empty -> np.load raises EOFError.  Treat empty (or
  # otherwise unreadable) as "not present yet".
  if opts.calibration_proposal_breadcrumb:
     _bc_path = opts.calibration_proposal_breadcrumb
     if not (os.path.exists(_bc_path) and os.path.getsize(_bc_path) > 0):
        print(" Calibration proposal breadcrumb {} missing or empty (iteration-0 placeholder); falling back to PRIOR cal draws.".format(_bc_path))
        opts.calibration_proposal_breadcrumb = None
  if opts.calibration_dump_responsibilities:
     # PILOT (Option C): KEEP the cal node vectors so the per-realization responsibilities
     # accumulated below can be fitted into a proposal (util_CalPilotFit.py).  Deterministic
     # (seeded) so node draws are identical across intrinsic points and the accumulation
     # aligns by realization.
     #   - If an incoming proposal breadcrumb is given, draw the pilot's cal realizations
     #     FROM IT (refinement step: pilot_N seeded from consolidation_{N-1}); the fit then
     #     folds log_w so the refined proposal targets the posterior.  This is the
     #     across-iteration climb (adaptive_cal unrolled over the DAG).
     #   - Otherwise draw from the broad prior (the N=0 cold start).
     import RIFT.calmarg.breadcrumbs, RIFT.calmarg.adaptive
     if opts.calibration_proposal_breadcrumb:
        _bc = RIFT.calmarg.breadcrumbs.load(opts.calibration_proposal_breadcrumb)
        calibration_realization_dict, calibration_log_weights, _pilot_nodes = \
           RIFT.calmarg.generate_realizations.seed_realizations_from_breadcrumb(
              _bc, 1./P.deltaF, P.deltaT, opts.fmin_template, fmax,
              opts.calibration_spline_count, opts.calibration_n_realizations,
              fmin_ifo=cal_fmin_ifo, rng=np.random.default_rng(getattr(opts,'seed',None)))
        _cal = _bc["cal"]
        _calpilot = dict(nodes=_pilot_nodes, prior_mean=_cal["prior_mean"], prior_sigma=_cal["prior_sigma"],
                         node_log_f=_cal["node_log_f"], n_nodes_amp=int(_cal["n_nodes_amp"]),
                         dets=list(_cal["dets"]), log_w=np.asarray(calibration_log_weights))
        print(" Calibration PILOT mode (refine): seeded from {} ; responsibilities -> {}".format(
              opts.calibration_proposal_breadcrumb, opts.calibration_dump_responsibilities))
     else:
        _calpilot = RIFT.calmarg.generate_realizations.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, opts.calibration_n_realizations,
           fmin_ifo=cal_fmin_ifo, rng=np.random.default_rng(getattr(opts,'seed',None)))
        calibration_realization_dict = _calpilot['realizations']
        _calpilot['log_w'] = np.zeros(opts.calibration_n_realizations)   # prior draws -> uniform
        print(" Calibration PILOT mode (cold): prior cal draws; responsibilities -> {}".format(
              opts.calibration_dump_responsibilities))
  elif opts.calibration_proposal_breadcrumb:
     # Option C / adaptive pilot: draw cal realizations from the LEARNED proposal and
     # carry importance weights log(prior/proposal) so the marginalization is unbiased.
     import RIFT.calmarg.breadcrumbs, RIFT.calmarg.adaptive
     try:
       _bc = RIFT.calmarg.breadcrumbs.load(opts.calibration_proposal_breadcrumb)
       _rng = np.random.default_rng(getattr(opts,'seed',None))
       calibration_realization_dict, calibration_log_weights, _seed_nodes = \
          RIFT.calmarg.generate_realizations.seed_realizations_from_breadcrumb(
             _bc, 1./P.deltaF, P.deltaT, opts.fmin_template, fmax,
             opts.calibration_spline_count, opts.calibration_n_realizations,
             fmin_ifo=cal_fmin_ifo, rng=_rng)
       if opts.calibration_export_posterior:
          calibration_nodes = _seed_nodes
          calibration_node_dets = list(_bc["cal"]["dets"]); calibration_n_nodes_amp = int(_bc["cal"]["n_nodes_amp"])
       print(" Calibration realizations SEEDED from proposal breadcrumb {} ; neff(cal weights)~{:.1f}/{}".format(
             opts.calibration_proposal_breadcrumb,
             RIFT.calmarg.adaptive.neff_from_logweights(calibration_log_weights),
             opts.calibration_n_realizations))
     except Exception as _e_bc:
       # robustness (esp. OSG file transfer): a missing/partial/invalid breadcrumb must NOT
       # kill the job -- fall back to broad PRIOR cal draws (still unbiased, just unseeded).
       print(" WARNING: could not seed from breadcrumb {} ({}); falling back to PRIOR cal draws.".format(
             opts.calibration_proposal_breadcrumb, _e_bc))
       opts.calibration_proposal_breadcrumb = None
       _cal_setup_prior_with_nodes(psd_dict)
  else:
     _cal_setup_prior_with_nodes(psd_dict)

def _draw_more_calibration_draws(n_more, psd_dict):
    """Draw n_more ADDITIONAL independent cal realizations from the same source as
    the original set (broad prior / prior-with-nodes / proposal breadcrumb), EXTEND
    the module-level bookkeeping in place (realization dict columns, importance
    log-weights, node vectors), and return JUST the new realizations dict so the
    caller can precompute only the new rholm blocks and append them.

    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=_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])
    elif opts.calibration_export_posterior:
        _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=_rng)
        new = _ret['realizations']
        calibration_nodes = np.vstack([calibration_nodes, _ret['nodes']]) if calibration_nodes is not None else _ret['nodes']
    else:
        for ifo in psd_dict:
            fname = opts.calibration_envelope_directory + "/" + ifo + ".txt"
            new[ifo] = _genr.create_realizations(fname, 1./P.deltaF, P.deltaT,
                cal_fmin_ifo[ifo], fmax, opts.calibration_spline_count, n_more)
    for ifo in new:
        calibration_realization_dict[ifo] = np.concatenate([calibration_realization_dict[ifo], new[ifo]], axis=1)
    return new



#
# Set up parameters and bounds
#

# PROBLEM: if too large, you can MISS a source. Does NOT need to be fixed for all masses *IF* the problem really has strong support
dmin = opts.d_min    # min distance
dmax = opts.d_max  # max distance FOR ANY SOURCE EVER. EUCLIDEAN



dmax_sampling_guess = dmax
distBoundGuess = dmax

print( "Recommended distance for sampling ", dmax_sampling_guess, " and probably near ", distBoundGuess, " smaller than  ", dmax)
print( "    (recommendation not yet used) ")
param_limits = { "psi": (0, 2*numpy.pi),
    "phi_orb": (0, 2*numpy.pi),
    "distance": (dmin, dmax),   # CAN LEAD TO CATASTROPHIC FAILURE if dmax is too large (adaptive fails - too few bins)
    "right_ascension": (0, 2*numpy.pi),
    "declination": (-numpy.pi/2, numpy.pi/2),
    "t_ref": (-t_ref_wind, t_ref_wind),
    "inclination": (0, numpy.pi)
}
if opts.internal_rotate_phase:
  param_limits['psi'] = (0, 4*numpy.pi)
  param_limits['phi_orb'] = (0, 4*numpy.pi)
if opts.internal_reparam_dl_incl:
  # The reparam operates in DISTANCE units (D_eff = d_L/A in Mpc).  --d-prior-redshift samples
  # in redshift and converts, which does not compose with the amplitude relation -- refuse it
  # rather than silently bias.  All distance-space priors (Euclidean/cosmo/cosmo_sourceframe/
  # pseudo_cosmo) ARE supported: the measure correction below uses dist_prior_pdf directly.
  if getattr(opts, 'd_prior_redshift', False):
    raise SystemExit(" --internal-reparam-dl-incl is not compatible with --d-prior-redshift (redshift-space sampling). Use a distance-space --d-prior.")
  # sample the distance axis as D_eff = d_L / A(iota); widen it so physical d_L = D_eff*A(iota)
  # can cover [dmin,dmax] for all iota (A in [0.5, sqrt(2)]).  dist_prior_pdf auto-normalizes
  # over this range (=> a constant lnZ offset vs baseline, computable; posterior unaffected);
  # the physical d_L bound and the prior-agnostic measure term are applied in the likelihood closure.
  param_limits['distance'] = (dmin/_REPARAM_A_MAX, dmax/_REPARAM_A_MIN)
  print("  [reparam d_L<->D_eff] distance axis is D_eff; sampling range {:.1f}..{:.1f}".format(*param_limits['distance']))
# Optional truth-centered "zoom box": narrow the extrinsic sampling AND prior ranges so the
# adaptive sampler can resolve a narrow high-SNR peak it could never find from the full prior.
# Threads through param_limits into every sky/orientation sampler + its pdf/cdf_inv/prior_pdf.
# The limits are ALWAYS specified in radians of the physical angle; the cosine samplers
# (--declination-cosine-sampler / --inclination-cosine-sampler) transform them below into the
# coordinate they actually sample (sin(dec) resp. cos(iota)).
for _optv, _k in [(opts.limit_psi, 'psi'), (opts.limit_right_ascension, 'right_ascension'),
                  (opts.limit_declination, 'declination'), (opts.limit_inclination, 'inclination')]:
  if _optv:
    try:
      _lo, _hi = [float(_x) for _x in str(_optv).split(',')]
    except ValueError:
      raise SystemExit(" --limit-{} expects 'LO,HI' in radians, got '{}'".format(_k.replace('_','-'), _optv))
    if _k in ('declination', 'inclination'):
      # validates lo<hi and overlap with the physical domain; raises loudly otherwise
      _lo, _hi = clip_angle_limits(_lo, _hi, _k)
    elif not (_hi > _lo):
      raise SystemExit(" --limit-{}: empty or inverted range [{}, {}] (need LO < HI)".format(_k.replace('_','-'), _lo, _hi))
    param_limits[_k] = (_lo, _hi)
    print("  [limit] restricting {} sampling/prior to [{:.4f}, {:.4f}]".format(_k, _lo, _hi))
limit_declination_active = bool(opts.limit_declination)
limit_inclination_active = bool(opts.limit_inclination)
# --limit-distance: SAMPLING-only narrowing.  dist_prior_range is captured BEFORE the
# narrowing and is what the distance prior normalizes over, so the reported lnZ keeps the
# full-range scale.  (Under --internal-reparam-dl-incl the range captured here is the
# widened D_eff axis, which is what that mode already normalized over -- and which is why
# --limit-distance is refused there rather than reinterpreted.)
dist_prior_range = (param_limits["distance"][0], param_limits["distance"][1])
limit_distance_active = bool(opts.limit_distance)
if limit_distance_active:
  if opts.distance_marginalization:
    raise SystemExit(" --limit-distance is not compatible with --distance-marginalization: that path has no distance sampler to narrow (the distance integral is done analytically over [--d-min,--d-max] from the lookup table).")
  if getattr(opts, 'd_prior_redshift', False):
    raise SystemExit(" --limit-distance is not compatible with --d-prior-redshift: the sampled coordinate is then redshift, not luminosity distance in Mpc.")
  if opts.pin_distance_to_sim:
    raise SystemExit(" --limit-distance is not compatible with --pin-distance-to-sim: that path PINS distance to the injection value (analyze_event sets pinned_params['distance'] = P.dist), so there is no distance draw for a box to restrict and the option would be a silent no-op.  Same class as --distance-marginalization above: refuse rather than accept-and-ignore.")
  if opts.internal_reparam_dl_incl:
    raise SystemExit(" --limit-distance is not compatible with --internal-reparam-dl-incl: the sampled distance axis is then D_eff = d_L/A(iota), not d_L, so a box in Mpc of d_L does not map to a box in the sampled coordinate.")
  try:
    _dlo, _dhi = distance_limit_range(opts.limit_distance, dist_prior_range[0], dist_prior_range[1])
  except ValueError as _e:
    raise SystemExit(" --limit-distance: {}".format(_e))
  param_limits["distance"] = (_dlo, _dhi)
  print("  [limit] restricting distance sampling to [{:.4f}, {:.4f}] Mpc (prior normalization UNCHANGED over [{:.4f}, {:.4f}] Mpc)".format(_dlo, _dhi, dist_prior_range[0], dist_prior_range[1]))

#
# Parameter integral sampling strategy
#

# Oracle for portfolio if needed


# Portfolio
use_portfolio=False
use_gmm_member=False   # set when a portfolio carries a GMM member (see the portfolio setup loop)
params = {}
sampler = mcsampler.MCSampler()
xpy_asarray_already = functools.partial(xpy_default.asarray,dtype=np.float64)
if opts.sampler_method == "adaptive_cartesian_gpu":
    print(" ILE: {}".format(opts.sampler_method))
    sampler = mcsamplerGPU.MCSampler()
    sampler.xpy = xpy_default
    sampler.identity_convert=identity_convert
    mcsampler  = mcsamplerGPU  # force use of routines in that file, for properly configured GPU-accelerated code as needed

    xpy_asarray_already = lambda x: x  # do nothing because we are already on the board for GPU-generated 

    if opts.sampler_xpy == "numpy":
      mcsampler.set_xpy_to_numpy()
      sampler.xpy= numpy
      sampler.identity_convert= lambda x: x
elif opts.sampler_method == "GMM":
    print(" ILE: {}".format(opts.sampler_method))
    sampler = mcsamplerEnsemble.MCSampler()
elif opts.sampler_method == 'AV':
    print(" ILE: {}".format(opts.sampler_method))
    opts.internal_use_lnL=True

    sampler = mcsamplerAdaptiveVolume.MCSampler(n_chunk=opts.n_chunk) # note larger is better, but keep GPU mem limits in mind
    sampler.xpy = xpy_default
    sampler.identity_convert=identity_convert
    mcsampler  = mcsamplerAdaptiveVolume  # force use of routines in that file, for properly configured GPU-accelerated code as needed

    xpy_asarray_already = lambda x: x  # do nothing because we are already on the board for GPU-generated 

    if opts.sampler_xpy == "numpy":
      mcsampler.set_xpy_to_numpy()
      sampler.xpy= numpy
      sampler.identity_convert= lambda x: x
elif opts.sampler_method == "portfolio":
    # NB the `and mcsampler_Portfolio_ok` that used to be part of this test made the raise
    # below dead code AND sent an unavailable portfolio to the `else` fallback at the end of
    # this chain, which silently runs the plain mcsampler.MCSampler instead.  Requesting a
    # sampler that cannot be built must fail, not quietly become a different sampler.
    if not(mcsampler_Portfolio_ok):
      raise Exception(" Portfolio integrator requested but not available")
    use_portfolio=True
    opts.internal_use_lnL=True  # required, we only implement those scenarios right now
    sampler_list = []
    # --sampler-portfolio is action='append' AND documented as comma-separated, so honor BOTH:
    # flatten the appended list and split every element on ',' (e.g. ['AV,GMM'] -> ['AV','GMM'],
    # ['AV','GMM'] -> ['AV','GMM']).  Without this, 'AV,GMM' was one bogus member name that matched
    # no branch, silently yielding a single-member portfolio.
    # The `or []` matters because the option defaults to None: omitting it entirely used to raise
    # TypeError from this comprehension rather than saying what the user actually got wrong.
    sampler_types = [s.strip() for item in (opts.sampler_portfolio or []) for s in str(item).split(',') if s.strip()]
    if not sampler_types:
      raise Exception(" --sampler-method portfolio requires at least one --sampler-portfolio member")

    # prep xpy, etc
    my_xpy = xpy_default
    my_identity_convert=identity_convert
    my_identity_convert_togpu=identity_convert_togpu
    print(" PORTFOLIO ", opts.sampler_portfolio)
    xpy_asarray_already = lambda x: x  # do nothing because we are already on the board for GPU-generated 
    if opts.sampler_xpy == "numpy":
      mcsampler.set_xpy_to_numpy()
      my_xpy= numpy
      my_identity_convert= lambda x: x
      my_identity_convert_togpu= lambda x: x
    for name in sampler_types:
        if name =='AV':
            sampler = mcsamplerAdaptiveVolume.MCSampler(n_chunk=opts.n_chunk) # enforce now, so provided for setup phase
        elif name =='GMM':
            sampler = mcsamplerEnsemble.MCSampler()
            # A GMM member needs the GMM-specific argument blocks below to run so its config is
            # forwarded.  Historically this was done by CLOBBERING opts.sampler_method='GMM', which
            # silently broke every downstream `sampler_method == "portfolio"` test (the portfolio
            # setup block became dead code, and the L0 auto-rescue gate never fired for a portfolio)
            # and made a portfolio take GMM-only branches (e.g. return_lnI).  Instead flag it
            # non-destructively: sampler_method stays 'portfolio', and the GMM blocks below key off
            # `use_gmm_args` = standalone GMM OR a portfolio carrying a GMM member.
            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.
            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'):
          sampler.xpy = my_xpy
          sampler.identity_convert= my_identity_convert
          sampler.identity_convert_togpu=identity_convert_togpu
        sampler_list.append(sampler)
    sampler = mcsamplerPortfolio.MCSampler(portfolio=sampler_list)
    sampler.xpy = my_xpy
    sampler.identity_convert= my_identity_convert
    sampler.identity_convert_togpu= my_identity_convert_togpu
    # sampler weights will be CPU-typed, so don't change them
elif mcsampler_Portfolio_ok and opts.sampler_method in mcsamplerPortfolio.known_pipelines: # access from plugins
  sampler = mcsamplerPortfolio.known_pipelines[opts.sampler_method]()
  # prep xpy, etc
  my_xpy = xpy_default
  my_identity_convert=identity_convert
  my_identity_convert_togpu=identity_convert_togpu
  sampler.xpy = my_xpy
  sampler.identity_convert= my_identity_convert
  sampler.identity_convert_togpu= my_identity_convert_togpu
else:
    print(" ILE: **original sampler** ")
    # mcsamplerPortfolio is only bound if its import succeeded; reaching this line with a
    # failed import used to raise NameError from the diagnostic itself.
    print(" ILE requested: {}".format(opts.sampler_method), " compare to ",
          sorted(mcsamplerPortfolio.known_pipelines) if mcsampler_Portfolio_ok else "<mcsamplerPortfolio unavailable>")

#
# Psi -- polarization angle
# sampler: uniform in [0, pi)
#
if not opts.psi_marginalization:
    psi_sampler = mcsampler.ret_uniform_samp_vector_alt(
        param_limits["psi"][0], param_limits["psi"][1])
    psi_sampler_cdf_inv = functools.partial(mcsampler.uniform_samp_cdf_inv_vector,
        param_limits["psi"][0], param_limits["psi"][1])
    sampler.add_parameter("psi",
        pdf = psi_sampler,
        cdf_inv = psi_sampler_cdf_inv,
        left_limit = param_limits["psi"][0],
        right_limit = param_limits["psi"][1],
        prior_pdf = mcsampler.uniform_samp_psi,
        adaptive_sampling=opts.internal_rotate_phase or opts.force_adapt_all)
# else: psi is not a sampled dimension at all -- --psi-marginalization integrates it out
# analytically inside the likelihood (see the likelihood_function branch below), matching the
# --distance-marginalization precedent of skipping add_parameter entirely for a parameter that
# is marginalized rather than sampled.
#
# EVIDENCE NEUTRALITY.  The psi prior this driver USES is not normalized: the sampled path puts
# uniform_samp_psi = 1/pi over param_limits["psi"] = (0, 2 pi), so its psi prior integrates to 2,
# and every ordinary ILE lnZ on this path carries that +ln 2.  NetworkLogLikelihoodPolarizationMarginalized
# instead returns the NORMALIZED marginal, (1/pi) int_0^pi exp(lnL) dpsi, which integrates to 1.
# Without the correction below the flag would report lnL exactly ln 2 = 0.693 nat BELOW an
# otherwise identical sampled-psi run, so marginalized and sampled rows could not share an
# all.net.  Measured on two fixtures, AV, 3 seeds each: 0.7027 and 0.6602 +/- 0.036.
# Derived from the sampler's OWN prior and limits rather than written as a literal ln 2, so that
# changing either (e.g. --internal-rotate-phase's (0, 4 pi) psi range) cannot silently desync it.
# NOTE FOR REVIEW: this matches the incumbent, it does not decide the convention.  The RIFT JAX
# driver uses psi in [0, pi]; until one convention is chosen, evidences compared ACROSS the two
# drivers differ by ln 2 regardless of this flag.
psi_marginalization_ln_prior_mass = 0.0
if opts.psi_marginalization:
    _psi_prior_density = float(numpy.atleast_1d(
        mcsampler.uniform_samp_psi(numpy.atleast_1d(
            0.5*(param_limits["psi"][0]+param_limits["psi"][1]))))[0])
    _psi_prior_mass = _psi_prior_density*(param_limits["psi"][1]-param_limits["psi"][0])
    if not (_psi_prior_mass > 0):
        raise ValueError("--psi-marginalization: psi prior mass {} is not positive".format(_psi_prior_mass))
    psi_marginalization_ln_prior_mass = float(numpy.log(_psi_prior_mass))
    print("  --psi-marginalization: sampled-path psi prior mass is {:.6f} over ({:.4f}, {:.4f}) at "
          "density {:.6f}; adding ln(mass) = {:+.6f} nat to the normalized analytic marginal so "
          "lnZ matches an otherwise identical sampled-psi run.".format(
              _psi_prior_mass, param_limits["psi"][0], param_limits["psi"][1],
              _psi_prior_density, psi_marginalization_ln_prior_mass))

#
# Phi - orbital phase
# sampler: uniform in [0, 2*pi)
#
if not (opts.distance_marginalization and lookup_table["phase_marginalization"]):
    phi_sampler = mcsampler.ret_uniform_samp_vector_alt( 
        param_limits["phi_orb"][0], param_limits["phi_orb"][1])
    phi_sampler_cdf_inv = functools.partial(mcsampler.uniform_samp_cdf_inv_vector, 
        param_limits["phi_orb"][0], param_limits["phi_orb"][1])
    sampler.add_parameter("phi_orb",
        pdf = phi_sampler,
        cdf_inv = phi_sampler_cdf_inv,
        left_limit = param_limits["phi_orb"][0], 
        right_limit = param_limits["phi_orb"][1],
        prior_pdf = mcsampler.uniform_samp_phase,
        adaptive_sampling=opts.internal_rotate_phase or opts.force_adapt_all)


#
# inclination - angle of system angular momentum with line of sight
# sampler: cos(incl) uniform in [-1, 1)
#

adapt_extra_extrinsic=False
if (opts.sampler_method == "adaptive_cartesian_gpu"  or opts.sampler_method == 'AV' ) or use_portfolio or opts.force_adapt_all:  # this is a better/more stable/faster adaptive code, trust it to adapt in more extrinsic dimensions
  adapt_extra_extrinsic=True

if not opts.inclination_cosine_sampler:
 if limit_inclination_active:
   # truncated uniform-in-cos(iota) draw, expressed in the ANGLE coordinate
   incl_sampler = ret_cos_samp_vector(param_limits["inclination"][0], param_limits["inclination"][1])
   incl_sampler_cdf_inv = ret_cos_samp_cdf_inv_vector(param_limits["inclination"][0], param_limits["inclination"][1])
 else:
   incl_sampler = mcsampler.cos_samp_vector # this is NOT dec_samp_vector, because the angular zero point is different!
   incl_sampler_cdf_inv = mcsampler.cos_samp_cdf_inv_vector
 sampler.add_parameter("inclination",
    pdf = incl_sampler,
    cdf_inv = incl_sampler_cdf_inv,
    left_limit = param_limits["inclination"][0],
    right_limit = param_limits["inclination"][1],
    prior_pdf = mcsampler.uniform_samp_theta)  # do not adapt in parameter going to zero at edge
else:
 # Sample uniformly in cos(iota) [=1 face-on, -1 face-off]: the likelihood closure below
 # converts back with iota = arccos(z).  A --limit-inclination box must therefore be mapped
 # into z, and because cos() DECREASES on [0,pi] the limits SWAP:
 #    [iota_lo, iota_hi]  ->  [cos(iota_hi), cos(iota_lo)]
 # (before this fix the range was hardcoded to [-1,1] and --limit-inclination was silently ignored).
 incl_z_lo, incl_z_hi = cosine_sampler_limits(param_limits["inclination"][0], param_limits["inclination"][1], 'inclination')
 if limit_inclination_active:
   print("  [limit] inclination box [{:.4f}, {:.4f}] rad -> cos(iota) sampling range [{:.6f}, {:.6f}]".format(
       param_limits["inclination"][0], param_limits["inclination"][1], incl_z_lo, incl_z_hi))
 incl_sampler = mcsampler.ret_uniform_samp_vector_alt(incl_z_lo, incl_z_hi)
 incl_sampler_cdf_inv = lambda x, _a=incl_z_lo, _b=incl_z_hi: _a + x*(_b-_a)  # functools.partial(mcsampler.uniform_samp_cdf_inv_vector,_a,_b)
 sampler.add_parameter("inclination",
    pdf = incl_sampler,
    cdf_inv = incl_sampler_cdf_inv,
    left_limit = incl_z_lo,
    right_limit = incl_z_hi,
    # prior density in cos(iota) is the FULL-RANGE constant 1/2, deliberately not renormalized
    # to the box, so restricting the box costs exactly the prior mass it should -- matching the
    # non-cosine branch, where prior_pdf=0.5*sin(iota) is likewise not renormalized.
    prior_pdf = mcsampler.ret_uniform_samp_vector_alt(-1.0,1.0),
    adaptive_sampling=adapt_extra_extrinsic)

#
# Distance - luminosity distance to source in parsecs
# sampler: uniform distance over [dmin, dmax), adaptive sampling
#
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.units import Hz
    # ported form https://github.com/lscsoft/lalsuite/blob/master/lalinference/python/lalinference/bayespputils.py
    # 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
    if opts.d_prior == 'cosmo':
      def dVdz(z):
        #      return lal.ComovingVolumeElement(z,omega)
        return my_cosmo.differential_comoving_volume(z).value # units irrelevant, just need scale
    else:
      def dVdz(z):
        # uniform in dVc/dz/(1+z), allowing for redshifted time for detections
        return my_cosmo.differential_comoving_volume(z).value/(1+z) # units irrelevant, just need scale
    def dLofz(z):
      return my_cosmo.luminosity_distance(z).value
    if not(opts.d_prior_redshift):
      pdf_dL, cdf_dL, cdf_inv_dL = priors_utils.norm_and_inverse_via_grid_interp( dVdz, [zmin,zmax],vectorized=True,y_of_x=dLofz,\
                                                                                  to_gpu_needed=cupy_success,final_scipy_interpolate=final_scipy_interpolate,final_np=xpy_default,to_gpu=identity_convert_togpu)
      # note how these routines are carefully ported over from mcsamplerGPU to AV, etc so we can do this. We will fail otherwise on GPUs
      dist_sampler = mcsampler.ret_uniform_samp_vector_alt( param_limits["distance"][0], param_limits["distance"][1])
      dist_sampler_cdf_inv = functools.partial(mcsampler.uniform_samp_cdf_inv_vector,     param_limits["distance"][0], param_limits["distance"][1])
      sampler.add_parameter("distance",   
                          pdf = dist_sampler,   
                          cdf_inv = dist_sampler_cdf_inv, 
                          # Historically the physical [dmin,dmax]; narrowed only by
                          # --limit-distance.  pdf_dL is normalized over the full
                          # [zmin,zmax] <-> [dmin,dmax] either way, so the evidence
                          # scale does not move.
                          left_limit = param_limits["distance"][0] if limit_distance_active else dmin, 
                          right_limit = param_limits["distance"][1] if limit_distance_active else dmax,
                          prior_pdf = pdf_dL,   #only thing preserved in calculation
                          adaptive_sampling = (adapt_extra_extrinsic and not (opts.no_adapt or opts.no_adapt_distance)) or opts.force_adapt_all)
    else:
      redshift_to_distance = dLofz
      pdf_z, cdf_z, cdf_inv_z = priors_utils.norm_and_inverse_via_grid_interp( dVdz, [zmin,zmax],vectorized=True,\
                                                                                  to_gpu_needed=cupy_success,final_scipy_interpolate=final_scipy_interpolate,final_np=xpy_default,to_gpu=identity_convert_togpu)
      sampler.add_parameter("distance",   # really REDSHIFT, but changing names causes problems
                          pdf = pdf_z,   # will be immediately replaced if adaptive. Note usually better to sample UNIFORMLY - change?
                          cdf_inv = cdf_inv_z,  # ditto
                          left_limit = zmin, 
                          right_limit = zmax,
                          prior_pdf = pdf_z,   #only thing preserved in calculation
                          adaptive_sampling = (adapt_extra_extrinsic and not (opts.no_adapt or opts.no_adapt_distance)) or opts.force_adapt_all)
elif not opts.distance_marginalization:
  # The SAMPLING range is param_limits["distance"] (narrowed by --limit-distance); the
  # PRIOR normalization range is dist_prior_range, which --limit-distance never touches.
  # Keeping them as two arguments is the point: the old one-range form normalized the
  # Euclidean density over whatever the sampler happened to be drawing from, so narrowing
  # for cost silently rescaled the evidence.  See mcsampler.distance_sampler_kwargs().
  try:
    _dist_kwargs = distance_sampler_kwargs(
                        mcsampler, param_limits["distance"], dist_prior_range,
                        d_prior=opts.d_prior, xpy=xpy_default,
                        adaptive_sampling = (adapt_extra_extrinsic and not (opts.no_adapt or opts.no_adapt_distance)) or opts.force_adapt_all)
  except ValueError:
    print(" ==== WARNING UNKNOWN DISTANCE PRIOR === ")
    raise Exception('distance prior')
  dist_sampler = _dist_kwargs['pdf']
  dist_sampler_cdf_inv = _dist_kwargs['cdf_inv']
  dist_prior_pdf = _dist_kwargs['prior_pdf']
  if opts.internal_reparam_dl_incl:
    # dist_prior_pdf is normalized over the WIDENED D_eff range; the physical prior must be
    # normalized over [dmin,dmax].  ln F = ln(mass of dist_prior_pdf in [dmin,dmax]); the closure
    # subtracts it so the reported lnZ matches the baseline physical-range normalization exactly.
    _xg = numpy.linspace(dmin, dmax, 40000)
    try:
      _pg = numpy.asarray(dist_prior_pdf(_xg), dtype=float)
    except Exception:
      _pg = numpy.array([float(dist_prior_pdf(numpy.array([_x]))[0]) for _x in _xg])
    _REPARAM_LNF = float(numpy.log(numpy.trapz(_pg, _xg)))
    print("  [reparam] physical-range prior-mass fraction F={:.4f} (lnF={:.3f}); lnZ normalization matched".format(numpy.exp(_REPARAM_LNF), _REPARAM_LNF))
  #dist_sampler_cdf_inv=None
  sampler.add_parameter("distance", **_dist_kwargs)   # prior_pdf is the only thing physical

# 
# Rotate sky coordinates
#
if opts.internal_sky_network_coordinates:
  ifo_list = list(psd_dict)
  if not(opts.internal_sky_network_coordinates_raw):
    # remove V and K : the sky ring is almost always only HL
    if 'K1' in ifo_list:
      ifo_list.remove('K1')
    if 'V1' in ifo_list:
      ifo_list.remove('V1')
  # problem: ordering of psd_dict is rarely rational; almost always we want an HL network, rarely V
  if len(ifo_list) <2:
    opts.internal_sky_network_coordinates = False # stop using this code
  else:
    sky_rotations.assign_sky_frame(ifo_list[0], ifo_list[1], fiducial_epoch)
    frm = identity_convert_togpu(sky_rotations.frm)
    my_rotation = functools.partial(lalsimutils.polar_angles_in_frame_alt,frm,xpy=xpy_default)
    my_rotation_cpu = functools.partial(lalsimutils.polar_angles_in_frame_alt,sky_rotations.frm,xpy=np)
if opts.internal_sky_network_coordinates and (opts.limit_right_ascension or opts.limit_declination):
  # The sampled RA/dec live in the network-aligned frame, so a truth-centered sky box given in
  # equatorial coordinates would silently select the wrong patch of sky.  Fail loudly.
  raise SystemExit(" --limit-right-ascension / --limit-declination are sky boxes in EQUATORIAL coordinates and are not compatible with --internal-sky-network-coordinates (which samples in a rotated, network-aligned frame). Drop --internal-sky-network-coordinates when using a sky zoom box.")

#
# Intrinsic parameters
#
sampler_lookup = {}
sampler_inv_lookup = {}
sampler_lookup['q'] = mcsampler.q_samp_vector   # only one intrinsic parameter possible
sampler_lookup['M'] = mcsampler.M_samp_vector
sampler_inv_lookup['q'] = mcsampler.q_cdf_inv_vector
sampler_inv_lookup['M'] = None # mcsampler.M_cdf_inv_vector

if opts.rom_use_basis and opts.rom_integrate_intrinsic:
 for p in intrinsic_param_names:
    indx = intrinsic_param_names.index(p)
    qmin,qmax = param_ranges[indx]
    q_pdf =mcsampler.ret_uniform_samp_vector_alt( qmin, qmax)   # sample uniformly by default
#    q_pdf =functools.partial(sampler_lookup[p], qmin, qmax)   # sample uniformly by default
    q_cdf_inv = functools.partial(mcsampler.uniform_samp_cdf_inv_vector, qmin,qmax) # sample uniformly by default
#    q_cdf_inv= None
    q_pdf_prior = functools.partial(sampler_lookup[param], qmin, qmax)  # true prior, from lookup
    sampler.add_parameter(p, 
        pdf=q_pdf, 
        cdf_inv=q_cdf_inv, 
        left_limit=qmin, right_limit=qmax,prior_pdf=q_pdf_prior, 
        adaptive_sampling = opts.adapt_intrinsic)



if False: #opts.skymap_file is not None:
    from ligo.skymap.io import fits as bfits
    #
    # Right ascension and declination -- use a provided skymap
    #
    smap, _ = bfits.read_sky_map(opts.skymap_file)
    # FIXME: Uncomment for 'mixed' map
    #smap = 0.9*smap + 0.1*numpy.ones(len(smap))/len(smap)
    ss_sampler = mcsampler.HealPixSampler(smap)
    #isotropic_2d_sampler = numpy.vectorize(lambda dec, ra: mcsampler.dec_samp_vector(dec)/2/numpy.pi)
    isotropic_bstar_sampler = numpy.vectorize(lambda dec, ra: 1.0/len(smap))

    # FIXME: Should the left and right limits be modified?
    sampler.add_parameter(("declination", "right_ascension"), 
        pdf = ss_sampler.pseudo_pdf,
        cdf_inv = ss_sampler.pseudo_cdf_inverse, 
        left_limit = (param_limits["declination"][0], param_limits["right_ascension"][0]),
        right_limit = (param_limits["declination"][1], param_limits["right_ascension"][1]),
        prior_pdf = isotropic_bstar_sampler)

else:
    #
    # Right ascension - angle in radians from prime meridian plus hour angle
    # sampler: uniform in [0, 2pi), adaptive sampling
    #
    ra_sampler = mcsampler.ret_uniform_samp_vector_alt(
        param_limits["right_ascension"][0], param_limits["right_ascension"][1])
    ra_sampler_cdf_inv = functools.partial(mcsampler.uniform_samp_cdf_inv_vector,
        param_limits["right_ascension"][0], param_limits["right_ascension"][1])
    sampler.add_parameter("right_ascension", 
        pdf = ra_sampler, 
        cdf_inv = ra_sampler_cdf_inv, 
        left_limit = param_limits["right_ascension"][0],
        right_limit =  param_limits["right_ascension"][1],
        prior_pdf = mcsampler.uniform_samp_phase,
        adaptive_sampling = opts.force_adapt_all or ((not opts.no_adapt) and (not opts.internal_sky_network_coordinates)))  # TOO DANGEROUS to double-adapt in sky, ends up overconverging too easily. Just leave the whole sky ring in place if we want to do this, it's usually there and we don't lose that much.  If we have full localization, we should just remove the sky network coordinates argument!

    #
    # declination - angle in radians from the north pole piercing the celestial
    # sky sampler: cos(dec) uniform in [-1, 1), adaptive sampling
    #
    if not opts.declination_cosine_sampler:
     if limit_declination_active:
       # truncated uniform-in-sin(dec) draw, expressed in the ANGLE coordinate
       dec_sampler = ret_dec_samp_vector(param_limits["declination"][0], param_limits["declination"][1])
       dec_sampler_cdf_inv = ret_dec_samp_cdf_inv_vector(param_limits["declination"][0], param_limits["declination"][1])
     else:
       dec_sampler = mcsampler.dec_samp_vector
       dec_sampler_cdf_inv = mcsampler.dec_samp_cdf_inv_vector
     sampler.add_parameter("declination",
        pdf = dec_sampler,
        cdf_inv = dec_sampler_cdf_inv,
        left_limit = param_limits["declination"][0],
        right_limit = param_limits["declination"][1],
        prior_pdf = mcsampler.uniform_samp_dec,
        adaptive_sampling = opts.force_adapt_all or (not opts.no_adapt))
    else:
     # Sample uniformly in cos(polar_theta), =1 for north pole, -1 for south pole.
     # Propagate carefully in conversions: time of flight libraries use RA,DEC
     # polar_theta = pi/2 - dec, so the sampled variable is z = sin(dec) (see the likelihood
     # closures: dec = pi/2 - arccos(z)).  sin() INCREASES on [-pi/2,pi/2], so a
     # --limit-declination box maps order-preservingly:  [lo,hi] -> [sin(lo), sin(hi)]
     # (before this fix the range was hardcoded to [-1,1] and --limit-declination was silently ignored).
     dec_z_lo, dec_z_hi = cosine_sampler_limits(param_limits["declination"][0], param_limits["declination"][1], 'declination')
     if limit_declination_active:
       print("  [limit] declination box [{:.4f}, {:.4f}] rad -> sin(dec) sampling range [{:.6f}, {:.6f}]".format(
           param_limits["declination"][0], param_limits["declination"][1], dec_z_lo, dec_z_hi))
     dec_sampler = mcsampler.ret_uniform_samp_vector_alt(dec_z_lo, dec_z_hi)
     dec_sampler_cdf_inv = lambda x, _a=dec_z_lo, _b=dec_z_hi: _a + x*(_b-_a) # functools.partial(mcsampler.uniform_samp_cdf_inv_vector,_a,_b)
     sampler.add_parameter("declination",
        pdf = dec_sampler,
        cdf_inv = dec_sampler_cdf_inv,
        left_limit = dec_z_lo,
        right_limit = dec_z_hi,
        # prior density in sin(dec) is the FULL-RANGE constant 1/2, deliberately not renormalized
        # to the box, so the prior mass removed by the box matches the non-cosine branch, where
        # prior_pdf=uniform_samp_dec=0.5*cos(dec) is likewise not renormalized.
        prior_pdf = mcsampler.ret_uniform_samp_vector_alt(-1.0,1.0),
        adaptive_sampling = opts.force_adapt_all or (not opts.no_adapt))

if not opts.time_marginalization:
    #
    # tref - GPS time of geocentric end time
    # sampler: uniform in +/-2 ms window around estimated end time 
    #
    tref_sampler = mcsampler.ret_uniform_samp_vector_alt(
        param_limits["t_ref"][0], param_limits["t_ref"][1])

    tref_sampler_cdf_inv = functools.partial(mcsampler.uniform_samp_cdf_inv_vector, 
                                            param_limits["t_ref"][0], param_limits["t_ref"][1])
    sampler.add_parameter("t_ref", 
                          pdf = tref_sampler, 
                          cdf_inv = tref_sampler_cdf_inv, 
                          left_limit = param_limits["t_ref"][0], 
                          right_limit = param_limits["t_ref"][1],
                          # Reuse the backend-portable closure above.  AV exposes
                          # ret_uniform_samp_vector_alt but not the legacy
                          # uniform_samp_vector symbol.
                          prior_pdf = tref_sampler)


# skymap oracle
oracleRS=None
if opts.skymap_file:
  # Read file
  from RIFT.misc.reference_samples import ReferenceSamples
  rs_object = ReferenceSamples()
  if opts.skymap_file.endswith('fits'):
    rs_object.from_skymap_fits(fname=opts.skymap_file,cos_dec=opts.declination_cosine_sampler)
  else:
      rs_object.from_ascii(fname=opts.skymap_file, reference_params=['ra', 'dec'])
      if opts.declination_cosine_sampler:
        rs_object.reference_params[:,1] = np.arccos(rs.reference_params[:,1])
      # relabel
      rs_object.reference_params  = ['right_ascension', 'declination']

  # Pass to resampling oracle
  from RIFT.integrators.unreliable_oracle.resampling import ResamplingOracle
  oracleRS = ResamplingOracle()
  for p in sampler.params_ordered:
        oracleRS.add_parameter(p,pdf=None, left_limit =sampler.llim[p], right_limit = sampler.rlim[p])
  oracleRS.setup(reference_samples=rs_object.reference_samples, reference_params=rs_object.reference_params)

#
# Determine pinned and non-pinned parameters
#
pinned_params = get_pinned_params(opts)
unpinned_params = get_unpinned_params(opts, sampler.params)
if opts.psi_marginalization:
    # psi is integrated out analytically, so it must not ALSO be an integration dimension.
    # It is absent because add_parameter("psi", ...) above is skipped; this is the live check
    # of that contract, at the one place unpinned_params is actually built for this path.
    # (A `unpinned_params.remove('psi')` used to sit in the GMM-only branch below, which this
    # flag refuses outright, so it could never run.)
    _psi_still_sampled = [p for p in sampler.params
                          if p == 'psi' or (isinstance(p, tuple) and 'psi' in p)]
    if _psi_still_sampled or 'psi' in unpinned_params:
        raise ValueError(
            "--psi-marginalization: 'psi' is still a sampled dimension ({}); it would be "
            "integrated twice.".format(_psi_still_sampled or sorted(unpinned_params)))
    print("  --psi-marginalization: psi is NOT a sampled dimension; integrating over {}".format(
        sorted(str(p) for p in unpinned_params)))
print( "{0:<25s} {1:>5s} {2:>5s} {3:>20s} {4:<10s}".format("parameter", "lower limit", "upper limit", "pinned?", "pin value"))
plen = len(sorted(sampler.params, key=lambda p: len(p))[-1])
for p in sampler.params:
    if p in pinned_params:
        pinned, value = True, "%1.3g" % pinned_params[p]
    else:
        pinned, value = False, ""

    if isinstance(p, tuple):
        for subp, subl, subr in zip(p, sampler.llim[p], sampler.rlim[p]):
            subp = subp + " "*min(0, plen-len(subp))
            print( "|{0:<25s} {1:>1.3g}   {2:>1.3g} {3:>20s} {4:<10s}".format(subp, subl, subr, str(False), ""))
    else:
        p = p + " "*min(0, plen-len(p))
        print( "{0:<25s} {1:>1.3g}   {2:>1.3g} {3:>20s} {4:<10s}".format(p, sampler.llim[p], sampler.rlim[p], str(pinned), value))

# Special case: t_ref is assumed to be relative to the epoch
if "t_ref" in pinned_params:
    pinned_params["t_ref"] -= float(fiducial_epoch)

#
# Provide convergence tests
# FIXME: Currently using hardcoded thresholds, poorly hand-tuned
#
test_converged = {}

#
# Merge options into one big ol' kwargs dict
#

pinned_params.update({ 
    # Iteration settings and termination conditions
    "n": min(opts.n_chunk, n_max), # Number of samples in a chunk
    "nmax": n_max, # Total number of samples to draw before termination
    "neff": n_eff, # Total number of effective samples to collect before termination

    "convergence_tests" : test_converged,    # Dictionary of convergence tests

    # Adaptive sampling settings
    "tempering_exp": opts.adapt_weight_exponent if not opts.no_adapt else 0.0, # Weights will be raised to this power to prevent overconvergence
    "tempering_log": opts.adapt_log,
    "tempering_adapt": opts.adapt_adapt,

    "floor_level": opts.adapt_floor_level if not opts.no_adapt else 0.0, # The new sampling distribution at the end of each chunk will be floor_level-weighted average of a uniform distribution and the (L^tempering_exp p/p_s)-weighted histogram of sampled points.
    "history_mult": 10, # Multiplier on 'n' - number of samples to estimate marginalized 1-D histograms
    "n_adapt": 100 if not opts.no_adapt else 0, # Number of chunks to allow adaption over

    # Verbosity settings
    "verbose": True, #not opts.rom_integrate_intrinsic, 
    "extremely_verbose": False, 

    # Sample caching
    "save_intg": opts.save_samples, # Cache the samples (and integrand values)?
    "igrand_threshold_deltalnL": opts.save_deltalnL, # Threshold on distance from max L to save sample
    "igrand_threshold_p": opts.save_P, # Threshold on cumulative probability contribution to cache sample
    "igrand_fairdraw_samples": opts.fairdraw_extrinsic_output,
    "igrand_fairdraw_samples_max": np.min([opts.fairdraw_extrinsic_output_n_max,opts.n_eff])
})
if opts.sampler_method == "adaptive_cartesian_gpu":
  pinned_params.update({"save_no_samples":True})   # do not exhaust GPU memory with MC samples!
# GMM-specific argument blocks must run for a STANDALONE GMM *or* for a portfolio carrying a GMM
# member (whose config still has to be forwarded).  This used to be achieved by clobbering
# opts.sampler_method='GMM' during portfolio setup, which broke every downstream 'portfolio' test;
# key off this explicit flag instead so sampler_method keeps meaning what the user asked for.
use_gmm_args = (opts.sampler_method == "GMM") or use_gmm_member
return_lnL=False
if (opts.sampler_method=="GMM") and opts.internal_use_lnL:
  # standalone GMM only: return_lnI is an mcsamplerEnsemble kwarg; the portfolio does not consume it
  # (the portfolio's own use_lnL wiring is in the portfolio block below).
  return_lnL=True
  pinned_params.update({"use_lnL":True,"return_lnI":True})
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.
  return_lnL=True
  pinned_params.update({"use_lnL":True})
if opts.sampler_method =="portfolio":
  return_lnL=True
  pinned_params.update({"use_lnL":True})
  # FLEXIBLE allocation for the portfolio's GMM member.  NOTE: when the portfolio HAS a GMM member
  # (use_gmm_member), the GMM block below already forwards gmm_adaptive as a per-group DICT (keyed by
  # the member's actual parameter groups) via extra_args -- which is richer than the scalar cap here,
  # and is the path every portfolio benchmark on this branch actually exercised (it ran because
  # sampler_method used to be clobbered to 'GMM').  Only fall back to the scalar form for a portfolio
  # WITHOUT a GMM member, where that block does not run.  Setting both would double-specify it.
  if opts.internal_gmm_adaptive_components and not use_gmm_member:
    pinned_params.update({'gmm_adaptive': int(opts.internal_gmm_max_components),
                          'gmm_defensive_frac': float(opts.internal_gmm_defensive_frac),
                          'gmm_inflate': float(opts.internal_gmm_inflate)})
    print(" Portfolio: GMM member adaptive components enabled (BIC, cap {})".format(opts.internal_gmm_max_components))
if use_gmm_args:   # standalone GMM, or a portfolio carrying a GMM member (see use_gmm_args above)
    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
    #    (distance,inclination)
    #    (ra, dec)
    #    (psi) (orb_phase)   # only do 1d adaptivity there, 
#    gmm_dict = {tuple([2]):None,tuple([4]):None,(3,5):None,(0,1):None} 
#    comp_dict = {tuple([2]):1,tuple([4]):1,(3,5):3,(0,1):4} 
    default_phipsi = mcsamplerEnsemble.create_wide_single_component_prior( [ param_limits['psi'], param_limits['phi_orb']])
#    pair_sky = sampler_param_tuple(sampler, ['right_ascension','declination'])
    pair_ra_dec =   sampler_param_tuple(sampler, ["right_ascension", "declination"])
    n_d =  2  # 2 distance-inclination lobes by default
    n_sky = 4
    n_phase =4
    adapt_phase = False
    if 'distance' in sampler.params:
         pair_d_incl = sampler_param_tuple(sampler, ['distance','inclination'])
    else:
         pair_d_incl = sampler_param_tuple(sampler, ['inclination'])
         n_d = 1 # one peak in distance by default
    if 'phi_orb' in sampler.params:
         pair_phi_psi = sampler_param_tuple(sampler, ['psi', "phi_orb"])
    else:
         pair_phi_psi = sampler_param_tuple(sampler, ['psi'])
         adapt_phase = True
         n_phase = 2
    # user overrides for component counts (single-peak sources: the ring-sized
    # defaults overfit a localized peak)
    if opts.internal_gmm_sky_components:
      n_sky = opts.internal_gmm_sky_components
    if opts.internal_gmm_phase_components:
      n_phase = opts.internal_gmm_phase_components
    if opts.internal_gmm_correlate_all:
      # SINGLE full-dimension group: a product of per-group GMMs cannot
      # represent cross-group correlations (sky-phase etc.); for a localized
      # single-peak source the factored proposal can stall at the prior
      # (rank-elite refits included: the elite set looks broad in every
      # per-group projection).  One joint GMM can compound on any structure
      # a Gaussian mixture can represent.
      pair_all = tuple(range(len(sampler.params_ordered)))
      n_all = opts.internal_gmm_sky_components if opts.internal_gmm_sky_components else 2
      gmm_dict = {pair_all: None}
      gmm_adapt = {pair_all: True}
    else:
      gmm_dict = {pair_ra_dec:None,pair_d_incl:None,pair_phi_psi:default_phipsi}
      gmm_adapt = {pair_ra_dec: True, pair_d_incl: True, pair_phi_psi: False}
      if opts.internal_rotate_phase:
        gmm_adapt[pair_phi_psi] = True
    # Extrinsic handoff: SEED the per-group GMMs from a learned proposal breadcrumb (the
    # previous iteration's posterior).  Keys are dim-group index tuples, matched by name to
    # sampler.params_ordered -- so they line up with pair_ra_dec / pair_d_incl / pair_phi_psi.
    if opts.extrinsic_proposal_breadcrumb and os.path.exists(opts.extrinsic_proposal_breadcrumb) and os.path.getsize(opts.extrinsic_proposal_breadcrumb) > 0:
      try:
        import RIFT.calmarg.breadcrumbs as _ebcmod, RIFT.calmarg.extrinsic_handoff as _ehmod
        _ebc = _ebcmod.load(opts.extrinsic_proposal_breadcrumb)
        _ext_adapt = bool(getattr(opts, 'extrinsic_proposal_adapt', False))
        _seed = _ehmod.gmm_dict_from_breadcrumb(_ebc.get('extrinsic'), sampler.params_ordered, adapt=_ext_adapt, existing_keys=list(gmm_dict.keys()))
        for _k, _m in _seed.items():
          if _k in gmm_dict:
            gmm_dict[_k] = _m
            # Default FROZEN (gmm_adapt False): _train skips these groups, so the handed-off
            # proposal drives sampling unmodified.  Re-fitting a seeded model on a bad first
            # batch raises in the GMM init and triggers _reset -> the seed would be discarded.
            gmm_adapt[_k] = _ext_adapt
        print(" Extrinsic GMM SEEDED ({}) from {} for dim-groups {}".format(
            "adapting" if _ext_adapt else "frozen", opts.extrinsic_proposal_breadcrumb, list(_seed.keys())))
      except Exception as _e_eh:
        print(" WARNING: could not seed extrinsic GMM from {} ({}); using default proposal.".format(opts.extrinsic_proposal_breadcrumb, _e_eh))
#    gmm_dict = {tuple([2]):None,tuple([4]):None,(3,5):None,tuple([0]):None,tuple([1]):None} 
#    if len(psd_dict.keys()) > 2:
#        n_sky=2  # presumably we have localized better, avoid failure modes
#    comp_dict = {tuple([2]):1,tuple([4]):1,(3,5):3,tuple([0]):n_sky,tuple([1]):n_sky} 
    if opts.internal_gmm_correlate_all:
      comp_dict = {pair_all: n_all}
    else:
      comp_dict = {pair_ra_dec:n_sky,pair_d_incl:n_d,pair_phi_psi:n_phase}
    extra_args = {'n_comp':comp_dict,'max_iter':n_max_blocks,'gmm_dict':gmm_dict, 'gmm_adapt':gmm_adapt}  # made up for now, should adjust
    # FLEXIBLE allocation: each ADAPTING group chooses its component count from
    # the data by BIC (GMM.fit_gmm_adaptive), replacing the hard-coded per-group
    # counts above.  A defensive tail component + optional inflation keep the
    # importance weights bounded (the fix that gets GMM n_eff off ~1 at high SNR).
    if opts.internal_gmm_adaptive_components:
      k_cap = int(opts.internal_gmm_max_components)
      gmm_adaptive = {}
      for _g in gmm_dict:
        if (gmm_adapt is None) or gmm_adapt.get(_g, True):   # only groups that adapt
          gmm_adaptive[_g] = k_cap
      extra_args['gmm_adaptive'] = gmm_adaptive
      extra_args['gmm_defensive_frac'] = float(opts.internal_gmm_defensive_frac)
      extra_args['gmm_inflate'] = float(opts.internal_gmm_inflate)
      print("GMM adaptive components (BIC, cap {}, defensive {}, inflate {}): groups {}".format(
          k_cap, opts.internal_gmm_defensive_frac, opts.internal_gmm_inflate, list(gmm_adaptive.keys())))
    print("GMM:",extra_args)
    print("GMM:",sampler.params_ordered)
    # if opts.distance_marginalization:
    #   if lookup_table["phase_marginalization"]:
    #     gmm_dict = {pair_ra_dec:None,(3,4):None,(0,):None}
    #     gmm_adapt = {pair_ra_dec: True, (3,4): True, (0,): True}
    #     comp_dict = {pair_ra_dec:n_sky,tuple([4]):1,(0,):2}
    #   else:
    #     gmm_dict = {pair_ra_dec:None,tuple([4]):None,(0,1):None}
    #     gmm_adapt = {pair_ra_dec:True,tuple([4]):True,(0,1):True}
    #     comp_dict = {pair_ra_dec:n_sky,tuple([4]):1,(0,1):4}
#      extra_args = {'n_comp':comp_dict,'max_iter':n_max_blocks,'gmm_dict':gmm_dict}  # made up for now, should adjust

    # force adapt in all parameters, if we request it. Requires pass-by-reference in above
    if opts.force_adapt_all:
        for param in gmm_adapt:
            gmm_adapt[param] = True
    pinned_params.update(extra_args)

    # cannot pin params at present
    # unpinned params MUST be full call signature of likelihood, IN ORDER
    if not(opts.time_marginalization):
      unpinned_params =['right_ascension','declination', 't_ref','phi_orb','inclination','psi','distance']
    else:
      unpinned_params =['right_ascension','declination', 'phi_orb','inclination','psi','distance']
    if opts.distance_marginalization:
      unpinned_params.remove('distance')
      if lookup_table["phase_marginalization"]:
        unpinned_params.remove('phi_orb')
if opts.sampler_method == "AV":
  return_lnL=True
  pinned_params.update( { 'enforce_bounds':True})  # don't go out of range : choose integer bin sizes

# WHICH CONVENTION IS ACTUALLY STORED IN _rvs['integrand'] for this run.
#
# This is NOT the same question as opts.internal_use_lnL, and using that option as the predicate
# is wrong.  --internal-use-lnL is accepted for every method in ok_lnL_methods, but the samplers
# do different things with it:
#
#   GMM (mcsamplerEnsemble)  gets return_lnI, so value_array = integrator.cumulative_values, i.e.
#                            lnL, stored raw in _rvs['integrand'].  It writes NO log_* columns, so
#                            it is the one sampler that reaches the linear branch holding a log.
#   AV / portfolio           populate log_integrand + log_joint_prior + log_joint_s_prior; their
#                            _rvs['integrand'] alias is lnL as well, but every consumer here takes
#                            the log branch first, so the flag never decides anything for them.
#   adaptive_cartesian_gpu   use_lnL routes integrate() -> integrate_log(), which writes
#                            log_integrand and never sets 'integrand' at all.
#   adaptive_cartesian       mcsampler.py has NO use_lnL / return_lnI handling whatsoever -- it
#                            always stores linear L (see its numpy.log(self._rvs["integrand"]))
#                            and the driver never hands it use_lnL, so return_lnL stays False and
#                            the likelihood it integrates is L.
#
# Keying off opts.internal_use_lnL would therefore tell the linear branch of ln_weights_from_rvs
# to read lnL out of a record that holds L for '--sampler-method adaptive_cartesian
# --internal-use-lnL' -- a NEW wrong answer (L + ln p - ln p_s) on a path the old code got right.
# The honest predicate is the convention we actually handed the sampler: return_lnI.  Derived ONCE
# here, where pinned_params is final, and passed explicitly from here on.  It is deliberately NOT
# sniffed back out of the data ("are there negative values?"): a plausible guess about a science
# output is exactly what ln_weights_from_rvs exists to refuse.
rvs_integrand_is_lnL = bool(pinned_params.get("return_lnI", False))
print("  _rvs['integrand'] convention: {} (sampler_method={}, internal_use_lnL={})".format(
    "lnL" if rvs_integrand_is_lnL else "L", opts.sampler_method, bool(opts.internal_use_lnL)))


# set up sampler, as needed.  Mainly for portfolio integrator
if use_portfolio:
      print(" PORTFOLIO : setup")
      if opts.sampler_portfolio_args:
          print(" PRE_EVAL", opts.sampler_portfolio_args)
          #opts.sampler_portfolio_args = list(map(lambda x: eval(' "{}" '.format(x)), opts.sampler_portfolio_args))
          opts.sampler_portfolio_args = list(map(eval, opts.sampler_portfolio_args))
          # confirm all are dict
          for indx in range(len(opts.sampler_portfolio_args)):
            if not(isinstance(opts.sampler_portfolio_args[indx], dict)):
                print(indx,opts.sampler_portfolio_args[indx]) 
          print(" ARGS ", opts.sampler_portfolio_args)
      # 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
      # NOTE: the portfolio oracle MECHANISM is fixed (proposals now actually enter
      # member training; see mcsamplerPortfolio), and a FisherGaussianOracle can be
      # attached via sampler.oracle_realizations.  It is not auto-wired here because
      # (a) ILE has no natural Fisher source for the full extrinsic space, and
      # (b) MCSampler.setup() re-runs each oracle's setup(), which would reset a
      # pre-configured skymap ResamplingOracle.  Wire deliberately when a proposal
      # source is available.

# initialize sampler, before we call integrate, so we can seed it
if opts.sampler_method == 'adaptive_cartesian_gpu' and opts.skymap_file:
  sampler.setup()


def resample_samples(my_samples,
                     lookupNKDict=None, rholmArrayDict=None, ctUArrayDict=None, ctVArrayDict=None,epochDict=None, n_cal=1, cal_log_weights=None, ctUArrayDict_cal=None, ctVArrayDict_cal=None): # uses LOTS of global variables, don't pass them all
  global fSample # access global sampling rate
  # will look a LOT like the likelihood function definitions, unfortunately
  if not opts.vectorized:
    raise Exception( ' resample_samples currently only for vectorized ')
  if opts.distance_marginalization:
    raise Exception( ' resample_samples currently only final extrinsic samples; you should NOT have distance marginalization on ')

  n_samples = len(my_samples['longitude'])
  print(" Time resampling size : {} ".format(n_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 = 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)
  P.phiref = identity_convert_togpu(my_samples['coa_phase'])
  P.incl = identity_convert_togpu(my_samples['inclination'])
  P.psi = identity_convert_togpu(my_samples['psi'])
  P.dist = identity_convert_togpu(my_samples['distance']* 1.e6 * lalsimutils.lsu_PC) # luminosity distance
  if not(cupy_success):
    # convert objects
    for name in ['phi','theta', 'phiref','incl', 'psi','dist']:
      setattr(P,name, getattr(P,name).astype(float) )

  t_out = np.zeros(n_samples)
  lnL_out = np.zeros(n_samples)
  if opts._time_quadrature == "bandlimited":
    # The quadrature option carries an export contract: draw from the SAME
    # reflected, derived-resolution, dense-width-validated representation used
    # by the integral.  return_lnLt=True is intentionally coarse-grid and must
    # not be used here.
    if opts.zero_likelihood:
      from RIFT.likelihood.time_marginalization_quadrature import draw_piecewise_linear_log_posterior
      tvals_cpu = identity_convert(tvals)
      t_out, lnL_out = draw_piecewise_linear_log_posterior(
          np.zeros((n_samples, len(tvals_cpu))), float(P.deltaT),
          t0=float(tvals_cpu[0]))
    else:
      t_out, lnL_out = factored_likelihood.DiscreteFactoredLogLikelihoodViaArrayVectorNoLoop(
                          tvals, P, lookupNKDict, rholmArrayDict, ctUArrayDict,
                          ctVArrayDict, epochDict, Lmax=opts.l_max, xpy=xpy_default,
                          n_cal=n_cal, cal_log_weights=cal_log_weights,
                          time_interp=opts._noloop_time_interp,
                          ctUArrayDict_cal=ctUArrayDict_cal,
                          ctVArrayDict_cal=ctVArrayDict_cal,
                          time_quadrature="bandlimited", return_time_draw=True)
      t_out = np.asarray(identity_convert(t_out), dtype=float)
      lnL_out = np.asarray(identity_convert(lnL_out), dtype=float)
  else:
    # With calibration marginalization (n_cal>1) this returns the
    # cal-marginalized lnL(t) timeseries (weighted log-sum-exp over realizations
    # per time bin), so the legacy/spline resampling below operates on the
    # marginalized likelihood.
    lnLt = factored_likelihood.DiscreteFactoredLogLikelihoodViaArrayVectorNoLoop(tvals,
                          P, lookupNKDict, rholmArrayDict, ctUArrayDict, ctVArrayDict,epochDict,Lmax=opts.l_max,xpy=xpy_default,return_lnLt=True,n_cal=n_cal,cal_log_weights=cal_log_weights,
                          time_interp=opts._noloop_time_interp,
                          ctUArrayDict_cal=ctUArrayDict_cal, ctVArrayDict_cal=ctVArrayDict_cal)

    if opts._time_posterior_export == "continuous":
      # NoLoop's historical window gather consumes only tvals[0] and len(tvals),
      # stepping by P.deltaT. Its explicit-time mode instead evaluates the chosen
      # cubic/Lanczos Q stencil independently at every dense geocenter time.
      from RIFT.likelihood import time_marginalization_quadrature as _tm_export
      sigma_t, _, measurable_t = _tm_export.peak_width_from_lnL(
          lnLt, P.deltaT, xpy=xpy_default)
      factors_t = _tm_export.required_upsample_factors(
          xpy_default.where(measurable_t, sigma_t, np.inf), P.deltaT,
          xpy=xpy_default)
      time_export_refinement = max(4, int(xpy_default.max(factors_t)))
      t0_export = tvals[0]
      n_coarse_export = len(tvals)
      while True:
        if time_export_refinement > _tm_export.UPSAMPLE_FACTOR_MAX:
          raise RuntimeError(
              "continuous time export needs refinement above the supported ceiling {}"
              .format(_tm_export.UPSAMPLE_FACTOR_MAX))
        n_dense = (n_coarse_export - 1) * time_export_refinement + 1
        from RIFT.likelihood.time_posterior import validate_time_posterior_working_set
        validate_time_posterior_working_set(n_samples, n_dense)
        tvals_dense = (t0_export + (P.deltaT / time_export_refinement) *
                       xpy_default.arange(n_dense))
        lnLt_dense = factored_likelihood.DiscreteFactoredLogLikelihoodViaArrayVectorNoLoop(
                            tvals_dense, P, lookupNKDict, rholmArrayDict,
                            ctUArrayDict, ctVArrayDict, epochDict, Lmax=opts.l_max,
                            xpy=xpy_default, return_lnLt=True, n_cal=n_cal,
                            cal_log_weights=cal_log_weights,
                            time_interp=opts._noloop_time_interp,
                            ctUArrayDict_cal=ctUArrayDict_cal,
                            ctVArrayDict_cal=ctVArrayDict_cal,
                            explicit_time_values=True)
        sigma_dense, _, measurable_dense = _tm_export.peak_width_from_lnL(
            lnLt_dense, P.deltaT / time_export_refinement, xpy=xpy_default)
        extra = _tm_export.required_upsample_factors(
            xpy_default.where(measurable_dense, sigma_dense, np.inf),
            P.deltaT / time_export_refinement, xpy=xpy_default)
        extra = max(1, int(xpy_default.max(extra)))
        if extra == 1:
          tvals, lnLt = tvals_dense, lnLt_dense
          break
        # Avoid transiently holding two generations of full dense grids.
        del lnLt_dense, tvals_dense, sigma_dense, measurable_dense
        time_export_refinement *= extra
      print(" Time-posterior selected-stencil refinement: {}x ".format(
            time_export_refinement))

    lnLt = identity_convert(lnLt) # back to CPU.  Note we have removed offsets
    if opts.zero_likelihood:
      lnLt =np.zeros(lnLt.shape)  # zero likelihood
    lnLt_norm = scipy.special.logsumexp(lnLt,axis=-1)
    tvals = identity_convert(tvals) # back to CPU
  #  print(lnLt.shape, lnLt_norm.shape,tvals.shape)
    # Draw from the per-sample time posterior.  Sub-sample likelihood
    # interpolation implies a continuous export contract by default: choosing a
    # coarse tvals index here would throw away the resolution just requested.
    if opts._time_posterior_export == "continuous":
      from RIFT.likelihood.time_posterior import draw_continuous_time_posterior
      t_out, lnL_out = draw_continuous_time_posterior(tvals, lnLt)
    # Legacy/fallback grid export, including the explicit higher-rate lattice.
    elif 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; 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
      # far edge of the +-75 ms window, where the time-marginalized likelihood is
      # negligible.
      n_dense = int(np.floor((tvals[-1]-tvals[0])/dt_target)) + 1
      tvals_denser = tvals[0] + dt_target * np.arange(n_dense)
      from scipy.interpolate import RegularGridInterpolator, CubicSpline
      # cubic spline at first, easiest  - generally not exporting too many events
      lnLt_new = np.zeros( (lnLt.shape[0], n_dense) )
      for indx_here in np.arange(n_samples):
        cs = CubicSpline(tvals, lnLt[indx_here])
        lnLt_new[indx_here] = cs(tvals_denser)
      # replace, re-normalize
      tvals = tvals_denser; lnLt= lnLt_new
      lnLt_norm = scipy.special.logsumexp(lnLt,axis=-1)
    if opts._time_posterior_export == "grid":
      indx_list =np.arange(len(tvals))
      for indx in np.arange(n_samples):
        indx_choose = np.random.choice(indx_list, p=np.exp(lnLt[indx] - lnLt_norm[indx]))
        t_out[indx] = tvals[indx_choose]
        lnL_out[indx] = lnLt[indx][indx_choose]
#    print(' Resampled time offset {} '.format(t_out[indx])) #, lnLt[indx]-lnLt_norm[indx])
    
  my_samples['t_ref'] = fiducial_epoch+t_out
  if opts._time_posterior_export == "continuous":
    # Keep the historical float compatibility view above, but serialize a
    # continuous draw from exact integer GPS fields.  Adding a sub-microsecond
    # offset directly to a ~1e9 s float epoch can round away the very resolution
    # this path exists to export.  Do not alter legacy grid serialization.
    (_gps_seconds_exact,
     _gps_nanoseconds_exact) = xmlutils.gps_add_seconds_exact(
         fiducial_epoch_seconds, fiducial_epoch_nanoseconds, t_out)
    my_samples['t_ref_gps_seconds'] = _gps_seconds_exact
    my_samples['t_ref_gps_nanoseconds'] = _gps_nanoseconds_exact
  my_samples["lnL_raw"] = lnL_out   # export likelihoods (equivalent to SNR). Note needs downstream code filters to catch this and put it somewhere.
  
  return my_samples

  

def _rvs_lnL_convention(use_lnL=None):
    """Resolve the stored-'integrand' convention for a helper call.

    Returns the explicit argument when one is given, otherwise the run's `rvs_integrand_is_lnL`
    (derived once from pinned_params['return_lnI']; see the comment where it is set).  Falls back
    to False -- the historical linear reading -- when that global is absent, which happens when
    these helpers are lifted out of the driver by the unit tests.  Reading it through globals()
    rather than referencing the name directly matters: several callers wrap this in a bare
    `except Exception: return None`, so a NameError would turn into a silent None instead of a
    diagnosable failure.
    """
    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 the adapt-weight-exponent baked in.  That exponent is NOT 1 in production
    (helper_LDG_Events.py:1472/1477 sets it from the SNR) and --no-adapt drives it to 0, which
    removes the likelihood from the column entirely.  A consumer preferring that cache silently
    reweights its output by L^(e-1) whenever the GPU/AC sampler is in use.

    So the cache is never read here: the weight is always DERIVED from the canonical components --
    log form first, then the linear (mcsamplerEnsemble) form, with out-of-support rows set to
    -inf.  Raises when neither set is present: an explicit failure beats a plausible wrong number.

    `use_lnL` is REQUIRED to interpret the linear form correctly.  mcsamplerEnsemble reuses the
    'integrand' field for BOTH conventions:

        mcsamplerEnsemble:  self._rvs['integrand'] = self.identity_convert(value_array)

    where `value_array` is L normally but lnL when the sampler was given return_lnI.  Taking log()
    of the latter compresses tens of nats into log(tens), leaving an almost flat weight vector --
    the likelihood effectively drops out and whatever is reconstructed downstream is
    prior-dominated.  The positivity cut is wrong in that mode too: for a log, non-positive means a
    low-likelihood point, not a rejected one, so `ig > 0` silently discards every sample with
    lnL <= 0.

    PASS THE STORED CONVENTION, NOT THE CLI OPTION.  Callers must pass the module-level
    `rvs_integrand_is_lnL` (or thread it through `_rvs_lnL_convention`).  `opts.internal_use_lnL`
    is NOT the same predicate: it is accepted for adaptive_cartesian too, whose sampler
    (mcsampler.py) has no use_lnL/return_lnI handling at all and always stores linear L -- so
    keying off the option would compute L + ln p - ln p_s there, breaking a case the pre-fix code
    handled correctly.

    Samplers that populate 'log_integrand' (adaptive volume, portfolio) take the first branch and
    are unaffected either way.  Only the raw-field samplers reach the second.
    """
    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, and 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.  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):
    """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 with
    real importance weights.  Keying off the CLI flag would flatten those, which is the same
    class of error in the other direction.

    SURVIVES POOLING.  A pooled record built from fair-drawn replicas still has
    posterior-resampled rows, so anything that must not re-weight such rows -- the .dslice
    reweight core -- has to keep seeing True here.  Whether the record is GLOBALLY equal-weight
    is a different question with a different answer; 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 were briefly conflated here, and separating them is the whole point:

      rows resampled   -- each row was 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:
    _pool_replica_rvs gives block k weights summing to Z_k/K, equal within a block but
    differing between blocks by exactly the replica evidences.  Answering the second question
    with the first flag made .dgrid and the proposal breadcrumb mix replicas by exported row
    count instead of by evidence; answering the first with the second made the .dslice
    safeguard and the block-Kish n_eff branch unreachable.  Both are wrong, in opposite
    directions, from one boolean.
    """
    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 a second time
    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).  `_pool_replica_rvs` has guarded
    against exactly this since the replica work, via `already_resampled`; the .dgrid exporter
    and the extrinsic-proposal breadcrumb did not, and both fed science products.

    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 _equal_weight_fairdraw_for_serialization(rvs, sampler, n_max, convert=None,
                                             use_lnL=None, rng=None):
    """Return an equal-weight posterior draw for the external sample file.

    PR #87 deliberately lets an integrator keep its weighted retained record
    when a fair draw would not shrink it.  That is the correct *internal*
    contract: later in-process consumers can still use the real importance
    weights.  XML cannot carry that full weight provenance, however, so a
    caller of ``--fairdraw-extrinsic-output`` must never serialize such a
    record as though its rows were equal-weight posterior samples.

    Do the missing draw only at the serialization boundary.  Already
    equal-weight records pass through unchanged.  Raw or replica-pooled
    records are resampled from ``ln_weights_for_posterior``; the draw size is
    bounded by both the requested export cap and the retained row count.
    Sampling with replacement is intentional for a fair draw.  Final RIFT
    posterior assembly applies its separate unique-output policy later.
    """
    if _rvs_is_equal_weight(sampler):
        return rvs
    n_have = _rvs_len(rvs)
    if n_have == 0:
        return rvs
    n_draw = min(int(n_max), n_have)
    if n_draw < 1:
        raise ValueError("fair-draw serialization requires a positive sample cap")
    ln_w = numpy.asarray(ln_weights_for_posterior(
        rvs, sampler, convert=convert, use_lnL=use_lnL), dtype=float)
    finite = numpy.isfinite(ln_w)
    if not numpy.any(finite):
        raise ValueError("fair-draw serialization has no finite posterior weights")
    scale = numpy.max(ln_w[finite])
    weights = numpy.zeros(len(ln_w), dtype=float)
    weights[finite] = numpy.exp(ln_w[finite] - scale)
    total = numpy.sum(weights)
    if not numpy.isfinite(total) or total <= 0:
        raise ValueError("fair-draw serialization weights cannot be normalized")
    weights /= total
    rng = numpy.random if rng is None else rng
    indices = rng.choice(numpy.arange(n_have), size=n_draw,
                         replace=True, p=weights)
    out = {}
    for key, value in rvs.items():
        array = convert(value) if convert is not None else value
        array = numpy.asarray(array)
        out[key] = array[:, indices] if isinstance(key, tuple) else array[indices]
    return out


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 _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 L0 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.  On the collapsed cold
    pass at rho_net 146.8 that is ONE row out of 1000 retained at eff_samp ~ 1 -- about 7
    nats high -- against 5 rows at eff_samp ~ 8 for the warm pass.  So the gate was reading a
    4.2-nat artifact of its own two subsample sizes as evidence that the warm seed had missed
    mass, and rejecting the warm pass in 10 of 12 replicates on it.

    Falls back to the old _rvs reading when no reserve was kept (a sampler that does not
    keep one, or a pass that raised), 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 of the comparison came
    from the same source, because the two readings are not interchangeable: mixing them is
    the same apples-to-oranges error in a new place.
    """
    _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 _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):
    """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
    L0 rescue's reject path restored 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.  Nothing read that attribute at the time, so it was 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 following point.
    That is the exact failure the rescue's own reject gate exists to prevent, reintroduced one
    attribute over.

    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, because two of them need exactly this record and must
    not drift: 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).  Both
    otherwise fall back to sampler._rvs, which by then has been rebound to a fair-draw
    subset of min(n_extr, 1.5*eff_samp, 1.5*neff) rows 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 wants a different
    record: it reads only lnL and the two prior columns, never X, so a column-order mismatch
    is harmless to it and declining on one would throw away a perfectly good lnZ reading and
    silently downgrade the gate to its fair-draw fallback.  Two lookups with two different
    admissibility rules is correct; collapsing them would be the kind of false unification
    that produces the next defect.  (`_lnZ_of_reserve_or_rvs` does not do the portfolio-member
    fallback either -- worth a look, but that is a change to just-merged #79, not a rebase.)
    """
    _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 (the per-point handler below reports it) 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 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 .dgrid and the extrinsic-proposal breadcrumb apply
    # importance weights to rows that already carry them -- the w^2 defect this whole change
    # exists to remove, resurrected on the event after any failure.
    #
    # On ENTRY rather than in a `finally`: entry is reached on every call by construction,
    # needs no restructuring of a 2000-line function, and leaves the state correct even for a
    # caller that never returns normally at all.  The end-of-function clear stays as well, so
    # a sampler handed to anything else afterwards is not carrying a stale marker.
    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:
      pinned_params['distance']=P.dist/(1.e6 * lal.PC_SI)

    # Call external likleihood preparation if any
    if supplemental_ln_likelhood_prep:
      supplemental_ln_likelhood_prep(P=P,config=supplemental_ln_likelhood_parsed_ini)

    extra_waveform_kwargs = {}
    extra_waveform_kwargs['fd_alignment_postevent_time'] = 2 # 2 seconds after merger for ChooseFDModes
    if opts.internal_waveform_fd_L_frame:
      extra_waveform_kwargs['fd_L_frame'] = True
    if opts.internal_waveform_fd_no_condition:
      extra_waveform_kwargs['no_condition'] = True
    if opts.rom_group:
      extra_waveform_kwargs['rom_taper_start'] = True  # really for NRHyb3dq8 given discussion with Aasim for high-SNR sources, but valuable generally
    if opts.use_gwsignal_lmax_nyquist:
      extra_waveform_kwargs['lmax_nyquist'] = int(opts.use_gwsignal_lmax_nyquist)
    extra_waveform_kwargs['e_freq'] = int(opts.e_freq)
    if opts.internal_waveform_extra_lalsuite_args:
      extra_args_dict = eval(opts.internal_waveform_extra_lalsuite_args)  # should only do this once and for all, not in loop!
      if not(isinstance(extra_args_dict,dict)):
        print(" Type casting fail, maybe retrying ", extra_args_dict, type(extra_args_dict))  # might happen with double-quoting failure at condor level
        extra_args_dict = eval(extra_args_dict)
      print(" Waveform interface: extra args passed ", extra_args_dict)
      extra_waveform_kwargs['extra_waveform_args'] = extra_args_dict
    if opts.internal_waveform_extra_kwargs:
      extra_args_dict = eval(opts.internal_waveform_extra_kwargs)  # pass arguments at high level (eg, gwsignal), not to lalsuite wrapper
      if not(isinstance(extra_args_dict, dict)):
        print(" Type casting fail, maybe retrying ", extra_args_dict, type(extra_args_dict))  # might happen with double-quoting failure at condor level
        if isinstance(extra_args_dict, str):
          extra_args_dict = eval(extra_args_dict)
      print(" Waveform high-level extra args passed ", extra_args_dict, type(extra_args_dict))
      extra_waveform_kwargs.update(extra_args_dict)
    # Keep every precompute for this event on the same waveform-generation
    # route.  In particular, the compound slow-rotation/frequency-response
    # bank must not silently fall back to the default LAL generator when the
    # baseline bank selected GWSignal, NR, ROM, or custom conditioning.
    waveform_generation_kwargs = {
      'NR_group': NR_template_group,
      'NR_param': NR_template_param,
      'use_gwsignal': opts.use_gwsignal,
      'use_gwsignal_approx': opts.approximant,
      'use_external_EOB': opts.use_external_EOB,
      'nr_lookup': opts.nr_lookup,
      'nr_lookup_valid_groups': opts.nr_lookup_group,
      'perturbative_extraction': opts.nr_perturbative_extraction,
      'perturbative_extraction_full': opts.nr_perturbative_extraction_full,
      'use_provided_strain': opts.nr_use_provided_strain,
      'hybrid_use': opts.nr_hybrid_use,
      'hybrid_method': opts.nr_hybrid_method,
      'ROM_group': opts.rom_group,
      'ROM_param': opts.rom_param,
      'ROM_use_basis': opts.rom_use_basis,
      'ROM_limit_basis_size': opts.rom_limit_basis_size_to,
      'no_memory': opts.no_memory,
      'extra_waveform_kwargs': extra_waveform_kwargs,
      'force_22_mode': opts.force_hyperbolic_22,
    }
    # Precompute
    t_window = opts.internal_data_storage_window_half
    ignore_threshold=None
    if opts.internal_precompute_ignore_threshold:
      ignore_threshold=opts.internal_precompute_ignore_threshold
      print("IGNORE ACTIVE ", ignore_threshold)
    extra_kwargs ={}
    n_cal_for_likelihood = 1   # >1 triggers in-loop calibration marginalization in the likelihood
    # use the fused implementation (Option C) when requested; works on GPU (CUDA
    # kernels) and CPU (numpy).  Phase marginalization is not supported by the fused
    # path, so it is disabled there below (that call site stays on the loop method).
    # THE SAME COMPUTATION as the startup stencil guard, not a second expression -- see
    # fused_calmarg_in_use.  `calibration_marginalization` is passed explicitly because it is the
    # one term the early call had to substitute for; comparing the two results is what turns a
    # future divergence in how it is derived into a loud failure instead of a silent re-opening
    # of the drift this function was written to end.
    use_fused_calmarg = fused_calmarg_in_use(opts, calibration_marginalization)
    if use_fused_calmarg != _fused_calmarg_would_run:
        raise ValueError(
            "internal inconsistency: at startup the fused-calibration-kernel predicate was %r "
            "and at dispatch it is %r. The startup value decided whether the inherited "
            "--interpolate-time default was downgraded to 'nearest', so the stencil now in force "
            "(%r) was chosen on a premise that no longer holds. This means "
            "`calibration_marginalization` is no longer `bool(opts.calibration_envelope_directory)`; "
            "fused_calmarg_in_use's early substitution must be updated in the same commit. "
            "Refusing rather than integrating with a stencil chosen for the wrong reason."
            % (_fused_calmarg_would_run, use_fused_calmarg, opts._noloop_time_interp))
    if calibration_marginalization:
      extra_kwargs['calibration_realizations'] = calibration_realization_dict
      extra_kwargs['calibration_conjugate'] = bool(opts.calibration_conjugate_phase)
      # --calibration-global-norm falls back to the cheaper <h|h> route: skip the
      # per-realization self-term cross terms (and their SVD-basis precompute) entirely.
      extra_kwargs['calibration_self_term'] = not bool(opts.calibration_global_norm)
      n_cal_for_likelihood = opts.calibration_n_realizations
    rholms_intp, cross_terms, cross_terms_V,  rholms,  guess_snr, rest, cross_terms_cal, cross_terms_cal_V=factored_likelihood.PrecomputeLikelihoodTerms(
            fiducial_epoch, t_window, P, data_dict, psd_dict, opts.l_max, fmax,
            False, inv_spec_trunc_Q, T_spec,
            return_calibration_crossterms=True,
            ignore_threshold=ignore_threshold,   # default is None, old default was 1e-4. Use to speed calculation and/or discard 'junky' modes, esp at lower SNR. Dangerous at high SNR
            verbose=opts.verbose,quiet=not opts.verbose,
            skip_interpolation=opts.vectorized,
            **waveform_generation_kwargs, **extra_kwargs)

    # skip nan ! Something horrible has happened
    if np.isnan(guess_snr):
      print("  --- NAN SNR GUESS, ABORTING THIS EVENT {} --".format(indx_event))
      raise Exception(" NAN SNR ")

    if opts.auto_logarithm_offset and guess_snr:
      # important: this only impacts *this* analysis
      # important: if we have a very loud signal, it is important to change this dynamically to avoid *underflow*, which sometimes happens otherwise if we fix the scale early on.
      print("    : naive overflow protection: updating lnL overflow based on SNR guess of {} ".format(guess_snr))
      print("    : reminder, diagnostics for lnLmax below are offset by this amount! ")
      # Note by changing the offset, we change how adapt-weight-exponent is being applied (because we've offset the range before squashing it!).  Use with care: pipeline auto-sets the weight exponent
      manual_avoid_overflow_logarithm = guess_snr**2/2 - 100  # more conservative than helper, if I were to auto-set it from --hint-snr.  So the integral peak is more likely positive
      # Don't scale so much that peak guess likelihood is negative:  we do fine for low-ampltiude sources
      if manual_avoid_overflow_logarithm < 0:
        manual_avoid_overflow_logarithm = 0
    elif opts.auto_logarithm_offset:
      print(" PROBLEM: guess_snr not being returned, but auto-tuning requested ")
      # reset to default.  Should not be needed, but weird python scoping error
      manual_avoid_overflow_logarithm = manual_avoid_overflow_logarithm_default 
    else:
      # reset to default.  Should not be needed, but weird python scoping error
      manual_avoid_overflow_logarithm = manual_avoid_overflow_logarithm_default 

    if opts.vectorized:
        lookupNKDict = {}
        lookupKNDict={}
        lookupKNconjDict={}
        ctUArrayDict = {}
        ctVArrayDict={}
        # Per-realization |C_c|^2-weighted cross terms for the fused-calmarg self-term
        # fix.  ctUArrayDict_cal[det] is (n_cal, n_lms, n_lms); None-valued dicts stay
        # empty when calibration marginalization is off.  Built ONCE here, threaded to
        # the calmarg likelihood calls so rho_sq_c = <C_c h|C_c h> replaces the shared
        # (cal-independent) rho_sq.  See the calmarg self-term-bias analysis note.
        ctUArrayDict_cal = {}
        ctVArrayDict_cal = {}
        _have_cal_crossterms = (cross_terms_cal is not None) and (cross_terms_cal_V is not None)
        rholmArrayDict={}
        rholms_intpArrayDict={}
        epochDict={}
        q_deltaT = float(P.deltaT)
        _q_pregrid_reports = []
        for det in rholms_intp.keys():
            print( " Packing ", det)
            lookupNKDict[det],lookupKNDict[det], lookupKNconjDict[det], ctUArrayDict[det], ctVArrayDict[det], rholmArrayDict[det], rholms_intpArrayDict[det], epochDict[det] = factored_likelihood.PackLikelihoodDataStructuresAsArrays( rholms[det].keys(), rholms_intp[det], rholms[det], cross_terms[det],cross_terms_V[det])
            if _have_cal_crossterms:
                ctUArrayDict_cal[det], ctVArrayDict_cal[det] = factored_likelihood.PackCalCrossTermsAsArrays(
                    list(rholms[det].keys()), lookupKNDict[det], cross_terms_cal[det], cross_terms_cal_V[det])
        if opts.q_time_pregrid_factor == 8:
            _q_transfer = cupy.asarray if opts.gpu and (not xpy_default is np) else None
            _q_cleanup = (lambda: cupy.get_default_memory_pool().free_all_blocks()) \
                if _q_transfer is not None else None
            rholmArrayDict, _q_pregrid_reports, _q_pregrid_error = \
                factored_likelihood.prepare_reflected_q_pregrid(
                    rholmArrayDict, factor=8, transfer=_q_transfer, cleanup=_q_cleanup)
            if _q_pregrid_error is None:
                q_deltaT = float(P.deltaT) / 8.0
                print(" Q_lm pregrid telemetry: status=active q_deltaT={:.12g} input_bytes={} "
                      "retained_bytes={} peak_allocation_bytes={} max_roundtrip={:.3g}".format(
                          q_deltaT,
                          sum(item['input_bytes'] for item in _q_pregrid_reports),
                          sum(item['retained_bytes'] for item in _q_pregrid_reports),
                          max(item['peak_allocation_bytes'] for item in _q_pregrid_reports),
                          max(item['roundtrip_max'] for item in _q_pregrid_reports)))
            else:
                q_deltaT = float(P.deltaT)
                opts.q_time_pregrid_factor = 1
                opts._noloop_time_interp = opts._q_pregrid_fallback_interp
                print(" Q_lm pregrid telemetry: status=fallback reason={!r} q_deltaT={:.12g} "
                      "arrival_stencil={}".format(
                          _q_pregrid_error, q_deltaT, opts._noloop_time_interp))
        if opts.gpu and (not xpy_default is np):
            for det in rholmArrayDict:
                lookupNKDict[det] = cupy.asarray(lookupNKDict[det])
                # Q was transferred inside the pregrid transaction.  The
                # default/fallback path still needs its ordinary transfer.
                if opts.q_time_pregrid_factor != 8 and not isinstance(rholmArrayDict[det], cupy.ndarray):
                    rholmArrayDict[det] = cupy.asarray(rholmArrayDict[det])
                ctUArrayDict[det] = cupy.asarray(ctUArrayDict[det])
                ctVArrayDict[det] = cupy.asarray(ctVArrayDict[det])
                epochDict[det] = cupy.asarray(epochDict[det])
                if _have_cal_crossterms:
                    ctUArrayDict_cal[det] = cupy.asarray(ctUArrayDict_cal[det])
                    ctVArrayDict_cal[det] = cupy.asarray(ctVArrayDict_cal[det])
        # NoLoop keeps P.deltaT as the geocentric integration spacing and reads
        # this independent spacing only for Q-grid coordinates.
        P.q_deltaT = q_deltaT
        # Pass None (not empty dicts) downstream when the fix is inactive, so the
        # likelihood keeps its exact cal-independent behavior.
        if not _have_cal_crossterms:
            ctUArrayDict_cal = None
            ctVArrayDict_cal = None

        # Combined Path A/B+D: compose finite-frequency basis weights with the sidereal
        # modulation and delay-derivative operators.  This branch must precede the two
        # individual response branches: both flags now request one compound physical model.
        rotating_freqresponse_data = None
        def _apply_order_control(products, selected_p, selected_q):
            """Explicitly gated U,V order scan; returns the production subset."""
            if not _response_order_active:
                return products
            import warnings
            from RIFT.likelihood import response_order as _response_order
            _report = _response_order.estimate_response_orders(
                products[4], products[1], products[2],
                target_snr=float(opts.response_order_snr),
                lnL_tolerance=float(opts.response_order_lnL_tol),
                n_samples=int(opts.response_order_sky_samples),
                selected_p=int(selected_p), selected_q=int(selected_q),
                vary_p=bool(opts.check_slowrot_pmax or opts.choose_slowrot_pmax),
                vary_q=bool(opts.check_finite_size_Qmax or opts.choose_slowrot_Qmax))
            _response_order.print_order_report(_report)
            if (opts.check_slowrot_pmax or opts.check_finite_size_Qmax) and not _report['selected_passes']:
                warnings.warn("chosen response order fails the requested accuracy: predicted Delta lnL={:.3g} > {:.3g} at SNR {:.6g}".format(_report['selected_delta_lnL'], _report['lnL_tolerance'], _report['target_snr']), RuntimeWarning)
            _p, _q = int(selected_p), int(selected_q)
            if opts.choose_slowrot_pmax or opts.choose_slowrot_Qmax:
                if not _report['reference_resolved']:
                    raise ValueError("cannot auto-select from an unresolved finite response reference; raise the diagnostic reference order")
                if _report['chosen'] is None:
                    raise ValueError("no response order in the diagnostic reference bank satisfies the requested SNR/error budget")
                if opts.choose_slowrot_pmax:
                    _p = int(_report['chosen']['p_max'])
                if opts.choose_slowrot_Qmax:
                    _q = int(_report['chosen']['Qmax'])
            opts.rotation_p_max, opts.freqresponse_qmax = _p, _q
            print("  response order used: p_max=%d Qmax=%d" % (_p, _q))
            return _response_order.truncate_precompute_products(products, p_max=_p, q_max=_q)

        if opts.rotation_slow and opts.freqresponse:
            _p_selected = int(opts.rotation_p_max)
            _q_selected = int(opts.freqresponse_qmax)
            _pmax = max(_p_selected, int(opts.response_order_p_reference)) if (opts.check_slowrot_pmax or opts.choose_slowrot_pmax) else _p_selected
            _qmax = max(_q_selected, int(opts.response_order_Q_reference)) if (opts.check_finite_size_Qmax or opts.choose_slowrot_Qmax) else _q_selected
            if _response_order_active:
                from RIFT.likelihood import response_order as _response_order
                _response_order.guard_reference_bank(
                    'combined', _pmax, _qmax, opts.l_max, len(data_dict),
                    opts.response_order_max_bank_gib)
            _arm = opts.freqresponse_arm_length
            if _arm is not None:
                if '=' in str(_arm):
                    _arm = {kv.split('=')[0]: float(kv.split('=')[1])
                            for kv in str(_arm).split(',')}
                else:
                    _arm = float(_arm)
            if os.environ.get('RIFT_GPU_PRECOMPUTE', '0') == '1' and opts.gpu and xpy_default is not np:
                if _response_order_active:
                    raise NotImplementedError('Device-resident response-order selection is not yet supported; choose explicit orders or disable RIFT_GPU_PRECOMPUTE')
                if os.environ.get('RIFT_GPU_WAVEFORM', 'lal') != 'lal':
                    raise ValueError('Native GPU waveform provider is not yet validated; use RIFT_GPU_WAVEFORM=lal')
                from RIFT.likelihood.gpu_precompute import (
                    PrecomputeLikelihoodTermsRotatingFreqResponseGPU,
                    pack_device_precompute)
                _packed_rf, _meta_rf = PrecomputeLikelihoodTermsRotatingFreqResponseGPU(
                    fiducial_epoch, t_window, P, data_dict, psd_dict, opts.l_max, fmax,
                    Qmax=_qmax, L_arm=_arm, p_max=_pmax, analyticPSD_Q=False,
                    inv_spec_trunc_Q=inv_spec_trunc_Q, T_spec=T_spec,
                    verbose=opts.verbose, quiet=not opts.verbose,
                    skip_interpolation=True, return_device=True,
                    **waveform_generation_kwargs)
                _lkRF, _rhoA, _uAA, _vAA, _epRF = pack_device_precompute(_packed_rf, _meta_rf)
            else:
                _rint_rf, _ct_rf, _ctV_rf, _rho_rf, _meta_rf = \
                    factored_likelihood_rotating_freqresponse.PrecomputeLikelihoodTermsRotatingFreqResponse(
                        fiducial_epoch, t_window, P, data_dict, psd_dict, opts.l_max, fmax,
                        Qmax=_qmax, L_arm=_arm, p_max=_pmax, analyticPSD_Q=False,
                        inv_spec_trunc_Q=inv_spec_trunc_Q, T_spec=T_spec,
                        verbose=opts.verbose, quiet=not opts.verbose,
                        skip_interpolation=True, **waveform_generation_kwargs)
                _rint_rf, _ct_rf, _ctV_rf, _rho_rf, _meta_rf = _apply_order_control(
                    (_rint_rf, _ct_rf, _ctV_rf, _rho_rf, _meta_rf),
                    _p_selected, _q_selected)
                _lkRF, _rhoA, _uAA, _vAA, _epRF = \
                    factored_likelihood_rotating_freqresponse.pack_rotating_freqresponse_arrays(
                        _meta_rf, _rho_rf, _ct_rf, _ctV_rf)
            if opts.gpu and (not xpy_default is np):
                for _det in _rhoA:
                    for _a in _rhoA[_det]:
                        _rhoA[_det][_a] = cupy.asarray(_rhoA[_det][_a])
                    _uAA[_det] = cupy.asarray(_uAA[_det])
                    _vAA[_det] = cupy.asarray(_vAA[_det])
            rotating_freqresponse_data = dict(
                meta=_meta_rf, lookupNKDict=_lkRF, rho_by_a=_rhoA,
                U_by_aa=_uAA, V_by_aa=_vAA, epochDict=_epRF)
            _nbasis = len(_meta_rf['a_list'])
            print("  [rotation-slow+freqresponse] compound precompute complete; "
                  "p_max", _meta_rf['p_max'], "Qmax", _meta_rf['Qmax'], "basis elements", _nbasis,
                  "ordered U/V pairs", _nbasis * _nbasis, "arm-length",
                  opts.freqresponse_arm_length,
                  "(GPU)" if (opts.gpu and not xpy_default is np) else "(CPU)")

        # [Path A] slow-rotation precompute: build the harmonic-indexed bank and pack it.
        rotation_slow_data = None
        if opts.rotation_slow and not opts.freqresponse:
            _p_selected = int(opts.rotation_p_max)
            _pmax = max(_p_selected, int(opts.response_order_p_reference)) if (opts.check_slowrot_pmax or opts.choose_slowrot_pmax) else _p_selected
            if _response_order_active:
                from RIFT.likelihood import response_order as _response_order
                _response_order.guard_reference_bank(
                    'rotation', _pmax, 0, opts.l_max, len(data_dict),
                    opts.response_order_max_bank_gib)
            # --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,
                harmonics=_harm, p_max=_pmax, analyticPSD_Q=False,
                inv_spec_trunc_Q=inv_spec_trunc_Q, T_spec=T_spec,
                verbose=opts.verbose, quiet=not opts.verbose, skip_interpolation=True)
            _rint_r, _ct_r, _ctV_r, _rho_r, _meta_r = _apply_order_control(
                (_rint_r, _ct_r, _ctV_r, _rho_r, _meta_r),
                _p_selected, 0)
            _lkR, _rhoN, _uNN, _vNN, _epR = factored_likelihood_with_rotation.pack_rotation_arrays(
                _meta_r, _rho_r, _ct_r, _ctV_r)
            if opts.gpu and (not xpy_default is np):
                # move the (per-elementary-template) Q banks + U/V to device; the NoLoop
                # reuses the baseline fused kernel per template.  epoch/lookup stay on host.
                for _det in _rhoN:
                    for _a in _rhoN[_det]:
                        _rhoN[_det][_a] = cupy.asarray(_rhoN[_det][_a])
                    for _pair in _uNN[_det]:
                        _uNN[_det][_pair] = cupy.asarray(_uNN[_det][_pair])
                    for _pair in _vNN[_det]:
                        _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", _meta_r['p_max'], "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)
        # into the modes once and pack the response-basis overlap bank.
        freqresponse_data = None
        if opts.freqresponse and not opts.rotation_slow:
            _q_selected = int(opts.freqresponse_qmax)
            _qmax = max(_q_selected, int(opts.response_order_Q_reference)) if (opts.check_finite_size_Qmax or opts.choose_slowrot_Qmax) else _q_selected
            if _response_order_active:
                from RIFT.likelihood import response_order as _response_order
                _response_order.guard_reference_bank(
                    'finite', 0, _qmax, opts.l_max, len(data_dict),
                    opts.response_order_max_bank_gib)
            _arm = opts.freqresponse_arm_length
            if _arm is not None:
                if '=' in str(_arm):   # per-detector 'C1=40000,E1=10000'
                    _arm = {kv.split('=')[0]: float(kv.split('=')[1]) for kv in str(_arm).split(',')}
                else:
                    _arm = float(_arm)
            _rint_f, _ct_f, _ctV_f, _rho_f, _meta_f = factored_likelihood_freqresponse.PrecomputeLikelihoodTermsFreqResponse(
                fiducial_epoch, t_window, P, data_dict, psd_dict, opts.l_max, fmax,
                Qmax=_qmax, L_arm=_arm, analyticPSD_Q=False,
                inv_spec_trunc_Q=inv_spec_trunc_Q, T_spec=T_spec,
                verbose=opts.verbose, quiet=not opts.verbose, skip_interpolation=True)
            _rint_f, _ct_f, _ctV_f, _rho_f, _meta_f = _apply_order_control(
                (_rint_f, _ct_f, _ctV_f, _rho_f, _meta_f),
                0, _q_selected)
            _lkF, _rhoP, _uPP, _vPP, _epF = factored_likelihood_freqresponse.pack_freqresponse_arrays(
                _meta_f, _rho_f, _ct_f, _ctV_f)
            if opts.gpu and (not xpy_default is np):
                # move the (per-basis-weight) Q banks + U/V to device; the NoLoop reuses the
                # baseline fused kernel per weight p.  epoch/lookup stay on host (as in rotation).
                for _det in _rhoP:
                    for _p in _rhoP[_det]:
                        _rhoP[_det][_p] = cupy.asarray(_rhoP[_det][_p])
                    for _pair in _uPP[_det]:
                        _uPP[_det][_pair] = cupy.asarray(_uPP[_det][_pair])
                    for _pair in _vPP[_det]:
                        _vPP[_det][_pair] = cupy.asarray(_vPP[_det][_pair])
            freqresponse_data = dict(meta=_meta_f, lookupNKDict=_lkF, rho_by_p=_rhoP,
                                     U_by_pp=_uPP, V_by_pp=_vPP, epochDict=_epF)
            print("  [freqresponse] precompute complete; Qmax", _meta_f['Qmax'], "arm-length", opts.freqresponse_arm_length,
                  "(GPU)" if (opts.gpu and not xpy_default is np) else "(CPU)")

        def _cal_error_probe(n_cal_now, n_start=256, n_cap=None, rel_tol=0.1):
            """Estimate (sigma_lnZ_cal, neff_cal, n_used, dist_mode): the calibration
            MC error of lnZ and the effective cal draw count, from per-realization
            responsibilities on an extrinsic batch drawn from the RUN'S priors.

            * Distance comes from the run's distance prior -- the sampler's own
              prior_pdf when distance is sampled (incl. cosmo/redshift variants, via
              a uniform proposal + importance weight), the --d-prior pdf when distance
              marginalization is active, or the PINNED value (with a warning: at fixed
              distance the distance/amplitude degeneracy cannot absorb amplitude-like
              cal perturbations, so the estimate is conservative).
            * The batch is ADAPTIVE: doubled until sigma stabilizes (rel_tol) with
              adequate weight support, up to n_cap.
            Responsibilities are ~extrinsic-independent so modest batches converge."""
            import RIFT.calmarg.adaptive as _adapt
            from scipy.special import logsumexp as _lse
            # 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 = []
            def _draw_dist(n):
                if 'distance' in pinned_params:
                    if not _warned:
                        print(" [calmarg error] WARNING: distance is PINNED -- probe runs at the fixed value; without the distance/amplitude degeneracy the cal error estimate is conservative (an upper bound).")
                        _warned.append(1)
                    return np.full(n, float(pinned_params['distance'])), np.zeros(n), 'pinned'
                if opts.distance_marginalization:
                    # distance is integrated on-board against the lookup-table prior;
                    # the probe varies it explicitly, drawing per --d-prior.
                    if opts.d_prior == 'pseudo_cosmo':
                        d = _rng.uniform(dmin, dmax, n)
                        _nm = priors_utils.dist_prior_pseudo_cosmo_eval_norm(dmin, dmax)
                        corr = np.log(np.clip(np.asarray(priors_utils.dist_prior_pseudo_cosmo(d, nm=_nm, xpy=np), dtype=float), 1e-300, None))
                        return d, corr, 'pseudo_cosmo'
                    # Euclidean: exact inverse-CDF draw from the d^2 prior
                    u = _rng.uniform(0, 1, n)
                    d = (dmin**3 + u*(dmax**3 - dmin**3))**(1./3)
                    return d, np.zeros(n), 'Euclidean'
                # distance is a sampler dimension: uniform proposal over its range,
                # importance-weighted by the run's OWN prior_pdf (cosmo/redshift safe);
                # redshift_to_distance maps the sampled coordinate to luminosity distance.
                lo, hi = float(sampler.llim['distance']), float(sampler.rlim['distance'])
                x = _rng.uniform(lo, hi, n)
                pw = np.asarray(identity_convert(sampler.prior_pdf['distance'](identity_convert_togpu(x))), dtype=float)
                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 = 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
            n_batch = int(n_start); n_tot = 0; dist_mode = None
            while True:
                P.phi    = xpy_default.asarray(_rng.uniform(0, 2*np.pi, n_batch), dtype=np.float64)
                P.theta  = xpy_default.asarray(np.arcsin(_rng.uniform(-1, 1, n_batch)), dtype=np.float64)
                P.incl   = xpy_default.asarray(np.arccos(_rng.uniform(-1, 1, n_batch)), dtype=np.float64)
                P.psi    = xpy_default.asarray(_rng.uniform(0, np.pi, n_batch), dtype=np.float64)
                P.phiref = xpy_default.asarray(_rng.uniform(0, 2*np.pi, n_batch), dtype=np.float64)
                P.tref   = float(fiducial_epoch)
                _d, _corr, dist_mode = _draw_dist(n_batch)
                P.dist   = xpy_default.asarray(_d*1.e6*lalsimutils.lsu_PC, dtype=np.float64)
                _comp = factored_likelihood.DiscreteFactoredLogLikelihoodViaArrayVectorNoLoop(
                    _tv, P, lookupNKDict, rholmArrayDict, ctUArrayDict, ctVArrayDict, epochDict,
                    Lmax=opts.l_max, xpy=xpy_default, n_cal=n_cal_now, cal_method='loop',
                    return_cal_components=True, time_interp=opts._noloop_time_interp,
                    ctUArrayDict_cal=ctUArrayDict_cal, ctVArrayDict_cal=ctVArrayDict_cal)
                comp_list.append(np.atleast_2d(np.asarray(identity_convert(_comp), dtype=float)))
                corr_list.append(_corr)
                comp_all = np.vstack(comp_list); corr_all = np.concatenate(corr_list)
                slw = _lse(comp_all + _calw_np[None, :], axis=1) + corr_all
                sigma, neff_cal, _a = _adapt.cal_mc_error_from_components(
                    comp_all, cal_log_weights=_calw_np, sample_log_weights=slw)
                n_tot += n_batch
                kish = float(np.exp(2*_lse(slw) - _lse(2*slw)))
                if n_tot >= n_cap:
                    break
                if (sigma_prev is not None) and (abs(sigma - sigma_prev) <= rel_tol*max(sigma, 0.02)) and kish >= 32:
                    break
                sigma_prev = sigma
                n_batch = min(n_tot, n_cap - n_tot)   # double the total each pass
            return float(sigma), float(neff_cal), int(n_tot), dist_mode

        # ---- ADAPTIVE cal draw count: instead of trusting a hardcoded
        # --calibration-n-realizations, probe the effective number of contributing
        # draws at THIS intrinsic point and grow the draw set (fresh independent
        # draws; incremental precompute of only the new blocks) until the target
        # neff_cal is met or the cap is reached.  The realization dict and the
        # importance-weight/node bookkeeping are extended in place, so later events
        # in this job inherit the enlarged set.
        if calibration_marginalization and n_cal_for_likelihood > 1 and (not opts.calibration_dump_responsibilities) and opts.calibration_neff_cal_target:
            _ncal_cap = int(opts.calibration_n_realizations_max) if opts.calibration_n_realizations_max else 8*int(opts.calibration_n_realizations)
            while True:
                _sig0, _neff0, _npr0, _dmode0 = _cal_error_probe(n_cal_for_likelihood, n_start=256, n_cap=512)
                print(" [calmarg adapt] n_cal={} : cal n_eff ~ {:.1f} (target {:g}), sigma_cal ~ {:.3f} (probe {} pts, distance: {})".format(
                      n_cal_for_likelihood, _neff0, opts.calibration_neff_cal_target, _sig0, _npr0, _dmode0))
                if _neff0 >= opts.calibration_neff_cal_target:
                    break
                if n_cal_for_likelihood >= _ncal_cap:
                    print(" [calmarg adapt] WARNING: cal n_eff {:.1f} below target at the n_cal cap {} -- proceeding; the reported sigma will carry the (large) cal term.".format(_neff0, _ncal_cap))
                    break
                _n_more = min(n_cal_for_likelihood, _ncal_cap - n_cal_for_likelihood)
                print(" [calmarg adapt] growing cal draw set by {} -> {} (incremental precompute)".format(_n_more, n_cal_for_likelihood + _n_more))
                _new_real = _draw_more_calibration_draws(_n_more, psd_dict)
                _ek_more = dict(extra_kwargs); _ek_more['calibration_realizations'] = _new_real
                _intp_more, _ct_more, _ctV_more, rholms_more, _snr_more, _rest_more, _ct_cal_more, _ctV_cal_more = factored_likelihood.PrecomputeLikelihoodTerms(
                    fiducial_epoch, t_window, P, data_dict, psd_dict, opts.l_max, fmax,
                    False, inv_spec_trunc_Q, T_spec,
                    return_calibration_crossterms=True,
                    ignore_threshold=ignore_threshold,
                    NR_group=NR_template_group,NR_param=NR_template_param,
                    use_gwsignal=opts.use_gwsignal,
                    use_gwsignal_approx=opts.approximant,
                    use_external_EOB=opts.use_external_EOB,nr_lookup=opts.nr_lookup,nr_lookup_valid_groups=opts.nr_lookup_group,perturbative_extraction=opts.nr_perturbative_extraction,perturbative_extraction_full=opts.nr_perturbative_extraction_full,use_provided_strain=opts.nr_use_provided_strain,hybrid_use=opts.nr_hybrid_use,hybrid_method=opts.nr_hybrid_method,ROM_group=opts.rom_group,ROM_param=opts.rom_param,ROM_use_basis=opts.rom_use_basis,verbose=opts.verbose,quiet=not opts.verbose,ROM_limit_basis_size=opts.rom_limit_basis_size_to,no_memory=opts.no_memory,skip_interpolation=opts.vectorized, extra_waveform_kwargs=extra_waveform_kwargs,**_ek_more)
                for det in rholms_more.keys():
                    _lNK_m,_lKN_m,_,_,_, _rholmArray_more, _, _ = factored_likelihood.PackLikelihoodDataStructuresAsArrays(
                        rholms_more[det].keys(), _intp_more[det], rholms_more[det], _ct_more[det], _ctV_more[det])
                    if opts.gpu and (not xpy_default is np):
                        _rholmArray_more = cupy.asarray(_rholmArray_more)
                    rholmArrayDict[det] = xpy_default.concatenate([rholmArrayDict[det], _rholmArray_more], axis=-1)
                    # Grow the per-realization self-term cross terms in lockstep with
                    # the rholm blocks, so rho_sq_c stays aligned with the enlarged set.
                    if _have_cal_crossterms and _ct_cal_more is not None:
                        _U_cal_more, _V_cal_more = factored_likelihood.PackCalCrossTermsAsArrays(
                            list(rholms_more[det].keys()), _lKN_m, _ct_cal_more[det], _ctV_cal_more[det])
                        if opts.gpu and (not xpy_default is np):
                            _U_cal_more = cupy.asarray(_U_cal_more); _V_cal_more = cupy.asarray(_V_cal_more)
                        ctUArrayDict_cal[det] = xpy_default.concatenate([ctUArrayDict_cal[det], _U_cal_more], axis=0)
                        ctVArrayDict_cal[det] = xpy_default.concatenate([ctVArrayDict_cal[det], _V_cal_more], axis=0)
                n_cal_for_likelihood += _n_more
                opts.calibration_n_realizations = n_cal_for_likelihood   # later events start consistent with the extended dict

        if opts.calibration_dump_responsibilities and calibration_marginalization:
            # PILOT: accumulate the per-cal-realization extrinsic-marginalized log L at
            # THIS intrinsic point.  Cal is ~extrinsic-independent, so a uniform-prior
            # extrinsic batch gives an unbiased Monte-Carlo estimate of int dOmega L_c
            # (the cal posterior responsibility).  Uses the simplest likelihood config
            # (default helper, loop, fiducial distance) -- only the SHAPE in cal-node
            # space matters, and the runtime importance weights keep the production
            # marginalization unbiased regardless of pilot quality.
            _Next = int(opts.calibration_pilot_extrinsic or 256)
            _rng_ext = np.random.default_rng((getattr(opts,'seed',0) or 0) + 101 + int(indx_event))
            P.phi    = xpy_default.asarray(_rng_ext.uniform(0, 2*np.pi, _Next), dtype=np.float64)
            P.theta  = xpy_default.asarray(np.arcsin(_rng_ext.uniform(-1, 1, _Next)), dtype=np.float64)  # uniform in sin(dec)
            P.incl   = xpy_default.asarray(np.arccos(_rng_ext.uniform(-1, 1, _Next)), dtype=np.float64)
            P.psi    = xpy_default.asarray(_rng_ext.uniform(0, np.pi, _Next), dtype=np.float64)
            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 = 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',
                return_cal_components=True, time_interp=opts._noloop_time_interp,
                ctUArrayDict_cal=ctUArrayDict_cal, ctVArrayDict_cal=ctVArrayDict_cal)
            _comp = np.asarray(identity_convert(_comp))                       # (Next, n_cal) -> CPU
            from scipy.special import logsumexp as _logsumexp
            _calpilot_logresp_list.append(_logsumexp(_comp, axis=0) - np.log(_Next))
            print("   pilot point {}: accumulated cal responsibilities ({} realizations, {} extrinsic)".format(
                  int(indx_event), n_cal_for_likelihood, _Next))
            # PILOT is cheap: skip the full extrinsic sampler integration -- we only needed
            # the precompute + this small per-realization eval.  Return a harmless lnL.
            return 0.0



    # Likelihood
    if not opts.time_marginalization:

      if opts.psi_marginalization:

        # psi is not one of the sampled parameters (see the sampler.add_parameter("psi", ...)
        # guard above) -- it is integrated out analytically at every extrinsic point by
        # factored_likelihood.NetworkLogLikelihoodPolarizationMarginalized, which sums the
        # closed-form quadrature over its uniform [0, pi) prior.  The value handed to the
        # 'psi' argument below is a placeholder: the antenna pattern is periodic with period
        # pi (F(psi) = F(0)*exp(-2i*psi)) and the function integrates over one full period, so
        # its return value does not depend on which reference psi the placeholder names --
        # verified in test/test_psi_marginalization.py.
        _psi_marg_detectors = list(rholms_intp.keys())
        _psi_marg_ln_prior_mass = psi_marginalization_ln_prior_mass

        def likelihood_function(right_ascension, declination, t_ref, phi_orb,
                inclination, distance):

            dec = numpy.copy(declination).astype(numpy.float64)
            if opts.declination_cosine_sampler:
                dec = numpy.pi/2 - numpy.arccos(dec)
            incl = numpy.copy(inclination).astype(numpy.float64)
            if opts.inclination_cosine_sampler:
                incl = numpy.arccos(incl)
            if opts.d_prior_redshift:
                distance = redshift_to_distance(distance)

            # use EXTREMELY many bits
            lnL = numpy.zeros(right_ascension.shape,dtype=RiftFloat)
            i = 0
            for ph, th, tr, phr, ic, di in zip(right_ascension, dec,
                    t_ref, phi_orb, incl, distance):
                P.phi = ph # right ascension
                P.theta = th # declination
                P.tref = fiducial_epoch + tr # ref. time (rel to epoch for data taking)
                P.phiref = phr # ref. orbital phase
                P.incl = ic # inclination
                P.dist = di* 1.e6 * lalsimutils.lsu_PC # luminosity distance

                lnL[i] = factored_likelihood.NetworkLogLikelihoodPolarizationMarginalized(
                        fiducial_epoch, rholms_intp, cross_terms, cross_terms_V,
                        P.tref, P.phi, P.theta, P.incl, P.phiref, 0.0, P.dist,
                        opts.l_max, _psi_marg_detectors)
                i+=1
            # restore the prior mass the SAMPLED psi path carries (see the derivation at the
            # skipped sampler.add_parameter("psi") above): the analytic marginal is normalized,
            # this driver's psi prior is not.  Without it the flag reports lnZ - ln 2.
            lnL += _psi_marg_ln_prior_mass
            if return_lnL:
              return lnL - manual_avoid_overflow_logarithm
            return numpy.exp(lnL - manual_avoid_overflow_logarithm)

      else:

        def likelihood_function(right_ascension, declination, t_ref, phi_orb,
                inclination, psi, distance):

            dec = numpy.copy(declination).astype(numpy.float64)
            if opts.declination_cosine_sampler:
                dec = numpy.pi/2 - numpy.arccos(dec)
            incl = numpy.copy(inclination).astype(numpy.float64)
            if opts.inclination_cosine_sampler:
                incl = numpy.arccos(incl)
            if opts.d_prior_redshift:
                distance = redshift_to_distance(distance)

            # use EXTREMELY many bits
            lnL = numpy.zeros(right_ascension.shape,dtype=RiftFloat)
            i = 0
            for ph, th, tr, phr, ic, ps, di in zip(right_ascension, dec,
                    t_ref, phi_orb, incl, psi, distance):
                P.phi = ph # right ascension
                P.theta = th # declination
                P.tref = fiducial_epoch + tr # ref. time (rel to epoch for data taking)
                P.phiref = phr # ref. orbital phase
                P.incl = ic # inclination
                P.psi = ps # polarization angle
                P.dist = di* 1.e6 * lalsimutils.lsu_PC # luminosity distance

                lnL[i] = factored_likelihood.FactoredLogLikelihood(
                        P, rholms, rholms_intp, cross_terms, cross_terms_V,
                        opts.l_max)
                i+=1
            if return_lnL:
              return lnL - manual_avoid_overflow_logarithm
            return numpy.exp(lnL - manual_avoid_overflow_logarithm)

    else: # Sum over time for every point in other extrinsic params
     if not (opts.rom_integrate_intrinsic or opts.vectorized):
        def likelihood_function(right_ascension, declination, phi_orb, inclination,
                psi, distance):
            dec = numpy.copy(declination).astype(numpy.float64)  # get rid of 'object', and allocate space
            if opts.declination_cosine_sampler:
                dec = numpy.pi/2 - numpy.arccos(dec)
            incl = numpy.copy(inclination).astype(numpy.float64)
            if opts.inclination_cosine_sampler:
                incl = numpy.arccos(incl)
            if opts.d_prior_redshift:
                distance = redshift_to_distance(distance)

            # use EXTREMELY many bits
            lnL = numpy.zeros(right_ascension.shape,dtype=RiftFloat)
            i = 0
            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)
                P.phi = ph # right ascension
                P.theta = th # declination
                P.tref = fiducial_epoch  # see 'tvals', above
                P.phiref = phr # ref. orbital phase
                P.incl = ic # inclination
                P.psi = ps # polarization angle
                P.dist = di* 1.e6 * lalsimutils.lsu_PC # luminosity distance


                lnL[i] = factored_likelihood.FactoredLogLikelihoodTimeMarginalized(tvals,
                        P, rholms_intp, rholms, cross_terms, cross_terms_V,                   
                        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)
            if return_lnL:
              return lnL -manual_avoid_overflow_logarithm 
            return numpy.exp(lnL -manual_avoid_overflow_logarithm)

     elif opts.vectorized: # use array-based multiplications, fewer for loops
        if (not opts.gpu):
          if opts.distance_marginalization:
            print(" **Warning**: distance marginalization not being used, this is the old vectorized code path not the xpy path.  Check your code path; you may want --force-xpy ")
          def likelihood_function(right_ascension, declination, phi_orb, inclination,
                psi, distance):
#            global nEvals
            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)
            incl = numpy.copy(inclination).astype(numpy.float64)
            if opts.inclination_cosine_sampler:
              incl = numpy.arccos(incl)
            if opts.d_prior_redshift:
                distance = redshift_to_distance(distance)

            # use EXTREMELY many bits
            lnL = numpy.zeros(right_ascension.shape,dtype=RiftFloat)
            P.phi = right_ascension.astype(float)  # cast to float
            P.theta = dec #declination.astype(float)
            P.tref = float(fiducial_epoch)
            P.phiref = phi_orb.astype(float)
            P.incl = incl #inclination.astype(float)
            P.psi = psi.astype(float)
            P.dist = (distance* 1.e6 * lalsimutils.lsu_PC).astype(float) # luminosity distance

            # rotate sky if needed
            if opts.internal_sky_network_coordinates:
                  P.theta,P.phi = my_rotation(np.pi/2 - P.theta,P.phi)
                  P.theta = np.pi/2 - P.theta
                  P.phi = xpy_default.mod(P.phi, 2*np.pi)

            # rotate phase if needed
            # make copies of arrays
            #   Sampling assumes P.phiref == phi+ \psi ,   P.psi == phi - psi
            if opts.internal_rotate_phase:
              phi_orb_true = (P.phiref + P.psi)/2.
              psi_true = (P.phiref - P.psi)/2.
              P.psi= psi_true
              P.phiref = phi_orb_true

            if opts.rotation_slow and opts.freqresponse:
              lnL = factored_likelihood_rotating_freqresponse.DiscreteFactoredLogLikelihoodRotatingFreqResponseNoLoop(
                        tvals, P, rotating_freqresponse_data['meta'], rotating_freqresponse_data['lookupNKDict'],
                        rotating_freqresponse_data['rho_by_a'], rotating_freqresponse_data['U_by_aa'],
                        rotating_freqresponse_data['V_by_aa'], rotating_freqresponse_data['epochDict'],
                        Lmax=opts.l_max, time_interp=opts._noloop_time_interp)
            elif opts.rotation_slow:
              lnL = factored_likelihood_with_rotation.DiscreteFactoredLogLikelihoodViaArrayVectorNoLoopWithRotation(
                        tvals, P, rotation_slow_data['meta'], rotation_slow_data['lookupNKDict'],
                        rotation_slow_data['rho_by_n'], rotation_slow_data['U_by_nn'],
                        rotation_slow_data['V_by_nn'], rotation_slow_data['epochDict'], Lmax=opts.l_max,
                        time_interp=opts._noloop_time_interp)
            elif opts.freqresponse:
              lnL = factored_likelihood_freqresponse.DiscreteFactoredLogLikelihoodFreqResponseNoLoop(
                        tvals, P, freqresponse_data['meta'], freqresponse_data['lookupNKDict'],
                        freqresponse_data['rho_by_p'], freqresponse_data['U_by_pp'],
                        freqresponse_data['V_by_pp'], freqresponse_data['epochDict'], Lmax=opts.l_max,
                        time_interp=opts._noloop_time_interp)
            else:
              lnL = factored_likelihood.DiscreteFactoredLogLikelihoodViaArrayVector(tvals,
                        P, lookupNKDict, rholmArrayDict, ctUArrayDict, ctVArrayDict,epochDict,Lmax=opts.l_max)
#            nEvals +=len(right_ascension)
            if supplemental_ln_likelihood:
              lnL += supplemental_ln_likelihood(P.phi, P.theta, P.phiref ,P.incl, P.psi, P.dist) # use these variables so they are already float-type
            if return_lnL:
              return lnL -manual_avoid_overflow_logarithm
            return numpy.exp(lnL-manual_avoid_overflow_logarithm)
        else: # vectorized and gpu either available or being forced to use xpy code path
            print( " Using CUDA GPU likelihood, if cupy available ")
            if not opts.distance_marginalization:
              def likelihood_function(right_ascension, declination, phi_orb, inclination,
                psi, distance):
#                global nEvals
                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
                # mirrors the distance-marginalization likelihood_function below.
                P.phi = xpy_default.asarray(right_ascension, dtype=np.float64)
                if opts.declination_cosine_sampler:
                  P.theta = numpy.pi/2 - xpy_default.arccos(xpy_default.asarray(declination, dtype=np.float64))
                else:
                  P.theta = xpy_default.asarray(declination, dtype=np.float64)
                P.tref = float(fiducial_epoch)
                P.phiref = xpy_default.asarray(phi_orb, dtype=np.float64)
                if opts.inclination_cosine_sampler:
                  P.incl = xpy_default.arccos(xpy_default.asarray(inclination, dtype=np.float64))
                else:
                  P.incl = xpy_default.asarray(inclination, dtype=np.float64)
                if opts.d_prior_redshift:
                  distance = redshift_to_distance(distance)

                P.psi = xpy_default.asarray(psi, dtype=np.float64)
                if opts.internal_reparam_dl_incl:
                  # distance axis holds D_eff; reconstruct physical d_L = D_eff * A(iota)
                  distance = distance * _reparam_A_of_incl(P.incl, xpy=xpy_default)
                P.dist = xpy_default.asarray(distance* 1.e6 * lalsimutils.lsu_PC, dtype=np.float64) # luminosity distance

                # rotate sky if needed
                if opts.internal_sky_network_coordinates:
                  P.theta,P.phi = my_rotation(np.pi/2 - P.theta,P.phi)
                  P.theta = np.pi/2 - P.theta
                  P.phi = xpy_default.mod(P.phi, 2*np.pi)

                # rotate phase if needed
                # make copies of arrays
                #   Sampling assumes P.phiref == phi+ \psi ,   P.psi == phi - psi
                if opts.internal_rotate_phase:
                  phi_orb_true = (P.phiref + P.psi)/2.
                  psi_true = (P.phiref - P.psi)/2.
                  P.psi= psi_true
                  P.phiref = phi_orb_true


                if opts.rotation_slow and opts.freqresponse:
                  lnL = factored_likelihood_rotating_freqresponse.DiscreteFactoredLogLikelihoodRotatingFreqResponseNoLoop(
                        tvals, P, rotating_freqresponse_data['meta'], rotating_freqresponse_data['lookupNKDict'],
                        rotating_freqresponse_data['rho_by_a'], rotating_freqresponse_data['U_by_aa'],
                        rotating_freqresponse_data['V_by_aa'], rotating_freqresponse_data['epochDict'],
                        Lmax=opts.l_max, time_interp=opts._noloop_time_interp, xpy=xpy_default)
                elif opts.rotation_slow:
                  lnL = factored_likelihood_with_rotation.DiscreteFactoredLogLikelihoodViaArrayVectorNoLoopWithRotation(
                        tvals, P, rotation_slow_data['meta'], rotation_slow_data['lookupNKDict'],
                        rotation_slow_data['rho_by_n'], rotation_slow_data['U_by_nn'],
                        rotation_slow_data['V_by_nn'], rotation_slow_data['epochDict'], Lmax=opts.l_max,
                        time_interp=opts._noloop_time_interp, xpy=xpy_default)
                elif opts.freqresponse:
                  lnL = factored_likelihood_freqresponse.DiscreteFactoredLogLikelihoodFreqResponseNoLoop(
                        tvals, P, freqresponse_data['meta'], freqresponse_data['lookupNKDict'],
                        freqresponse_data['rho_by_p'], freqresponse_data['U_by_pp'],
                        freqresponse_data['V_by_pp'], freqresponse_data['epochDict'], Lmax=opts.l_max,
                        time_interp=opts._noloop_time_interp, xpy=xpy_default)
                else:
                  lnL = 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=('fused' if use_fused_calmarg and opts._noloop_time_interp == 'nearest' else 'loop'), cal_log_weights=calibration_log_weights,
                        time_interp=opts._noloop_time_interp,
                        ctUArrayDict_cal=ctUArrayDict_cal, ctVArrayDict_cal=ctVArrayDict_cal)  # non-distmarg: default-helper fused kernel (cal_distmarg=None)
#                nEvals +=len(right_ascension)
                if opts.internal_reparam_dl_incl:
                  # PRIOR-AGNOSTIC measure for the D_eff<->d_L reparam: the integrand needs
                  #   p_prior(d_L) * |dd_L/dD_eff|  but the sampler applied p_prior(D_eff),
                  # so add  ln p(d_L) - ln p(D_eff) + ln A , using the ACTUAL --d-prior
                  # (dist_prior_pdf).  Its normalization cancels in the ratio, so this is correct
                  # for Euclidean / cosmo / cosmo_sourceframe / pseudo_cosmo (reduces to +3 ln A
                  # only for Euclidean).  Then enforce the physical d_L in [dmin,dmax].
                  _A = _reparam_A_of_incl(P.incl, xpy=xpy_default)
                  _dl = distance                # physical d_L (reconstructed above)
                  _deff = _dl / _A              # the sampled D_eff
                  # physical prior normalized over [dmin,dmax] (=> - _REPARAM_LNF); sampler prior at D_eff; Jacobian A
                  lnL = lnL + (xpy_default.log(dist_prior_pdf(_dl)) - _REPARAM_LNF) - xpy_default.log(dist_prior_pdf(_deff)) + xpy_default.log(_A)
                  lnL = xpy_default.where((_dl >= dmin) & (_dl <= dmax), lnL, -1e300)
                if supplemental_ln_likelihood:
                  lnL += supplemental_ln_likelihood(P.phi, P.theta, P.phiref ,P.incl, P.psi, P.dist,xpy=xpy_default) # use these variables so they are already float-type
                if return_lnL:
                  return identity_convert_lnL(lnL -manual_avoid_overflow_logarithm)
                return identity_convert_lnL(xpy_default.exp(lnL-manual_avoid_overflow_logarithm))
            else:
              print( " Using direct distance marginalization  ")
              xmin = factored_likelihood.distMpcRef / dmax
              xmax = factored_likelihood.distMpcRef / dmin
              bmax = xpy_default.asarray(lookup_table["bmax"])
              sqrt_bmax = xpy_default.sqrt(bmax)
              bref = xpy_default.asarray(lookup_table["bref"])
              s_array = xpy_default.asarray(lookup_table["s_array"])
              smin = s_array[0]
              smax = s_array[-1]
              t_array = xpy_default.asarray(lookup_table["t_array"])
              tmax = t_array[-1]
              lnI_array = xpy_default.asarray(lookup_table["lnI_array"])

              intp = EvenBivariateLinearInterpolator(s_array[0], s_array[1] - s_array[0], t_array[0], t_array[1] - t_array[0], lnI_array)

              def exponent_max(x0, b):
                x0_expmax = xpy_default.clip(x0, a_min=xmin, a_max=xmax)
                return b * x0_expmax * (x0 - 0.5*x0_expmax)

              def b_to_t(b):
                # TODO: this function is duplicate with what is in util_InitMargTable
#                return np.arcsinh(b / bref)
                b_by_bref = b / bref
                return xpy_default.arcsinh(b_by_bref, out=b_by_bref)


              def x0_to_s(x0):
                # TODO: this function is duplicate with what is in util_InitMargTable
#                return np.arcsinh(np.sqrt(bmax) * (x0 - xmin)) - np.arcsinh(np.sqrt(bmax) * (xmax - x0))
                A = x0 - xmin
                A *= sqrt_bmax
                xpy_default.arcsinh(A, out=A)

                B = xmax - x0
                B *= sqrt_bmax
                xpy_default.arcsinh(B, out=B)

                A -= B
                return A

              def distmarg_loglikelihood(kappa_sq, rho_sq):
                x0 = kappa_sq / rho_sq
#                lnI = np.ones(shape=x0.shape) * -np.inf
                lnI = xpy_default.full_like(x0, -xpy_default.inf)
                s = x0_to_s(x0)
                t = b_to_t(rho_sq)
#                in_bounds = (s > smin) * (s < smax) * (t < tmax)
                in_bounds = (s > smin) & (s < smax) & (t < tmax)
                lnI[in_bounds] = intp(s[in_bounds], t[in_bounds])
                return exponent_max(x0, rho_sq) + lnI

              # Opt-in: package the distmarg table for the fused kernel (Option C).
              # Only used (below) at the non-phase-marg distmarg call site; the loop
              # method (Option B) remains the default and the fallback.  Works on GPU
              # and CPU (the fused path has a numpy backend), so no GPU gate here.
              cal_distmarg_dict = None
              if use_fused_calmarg:
                  cal_distmarg_dict = dict(
                    lnI_array=lnI_array,
                    s0=float(s_array[0]), ds=float(s_array[1] - s_array[0]),
                    smin=float(smin), smax=float(smax),
                    t0=float(t_array[0]), dt=float(t_array[1] - t_array[0]), tmax=float(tmax),
                    xmin=float(xmin), xmax=float(xmax),
                    sqrt_bmax=float(sqrt_bmax), bref=float(bref))

              if lookup_table["phase_marginalization"]:

                print( " Using direct phase marginalization  ")
                for det in lookupNKDict:
                  # ``lookupNKDict`` is moved to CuPy above.  Iterating its rows
                  # yields device arrays (and, with current CuPy, unhashable
                  # zero-dimensional array elements).  The inverse lookup stays
                  # on the host and already has canonical ``(l,m)`` tuple keys,
                  # so use it for this structural identity check.  This avoids a
                  # device round trip and is representation-independent.
                  modes_here = set(lookupKNDict[det])
                  if modes_here != {(2, 2), (2, -2)}:
                    raise Exception(
                        " Phase marginalization is implemented only for 2-2 modes, "
                        f"while the modes considered here are {sorted(modes_here)}."
                    )

                def likelihood_function(right_ascension, declination, inclination, psi):
#                  global nEvals
                  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))
                  else:
                    P.theta = xpy_default.asarray(declination,dtype=np.float64)
                  P.tref = float(fiducial_epoch)
                  P.phiref = xpy_default.full_like(inclination, 0., dtype=np.float64)
                  if opts.inclination_cosine_sampler:
                    P.incl = xpy_default.arccos(xpy_default.asarray(inclination, dtype=np.float64))
                  else:
                    P.incl = xpy_default.asarray(inclination, dtype=np.float64)

                  P.psi = xpy_default.asarray(psi, dtype=np.float64)
                  P.dist = xpy_default.asarray(factored_likelihood.distMpcRef * 1.e6 * lalsimutils.lsu_PC,dtype=np.float64) # luminosity distance

                  # rotate sky if needed
                  if opts.internal_sky_network_coordinates:
                    P.theta,P.phi = my_rotation(np.pi/2 - P.theta,P.phi)
                    P.theta = np.pi/2 - P.theta
                    P.phi = xpy_default.mod(P.phi, 2*np.pi)

                  # rotate phase if needed
                  # make copies of arrays
                  #   Sampling assumes P.phiref == phi+ \psi ,   P.psi == phi - psi
                  if opts.internal_rotate_phase:
                    phi_orb_true = (P.phiref + P.psi)/2.
                    psi_true = (P.phiref - P.psi)/2.
                    P.psi= psi_true
                    P.phiref = phi_orb_true

                  lnL = factored_likelihood.DiscreteFactoredLogLikelihoodViaArrayVectorNoLoop(tvals,
                    P, lookupNKDict, rholmArrayDict, ctUArrayDict, ctVArrayDict,epochDict,Lmax=opts.l_max,xpy=xpy_default, loglikelihood=distmarg_loglikelihood, phase_marginalization=True,n_cal=n_cal_for_likelihood,
                    cal_method=('fused' if cal_distmarg_dict is not None and opts._noloop_time_interp == 'nearest' else 'loop'), cal_distmarg=(cal_distmarg_dict if opts._noloop_time_interp == 'nearest' else None), cal_log_weights=calibration_log_weights,
                    time_interp=opts._noloop_time_interp,
                    ctUArrayDict_cal=ctUArrayDict_cal, ctVArrayDict_cal=ctVArrayDict_cal)
#                  nEvals +=len(right_ascension)
                  if supplemental_ln_likelihood:
                    lnL += supplemental_ln_likelihood(P.phi, P.theta, P.phiref ,P.incl, P.psi, 0,xpy=xpy_default) # Same API
                  if return_lnL:
                    return identity_convert_lnL(lnL -manual_avoid_overflow_logarithm)
                  return identity_convert_lnL(xpy_default.exp(lnL-manual_avoid_overflow_logarithm))

              else:

                def likelihood_function(right_ascension, declination, phi_orb, inclination, psi):
#                  global nEvals
                  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))
                  else:
                    P.theta = xpy_default.asarray(declination,dtype=np.float64)
                  P.tref = float(fiducial_epoch)
                  P.phiref = xpy_default.asarray(phi_orb, dtype=np.float64)
                  if opts.inclination_cosine_sampler:
                    P.incl = xpy_default.arccos(xpy_default.asarray(inclination, dtype=np.float64))
                  else:
                    P.incl = xpy_default.asarray(inclination, dtype=np.float64)
                  P.psi = xpy_default.asarray(psi, dtype=np.float64)
                  P.dist = xpy_default.asarray(factored_likelihood.distMpcRef * 1.e6 * lalsimutils.lsu_PC,dtype=np.float64) # luminosity distance

                  # rotate sky if needed
                  if opts.internal_sky_network_coordinates:
                    P.theta,P.phi = my_rotation(np.pi/2 - P.theta,P.phi)
                    P.theta = np.pi/2 - P.theta
                    P.phi = xpy_default.mod(P.phi, 2*np.pi)

                  # rotate phase if needed
                  # make copies of arrays
                  #   Sampling assumes P.phiref == phi+ \psi ,   P.psi == phi - psi
                  if opts.internal_rotate_phase:
                    phi_orb_true = (P.phiref + P.psi)/2.
                    psi_true = (P.phiref - P.psi)/2.
                    P.psi= psi_true
                    P.phiref = phi_orb_true

                  lnL = factored_likelihood.DiscreteFactoredLogLikelihoodViaArrayVectorNoLoop(tvals,
                    P, lookupNKDict, rholmArrayDict, ctUArrayDict, ctVArrayDict,epochDict,Lmax=opts.l_max,xpy=xpy_default, loglikelihood=distmarg_loglikelihood,n_cal=n_cal_for_likelihood,
                    cal_method=('fused' if cal_distmarg_dict is not None and opts._noloop_time_interp == 'nearest' else 'loop'), cal_distmarg=(cal_distmarg_dict if opts._noloop_time_interp == 'nearest' else None), cal_log_weights=calibration_log_weights,
                    time_interp=opts._noloop_time_interp,
                    ctUArrayDict_cal=ctUArrayDict_cal, ctVArrayDict_cal=ctVArrayDict_cal)
#                  nEvals +=len(right_ascension)
                  if supplemental_ln_likelihood:
                    lnL += supplemental_ln_likelihood(P.phi, P.theta, P.phiref ,P.incl, P.psi, 0,xpy=xpy_default) # Same API
                  if return_lnL:
                    return identity_convert_lnL(lnL -manual_avoid_overflow_logarithm)
                  return identity_convert_lnL(xpy_default.exp(lnL-manual_avoid_overflow_logarithm))

     else: # integrate over intrinsic variables. Right now those variables ahave HARDCODED NAMES, alas
        def likelihood_function(right_ascension, declination, phi_orb, inclination,
                psi, distance,q):
            dec = numpy.copy(declination).astype(numpy.float64)
            if opts.declination_cosine_sampler:
                dec = numpy.pi/2 - numpy.arccos(dec)
            incl = numpy.copy(inclination).astype(numpy.float64)
            if opts.inclination_cosine_sampler:
                incl = numpy.arccos(incl)
            if opts.d_prior_redshift:
              distance = redshift_to_distance(distance)

            lnL = numpy.zeros(len(right_ascension),dtype=RiftFloat)
#            i = 0
            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() 
            for ph, th, phr, ic, ps, di,qi in zip(right_ascension, dec,
                    phi_orb, incl, psi, distance,q):
                 # Reconstruct U,V using ROM fits.  PROBABLY should do this once for every q, rather than deep on the loop
                P.assign_param('q',qi)  # mass ratio
                rholms_intp_A, cross_terms_A, cross_terms_V_A, rholms_A, rest_A = factored_likelihood.ReconstructPrecomputedLikelihoodTermsROM(P, rest, rholms_intp, cross_terms, cross_terms_V, rholms,verbose=False)
                # proceed for rest
                P.phi = ph # right ascension
                P.theta = th # declination
                P.tref = fiducial_epoch  # see 'tvals', above
                P.phiref = phr # ref. orbital phase
                P.incl = ic # inclination
                P.psi = ps # polarization angle
                P.dist = di* 1.e6 * lalsimutils.lsu_PC # luminosity distance


                lnL[i] = factored_likelihood.FactoredLogLikelihoodTimeMarginalized(tvals,
                        P, rholms_intp_A, rholms_A, cross_terms_A, cross_terms_V_A,                   
                        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
#            t_end =lal.GPSTimeNow() 
#            print " Cost per evaluation ", (t_end - t_start)/len(q)
#            print " Max lnL for this iteration ", numpy.max(lnL)
            if return_lnL:
              return lnL -manual_avoid_overflow_logarithm
            return numpy.exp(lnL - manual_avoid_overflow_logarithm)

    if opts.sampler_method == "adaptive_cartesian_gpu":
      # reset sampling parameter as needed (distance, inclination but not sky location)
      # distance (and inclination) can conceivably correlate with mass, and we don't want to truncate in distance/inclination prematurely from history effects
      # method ONLY implemented for adaptcart!
      if 'distance' in sampler.params:
        sampler.reset_sampling('distance')
      sampler.reset_sampling('inclination')
    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:
            gmm_dict[pair_d_incl] = None # reset inclination/distance sampling
      single_incl = sampler_param_tuple(sampler, ['inclination'])
      if (opts.distance_marginalization) and single_incl in gmm_dict:
        gmm_dict[single_incl] = None # reset inclination sampling


    # Integrate
    args = likelihood_function.__code__.co_varnames[:likelihood_function.__code__.co_argcount]
    print( " --------> Arguments ", args)
    # if exactly zero likelihood function
    like_to_integrate = likelihood_function
    if opts.zero_likelihood:
              if opts.internal_use_lnL:
                   like_to_integrate = zero_like
              else:
                   like_to_integrate = unit_like
    if oracleRS:
         print("  ORACLE  - seeding sampling  with" , oracleRS.params_ordered)
         if hasattr(sampler, 'update_sampling_prior'):
             rvs_train = {}
             _, _, rv_oracle = oracleRS.draw_simplified(opts.n_chunk)
             for indx,p  in enumerate(sampler.params_ordered):
               rvs_train[p] = rv_oracle[:,indx]
             # train with equal weight - no likelihood information
             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)

    # Warm-start / bootstrap.  The AV (VARAHA) sampler has no update_sampling_prior, so the
    # skymap-oracle seeding above is skipped for it; instead seed its live volume directly
    # (bootstrap_from_samples).  A PORTFOLIO forwards the seed to its warm-startable members
    # (e.g. its AV member) via the same method; members without it stay cold, and the
    # balance-heuristic mixture density (q_mix) keeps a cold/mis-seeded member from biasing.
    # A seed only shapes the initial proposal, never the integral, so this cannot bias.
    # Fires for any sampler exposing bootstrap_from_samples (AV directly, or a portfolio).
    if hasattr(sampler, 'bootstrap_from_samples'):
        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)
            elif opts.sampler_warmstart_samples:
                _dat = np.genfromtxt(opts.sampler_warmstart_samples, names=True)
                _cols = np.vstack([np.asarray(_dat[p], dtype=float) for p in sampler.params_ordered]).T
                print("  warm-start: bootstrapping from", opts.sampler_warmstart_samples, _cols.shape,
                      "(cover_frac={}, inflate={})".format(opts.sampler_warmstart_cover_frac, opts.sampler_warmstart_inflate))
                # cover_frac + inflate are the handoff safety margins: this seed usually
                # comes from a DIFFERENT (cherry-picked pilot) point, so it must not be
                # able to bias if the peak has shifted.  Default them >0 in this path.
                sampler.bootstrap_from_samples(_cols,
                                               cover_frac=opts.sampler_warmstart_cover_frac,
                                               inflate=opts.sampler_warmstart_inflate)
            elif oracleRS:
                _, _, _rv_oracle = oracleRS.draw_simplified(opts.n_chunk)
                print("  AV warm-start: bootstrapping live volume from skymap oracle")
                sampler.bootstrap_from_samples(_rv_oracle, params=list(sampler.params_ordered))
        except Exception as _e_ws:
            print("  AV warm-start skipped (", _e_ws, ")")

    # NF flow reuse: warm-load a pre-trained flow so this instance skips/shortens training.
    if opts.nf_flow_load and hasattr(sampler, 'load_flow'):
        try:
            print("  NF: loading pre-trained flow from", opts.nf_flow_load)
            sampler.load_flow(opts.nf_flow_load)
        except Exception as _e_nf:
            print("  NF flow load skipped (", _e_nf, ")")

    # Optional ZERO-CAL burn-in (generally useful; see RIFT/calmarg/DESIGN_adaptive_driver.md).
    # Adapt the extrinsic sampler cheaply on the n_cal=1 baseline first, then run the full
    # cal-marginalized integration reusing the adapted proposal.  The likelihood closures
    # read the enclosing n_cal_for_likelihood, so toggling it to 1 makes this same
    # like_to_integrate evaluate the fast baseline.  Correctness is preserved regardless of
    # whether the sampler retains adaptation across the two integrate() calls -- worst case
    # the burn-in is simply wasted; the production integral below is always the full cal one.
    if opts.calibration_burn_in_neff and calibration_marginalization and n_cal_for_likelihood > 1:
        _ncal_full = n_cal_for_likelihood
        n_cal_for_likelihood = 1
        _burn_pinned = dict(pinned_params)
        _burn_pinned['neff'] = float(opts.calibration_burn_in_neff)
        _burn_pinned['nmax'] = int(opts.calibration_burn_in_nmax) if opts.calibration_burn_in_nmax else int(pinned_params.get('nmax', n_max))
        print(" [calmarg burn-in] adapting extrinsic sampler on ZERO-CAL likelihood -> neff>={:g} (nmax cap {})".format(_burn_pinned['neff'], _burn_pinned['nmax']))
        try:
            _b = sampler.integrate(like_to_integrate, *unpinned_params, **_burn_pinned)
            print(" [calmarg burn-in] done (burn-in neff ~ {}); switching to full cal marginalization".format(_b[2] if _b and len(_b) > 2 else '?'))
        except Exception as _eb:
            print(" [calmarg burn-in] integrate failed ({}); proceeding to production".format(_eb))
        n_cal_for_likelihood = _ncal_full   # restore: production uses the full cal set

    # opt-in anisotropic per-axis bin allocation: set 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")

    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.
    # This is SAME-problem reuse (the peak provably lies in the seed, since the cold
    # pass found it), so no coverage floor is needed (cover_frac=0) and it cannot
    # bias the result.  AV or a portfolio carrying an AV member; opt-in via
    # --sampler-warmstart-retry-neff.  For a portfolio the peak-seed is bootstrapped into its
    # warm-startable members (the AV live volume) and the whole mixture is re-run; the seed comes
    # from the run's OWN _rvs (already in the sampling frame), so it is coordinate-safe by construction.
    # A DEGENERATE EARLY TERMINATION (neff is None) is the strongest possible rescue trigger, not a
    # reason to skip: mcsamplerPortfolio/AV return (None,None,None,None) from their "terminate early"
    # branch when the live volume never finds finite in-volume samples -- i.e. exactly the cold, very
    # sharp peak this rescue exists for.  Such a pass still populates _rvs (it DID sample the peak,
    # it just could not build a volume around it), so the peak-seed below is available.  Treat
    # neff=None as "below threshold".
    _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 (opts.sampler_method in ('AV', 'portfolio') and opts.sampler_warmstart_retry_neff
            and hasattr(sampler, 'bootstrap_from_samples')
            and _needs_l0_rescue):
        # 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 of
            # min(n_extr, 1.5*eff_samp, 1.5*neff) rows taken WITH REPLACEMENT -- a resample
            # built for EXPORT.  On the collapsed pass this rescue exists for, eff_samp ~ 1,
            # so _rvs is one row (measured at rho_net 146.8: "Fairdraw size : 1"), and at
            # rho_net 102.8 it is five rows several of which are the same point twice, which
            # is where "5 seed points of affine rank 2" came from.  The live set held a
            # thousand.  integrate_log now stashes a bounded copy of the retained points
            # before that overwrite; fall back to _rvs for a sampler that does not keep one.
            # Shared with the --sampler-sequential-warmstart capture below; see
            # _warm_seed_reserve_for for the portfolio fallback and the column-order guard.
            _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.  The
                # rule here used to be `len(_seed) < 2`, and a count cannot see the failure
                # it was standing in for: a 2-to-5 point seed passes it 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 the [AV COLLAPSE] report
                # already prints, 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.  Three things that look like
                # fixes are not:
                #   * cover_frac is a finite sprinkle of points into the grid (2.9% of the box at
                #     d=6), not a mixture, so it gives no coverage guarantee;
                #   * pooling cold+warm propagates the bias in diluted form, because averaging Z is
                #     unbiased only when EVERY term is.  Measured on a bimodal target whose seed
                #     caught one mode: true -4.6052, cold -4.6265, warm -5.3009 (-log 2, the missed
                #     mode), pooled -4.9079 (log 0.75).  Pinned in test_replica_pooling.py;
                #   * a warning alone does not correct the number that gets reported.
                # AND THE OBVIOUS "REAL FIX" IS A TRAP.  Giving AV a defensive component with
                # support everywhere requires per-sample sampling densities (integrate_log applies
                # ONE scalar log_joint_s_prior to every sample), i.e. a proposal that is a weighted
                # mixture of components whose densities are evaluated per sample and combined.
                # That is mcsamplerPortfolio: q_mix, defensive members, and the coverage
                # bookkeeping around them already exist there, tested.  Rebuilding it inside AV
                # would leave two implementations of the same mathematics to keep in step, and the
                # bugs found in this review -- a capability flag that lied, a defensive component
                # absorbed by an update, config silently dropped on re-setup -- are precisely the
                # kind that appear when one of two parallel paths is updated and the other is not.
                # So this is NOT scoped as future work on AV.  The architectural answer is that a
                # run needing a coverage guarantee uses the portfolio; standalone AV stays a fast
                # single-proposal sampler with this limitation documented at its call site.
                #
                # WHAT THIS DOES, DELIBERATELY NARROWLY.  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 changes is only the
                # case where we have POSITIVE EVIDENCE of lost mass: the cold pass had full
                # support, so if the warm evidence lands well below it, the seed missed something
                # the cold pass reached.  There we keep the cold result rather than report the
                # precise-but-truncated one.  Detection is imperfect -- a missed mode need not
                # produce this ordering -- so this narrows the failure, it does not close it.
                # 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 below 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, the confidence interval and the
                # replica trigger downstream 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, for the same reason one level out -- see
                # _snapshot_pass_state.
                _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, fall back to reading BOTH from _rvs -- the old behaviour,
                # which is at least self-consistent -- rather than compare across conventions.
                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,
                                            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)
                    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 + manual_avoid_overflow_logarithm,
                              _cold_lnZ - _warm_lnZ,
                              _cold_lnZ + manual_avoid_overflow_logarithm))
                    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.  Without it --sampler-sequential-warmstart
                        # seeds 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.
                        res, var, neff, dict_return = _restore_pass_state(sampler, _cold_state_l0)
                _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 `res, var, neff, dict_return = ...` never
            # completed, so those 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.  Put the point back on the cold pass, in full, and say so loudly.
            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)
            _clear_warm_state(sampler)

    # Persist adapted state / trained flow for reuse by later instances.
    if opts.sampler_method == 'AV' and opts.sampler_save_state and hasattr(sampler, 'save_state'):
        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, ")")
    if opts.nf_flow_save and hasattr(sampler, 'save_flow'):
        try:
            sampler.save_flow(opts.nf_flow_save)
            print("  NF: saved trained flow to", opts.nf_flow_save)
        except Exception as _e_fs:
            print("  NF: could not save flow (", _e_fs, ")")

    if not(res): # no resut
      raise ValueError(" No integral result returned")

    if not(opts.internal_use_lnL):
      log_res = numpy.log(res)
      sqrt_var_over_res =  numpy.sqrt(var)/res
    else:
      log_res = res
      sqrt_var_over_res =  numpy.exp(var/2 - log_res)

    # ------------------------------------------------------------------
    # MC-error stabilization (see RIFT/integrators/statutils.py helpers).
    # The sampler's naive sigma is (1/ESS_hat - 1/n)^{1/2} computed from the SAME
    # weights as the integral: tail-blind, and small exactly when the run silently
    # missed the peak.  Three disclosed defenses:
    #  (1) floor sigma at the between-chunk lnZ scatter (adaptation nonstationarity);
    #  (2) print the Pareto k-hat tail diagnostic (k>0.7: sigma is a LOWER BOUND);
    #  (3) if triggered and --mc-error-replicas>0, re-run cold replicas and combine
    #      by the LINEAR mean with scatter-based error (never inverse-variance).
    # ------------------------------------------------------------------
    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)
    _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) + manual_avoid_overflow_logarithm, precision=4)))

    # LIVE-VOLUME COLLAPSE.  The sampler can now tell us that its live volume degenerated
    # (see live_volume_collapse_verdict in mcsamplerAdaptiveVolume).  Before this branch
    # such a run CRASHED, and the crash -- however badly attributed -- at least kept the
    # result out of the posterior.  Now that it completes we must not simply write an
    # ordinary likelihood row: lnZ and the exported samples describe a single mode, and
    # nothing downstream can tell them from a converged export.  So: say so unmistakably,
    # let it trigger the existing replication machinery, and offer a hard gate.
    def _reject_if_collapsed(dd, stage):
        """Apply --reject-collapsed-live-volume to whatever the CURRENT verdict is.

        Called twice on purpose.  The early call is a fast path: if the first run already
        collapsed and the user wants such events dropped, there is nothing to learn from
        spending GPU on replicas.  But it cannot be the only call -- replication can turn a
        healthy first run into a collapsed POOL, and the gate has to see that too, or the
        flag is silently bypassed for exactly the case the pooling introduced.
        """
        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', '')))

    _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, "first run")

    _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}<target {:g} (ran out of nmax)'.format(float(neff), float(_neff_target)))
    if _trigger_reasons:
        print(" [mc error] REPLICATING ({} extra cold runs): ".format(int(opts.mc_error_replicas)) + ", ".join(_trigger_reasons))
        _rep_lnZ = [float(log_res)]; _rep_sig = [float(sqrt_var_over_res)]; _rep_neff = [float(neff)]
        # Keep EVERY replica's samples.  Exporting only the highest-n_eff replica made the
        # posterior disagree with the evidence it was reported alongside: lnZ is the linear mean
        # over K replicas, so the samples must represent that same mixture.  Worse, n_eff is the
        # wrong selector -- it measures weight CONCENTRATION, not coverage, so a mode-collapsed
        # replica scores HIGHEST and would be the one exported.  (Measured elsewhere in this work:
        # the copy with the highest n_eff in its arm was the most biased, 11 nats low.)
        _rep_rvs = [sampler._rvs]
        # Provenance PER REPLICA, captured beside the record it describes.  The CLI flag is
        # not this: each pass decides independently whether to fair-draw (skipped when it
        # would not shrink that pass's record), so passing opts.fairdraw_extrinsic_output to
        # the pooler either flattens a replica whose importance weights are genuine, or leaves
        # 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
        # dict_return, which only ever held the FIRST run's verdict.
        _rep_collapsed = [bool(dict_return.get('live_volume_collapsed', False))
                          if isinstance(dict_return, dict) else False]
        _rep_collapse_why = [("run 1: " + dict_return['collapse_reason'])
                             if isinstance(dict_return, dict) and dict_return.get('collapse_reason')
                             else None]
        for _irep in range(int(opts.mc_error_replicas)):
            # cold restart: drop the sample cache and reset per-parameter adaptation so
            # this replica is independent of the runs before it (AV cold-starts by
            # construction; the AC/GPU sampler needs the explicit reset; samplers
            # without reset_sampling rerun warm -- still an independent realization of
            # the draws, just not of the adaptation).
            sampler._rvs = {}
            # PORTFOLIO first.  Neither mcsamplerPortfolio, mcsamplerAdaptiveVolume nor
            # mcsamplerEnsemble defines reset_sampling (only the AC/GPU sampler does), so the
            # loop below was a no-op for them -- and because portfolio.integrate_log does NOT
            # call self.setup(), a portfolio replica reran with the PREVIOUS replica's adapted
            # grid and fitted GMM.  Such replicas share the very adaptation whose failure they
            # exist to detect, so their scatter understates the true MC error.  clear_warm_state
            # rebuilds every member from its original setup arguments.  (Standalone AV is
            # already cold: its integrate_log calls setup() itself.)
            # reset_adaptation() is the FULL reset: member proposals via clear_warm_state PLUS the
            # portfolio's own learned state (draw allocation, quality EMAs and their counts, probe
            # pointer, iteration counter, n_ess histories).  Clearing only the members leaves each
            # replica scheduling itself from what the previous replicas learned, so they are not
            # adaptation-independent and the between-replica scatter -- the whole quantity being
            # measured -- still understates the error.
            if hasattr(sampler, 'reset_adaptation'):
                sampler.reset_adaptation()
            elif hasattr(sampler, 'clear_warm_state'):
                sampler.clear_warm_state()
            if hasattr(sampler, 'reset_sampling'):
                for _p in list(getattr(sampler, 'params_ordered', [])):
                    try:
                        sampler.reset_sampling(_p)
                    except Exception:
                        pass
            try:
                _r2 = sampler.integrate(like_to_integrate, *unpinned_params, **pinned_params)
            except Exception as _e_rep:
                print(" [mc error] replica {} failed ({}); continuing with {} results".format(_irep + 1, _e_rep, len(_rep_lnZ)))
                continue
            if (_r2 is None) or (_r2[0] is None):
                continue
            _res2, _var2, _neff2, _dd2 = _r2
            if not(opts.internal_use_lnL):
                _lr2 = numpy.log(_res2); _sig2 = numpy.sqrt(_var2)/_res2
            else:
                _lr2 = _res2; _sig2 = numpy.exp(_var2/2 - _lr2)
            _kh2, _sb2, _ne2, _unused = _extract_mc_diag(_dd2)
            if _sb2 is not None and numpy.isfinite(_sb2):
                _sig2 = max(float(_sig2), float(_sb2))
            _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'):
                _rep_collapse_why.append("replica {}: {}".format(_irep + 1, _dd2['collapse_reason']))
        # POOL the replicas rather than picking one, so the exported posterior is a draw from the
        # same mixture the reported evidence describes.
        #   Zhat = (1/K) sum_k (1/n_k) sum_i w_ki    ->    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,
                                        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
        # 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)
            # (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.
        _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 + manual_avoid_overflow_logarithm, precision=3), float(_lnZ_comb + manual_avoid_overflow_logarithm), 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.  With
            # --fairdraw-extrinsic-output-n-max at its default of 5 that reports n_eff = 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.
            # 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)
                _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, 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:
                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

    # Calibration MC error budget.  The sampler's `var` is the EXTRINSIC sampling
    # variance with the cal draw set held FIXED -- it is structurally blind to the
    # Monte-Carlo error of the (1/n_cal) sum over realizations, which dominates
    # whenever the cal n_eff is small (high SNR and/or broad envelopes).  Estimate
    # that term with the adaptive probe (extrinsic batch from the RUN'S priors,
    # incl. its distance prior; see _cal_error_probe) and add it IN QUADRATURE to
    # the reported sigma.  Never fatal.
    neff_cal = None; sigma_lnZ_cal = None
    if calibration_marginalization and n_cal_for_likelihood > 1 and opts.vectorized and opts.calibration_mc_error_extrinsic:
      try:
        sigma_lnZ_cal, neff_cal, _npr_err, _dmode_err = _cal_error_probe(n_cal_for_likelihood)
        _sigma_ext = float(sqrt_var_over_res)
        sqrt_var_over_res = numpy.sqrt(_sigma_ext**2 + sigma_lnZ_cal**2)
        print(" [calmarg error] sigma_lnZ: extrinsic {:.4f} (+) cal {:.4f} -> total {:.4f} ; cal n_eff {:.1f} / {} (probe {} pts, distance: {})".format(
              _sigma_ext, sigma_lnZ_cal, float(sqrt_var_over_res), neff_cal, n_cal_for_likelihood, _npr_err, _dmode_err))
        if neff_cal < 10:
            print(" [calmarg error] WARNING: cal n_eff < 10: the marginalization is dominated by a few draws and the quoted sigma is a LOWER BOUND.  Increase --calibration-n-realizations / the adaptive cap (--calibration-n-realizations-max).")
      except Exception as _e_cme:
        print(" WARNING: calibration MC error estimate failed ({}); reported sigma is extrinsic-only.".format(_e_cme))

    # Extrinsic handoff: after the integration, fit the run's extrinsic POSTERIOR to a
    # per-group GMM and write it as a breadcrumb, so a later iteration can seed its extrinsic
    # sampler (--extrinsic-proposal-breadcrumb).  The extrinsic posterior barely moves
    # iteration-to-iteration, so this lets the next run start on the answer.  Wrapped so a
    # harvest/fit failure can never break a production integration.
    if opts.extrinsic_proposal_output:
      try:
        import RIFT.calmarg.extrinsic_handoff as _ehmod, RIFT.calmarg.breadcrumbs as _ebcmod
        _rvs = sampler._rvs
        # TRUE importance log-weight = lnL + ln(prior) - ln(sampling_prior).  Build it from the
        # raw, UNTEMPERED components and prefer them over any stored 'log_weights': the GPU/AV
        # sampler (mcsamplerGPU) stores log_weights = tempering_exp*lnL + ln(prior) - ln(s_prior)
        # (the adapt-weight-exponent, e.g. 0.1, baked in) -- fitting the GMM to those flattened
        # weights places the proposal in the WRONG region.  GMM's own _rvs has no tempering.
        # Use the ONE canonical derivation.  The inline copy that used to live here carried the
        # same linear-only assumption ln_weights_from_rvs was just fixed for, so a GMM run storing
        # lnL in 'integrand' fitted the handoff proposal to log(lnL)-flattened weights.
        # POSTERIOR weights, not importance weights: under --fairdraw-extrinsic-output these
        # rows are already a w-proportional draw, and fitting the GMM with w on top of that
        # gives a proposal shaped like w^2 -- over-concentrated.  It is then handed to the NEXT
        # iteration via --extrinsic-proposal-breadcrumb, so the truncation compounds across
        # iterations rather than staying inside one run.
        _lw = np.asarray(ln_weights_for_posterior(_rvs, sampler, convert=identity_convert,
                                                  use_lnL=rvs_integrand_is_lnL), dtype=float)
        # extrinsic samples + bounds for the standard groups that this run actually sampled.
        _ext_params = [p for grp in _ehmod.STANDARD_GROUPS for p in grp]
        _ext_samples = {p: np.array(_rvs[p], dtype=float).reshape(-1) for p in _ext_params if p in _rvs}
        _ext_bounds = {p: (float(sampler.llim[p]), float(sampler.rlim[p])) for p in _ext_samples if p in sampler.llim}
        # restrict to finite-weight, fully-bounded samples
        _good = np.isfinite(_lw)
        for _p in list(_ext_samples):
          _good = _good & np.isfinite(_ext_samples[_p])
        _ext_samples = {p: v[_good] for p, v in _ext_samples.items() if p in _ext_bounds}
        _ext = _ehmod.fit_extrinsic_proposal(_ext_samples, log_weights=_lw[_good], bounds=_ext_bounds)
        # store the true lnL (peak-referenced) + sample count so a downstream consolidation
        # can pick the most representative (near-peak / best-converged) proposal to hand on.
        _ebcmod.save(opts.extrinsic_proposal_output, extrinsic=_ext,
                     meta=dict(event=int(indx_event), n_samples=int(_good.sum()),
                               lnL=float(log_res + manual_avoid_overflow_logarithm),
                               neff=float(neff),
                               groups=[g['params'] for g in _ext['groups']]))
        print(" Extrinsic proposal WRITTEN to {} ({} groups: {})".format(
            opts.extrinsic_proposal_output, len(_ext['groups']), [g['params'] for g in _ext['groups']]))
      except Exception as _e_eho:
        print(" WARNING: could not write extrinsic proposal to {} ({}); continuing.".format(opts.extrinsic_proposal_output, _e_eho))

    # MACHINE-READABLE INTEGRATOR STATUS, beside every other artifact.
    # A collapsed run is not distinguishable from a converged one in the .dat/.grid/XML
    # products: their schemas are positional and consumed by CIP, so a marker cannot go in
    # a new column without breaking every reader.  A sidecar can, and unlike a stdout
    # warning it survives into the pipeline.  Written on EVERY run, so "collapsed": false
    # is an explicit statement rather than an inference from a missing file -- downstream
    # can require the file and fail loudly on an integrator too old to write one.
    if opts.output_file and isinstance(dict_return, dict):
        _fn_status = opts.output_file + "_" + str(indx_event) + "_" + "integrator_status.json"
        try:
            # Same convention as the .dat writer below, INCLUDING its explicit
            # `opts.event is None -> -1` case.  int(None) raises, and because the write is
            # wrapped defensively that would have silently suppressed the whole sidecar for
            # any --sim-xml run without --event -- turning a missing marker into the default.
            _event_id = opts.event if (opts.sim_xml and opts.event is not None) else -1
            _status = {"event_id": int(_event_id),
                       "indx_event": int(indx_event),
                       "sampler_method": opts.sampler_method,
                       "collapsed": bool(dict_return.get('live_volume_collapsed', False)),
                       "collapse_reason": dict_return.get('collapse_reason', ''),
                       "lnL": float(log_res + manual_avoid_overflow_logarithm),
                       "sigma_lnL": float(sqrt_var_over_res),
                       "neff": float(sampler.identity_convert(neff)) if neff is not None else None,
                       "ntotal": int(sampler.ntotal)}
            # NOTE on pooling: lnL/sigma_lnL above are the POOLED values, while the
            # per-run diagnostics below (ESS, k-hat, live-set counts) are the first run's --
            # they are per-integration quantities and the pool has no single value for them.
            # n_replicas_pooled/_collapsed say how many runs are behind the export, and
            # collapse_reason names each collapsed one individually.
            for _k in ('pareto_khat', 'n_ESS', 'n_live_final', 'n_empty_cycles',
                       'n_live_collapses', 'n_warm_seed', 'n_warm_seed_rank', 'V_warm_start',
                       'n_replicas_pooled', 'n_replicas_collapsed'):
                if _k in dict_return and dict_return[_k] is not None:
                    _status[_k] = float(dict_return[_k]) if not isinstance(dict_return[_k], bool) else dict_return[_k]
            with open(_fn_status, 'w') as _f_status:
                json.dump(_status, _f_status, indent=1, sort_keys=True)
            if _status["collapsed"]:
                print(" [mc error] collapse recorded for downstream in {}".format(_fn_status))
        except Exception as _e_status:
            # Never let a diagnostic file abort a run that otherwise succeeded.
            print(" WARNING: could not write {} ({}); continuing.".format(_fn_status, _e_status))

    # Report results
    if opts.output_file and opts.sim_grid:
      fname_output_txt = opts.output_file +"_"+str(indx_event)+"_" + ".grid"
      from  RIFT.misc.samples_utils import add_field
      # grid file output: use preserved columns
      grid_out =grid_in.copy()
      if not('lnL' in grid_out.dtype.names):
        grid_out = add_field(grid_out, [('lnL',float)])
      if not('sigma_lnL' in grid_out.dtype.names):
        grid_out = add_field(grid_out, [('sigma_lnL',float)])
      grid_out['lnL'][indx_event] = log_res + manual_avoid_overflow_logarithm
      grid_out['sigma_lnL'][indx_event] = log_res + manual_avoid_overflow_logarithm
      line_out = np.array(list(grid_out[indx_event])) # convert tuple to list then to array - very silly
#      print(grid_out[indx_event], line_out, type(line_out))
      np.savetxt(fname_output_txt, [line_out], header=' '.join(grid_out.dtype.names) )
      
    # Report results (standard. Note this is BEFORE TIME RESAMPLING)
    if opts.output_file:
        fname_output_txt = opts.output_file +"_"+str(indx_event)+"_" + ".dat"
        m1 =P.m1/lal.MSUN_SI
        m2 =P.m2/lal.MSUN_SI
        if opts.sim_xml:
            event_id = opts.event
        else:
            event_id = -1
        if opts.event == None:
            event_id = -1
        # ------------------------------------------------------------------
        # Hyperpipeline ASCII output path (opt-in via env var).
        # When RIFT_HYPERPIPELINE_FORMAT is truthy we emit a self-describing
        # header-bearing file with `lnL sigma_lnL` as columns 0/1, followed
        # by parameter columns.  All legacy branches below are bypassed so
        # the file layout is determined by hyperpipeline_io.build_column_list.
        # ------------------------------------------------------------------
        from RIFT.misc import hyperpipeline_io as _hpio
        # Which optional parameter groups this row carries.  The hyperpipeline
        # writer and the legacy writer below consume the SAME flags, so a run
        # that enables several groups at once emits all of them in either
        # format instead of only whichever one wins a branch dispatch.
        _use_ecc = bool(opts.save_eccentricity)
        _use_mpa = bool(_use_ecc and opts.save_meanPerAno)
        _use_tides = bool(P.lambda1>0 or P.lambda2>0)
        _use_eos_index = bool(_use_tides and opts.export_eos_index)
        _use_eob = bool(opts.save_EOB_parameters)
        _use_hyp = bool(opts.save_hyperbolic)
        _use_distance = bool(opts.pin_distance_to_sim and not any(
            (_use_tides, _use_ecc, _use_eob, _use_hyp)))
        if _hpio.is_active():
            _cols = _hpio.build_column_list(
                use_eccentricity=_use_ecc, use_meanPerAno=_use_mpa,
                use_tides=_use_tides, use_eos_index=_use_eos_index,
                use_eob_parameters=_use_eob, use_hyperbolic=_use_hyp,
                use_distance=_use_distance)
            _vals = {
                "lnL": log_res+manual_avoid_overflow_logarithm,
                "sigma_lnL": sqrt_var_over_res,
                "m1": m1, "m2": m2,
                "a1x": P.s1x, "a1y": P.s1y, "a1z": P.s1z,
                "a2x": P.s2x, "a2y": P.s2y, "a2z": P.s2z,
            }
            if _use_ecc:
                _vals["eccentricity"] = P.eccentricity
                if _use_mpa:
                    _vals["meanPerAno"] = P.meanPerAno
            if _use_tides:
                _vals["lambda1"] = P.lambda1
                _vals["lambda2"] = P.lambda2
                if _use_eos_index:
                    _vals["eos_table_index"] = P.eos_table_index
            if _use_eob:
                _vals["a6c"] = P.a6c
            if _use_hyp:
                _vals["E0"] = P.E0
                _vals["p_phi0"] = P.p_phi0
            if _use_distance:
                _vals["distance"] = pinned_params["distance"]
            _hpio.write_row(fname_output_txt, _cols, [_vals[c] for c in _cols])
        else:
          # ----------------------------------------------------------------
          # Legacy ASCII row, built COMPOSITIONALLY (one `if` per enabled
          # group), not by dispatching to one branch per combination.  The
          # optional columns must appear in the same order as CIP's `col_lnL`
          # increment chain in
          # util_ConstructIntrinsicPosterior_GenericCoordinates.py:
          #
          #   event_id m1 m2 s1x s1y s1z s2x s2y s2z
          #   [distance] [lambda1 lambda2 [eos_table_index]] [a6c] [E0 p_phi0]
          #   [eccentricity [meanPerAno]]
          #   lnL sigma_lnL ntotal neff
          #
          # A branch-per-combination layout silently dropped every group but
          # the first (e.g. --save-eccentricity won over a6c/E0/p_phi0) while
          # CIP still allocated columns for each enabled group, so it read the
          # likelihood/statistics columns as physical parameters.
          # ----------------------------------------------------------------
          _row = [event_id, m1, m2, P.s1x, P.s1y, P.s1z, P.s2x, P.s2y, P.s2z]
          if _use_distance:
              _row += [pinned_params["distance"]]
          if _use_tides:
              _row += [P.lambda1, P.lambda2]
              if _use_eos_index:
                  _row += [P.eos_table_index]
          if _use_eob:
              _row += [P.a6c]
          if _use_hyp:
              _row += [P.E0, P.p_phi0]
          if _use_ecc:
              _row += [P.eccentricity]
              if _use_mpa:
                  _row += [P.meanPerAno]
          _row += [log_res+manual_avoid_overflow_logarithm, sqrt_var_over_res, sampler.ntotal, neff]
          numpy.savetxt(fname_output_txt, numpy.array([_row]))

        # Per-intrinsic likelihood-vs-distance grid. Pure extrinsic-marginalized
        # likelihood as a function of d_L: divides out the distance sampling
        # prior so downstream can re-marginalize with any prior of choice.
        if opts.output_file and opts.export_marginal_distance_grid and not(opts.distance_marginalization) and opts.internal_use_lnL:
          from RIFT.misc.distance_grid import build_distance_grid, save_distance_grid
          fname_output_dgrid = opts.output_file +"_"+str(indx_event)+"_" + ".dgrid"
          dL = np.array(sampler._rvs["distance"])
          rvs = sampler._rvs
          # POSTERIOR weights: a fair-drawn record is already an equal-weight posterior draw,
          # and build_distance_grid weights the samples again to form the per-bin mass.
          ln_wts = ln_weights_for_posterior(rvs, sampler, use_lnL=rvs_integrand_is_lnL)
          # Distance prior at each sample. Use the sampler's stored prior_pdf
          # callable; this matches whatever ILE actually integrated against
          # (volumetric, pseudo_cosmo, redshift, ...).
          prior_pdf_d = sampler.prior_pdf["distance"]
          pi_d_samp = np.asarray(prior_pdf_d(dL), dtype=float)
          # Guard: prior must be strictly positive at sampled points
          pi_d_samp = np.where(pi_d_samp > 0, pi_d_samp, np.finfo(float).tiny)
          ln_prior_d_samp = np.log(pi_d_samp)
          params_out = {
              "m1": P.m1/lal.MSUN_SI,
              "m2": P.m2/lal.MSUN_SI,
              "s1x": P.s1x,
              "s1y": P.s1y,
              "s1z": P.s1z,
              "s2x": P.s2x,
              "s2y": P.s2y,
              "s2z": P.s2z,
              "lambda1": P.lambda1,
              "lambda2": P.lambda2,
              "eccentricity": P.eccentricity,
              "meanPerAno": P.meanPerAno,
              "eos_index": getattr(P, "eos_table_index", 0),
          }
          dgrid = build_distance_grid(
              dL,
              ln_wts,
              log_res + manual_avoid_overflow_logarithm,
              sqrt_var_over_res,
              params_out,
              ln_prior_d_at_samples=ln_prior_d_samp,
              n_grid=opts.n_eff,
          )
          save_distance_grid(fname_output_dgrid, dgrid)

        # Plan-B distance slices: K independent fixed-d extrinsic integrals.
        # Produces a (K rows x intrinsic cols) table per ILE job; target size
        # ~10x .composite when K~=10. See RIFT/misc/distance_slices.py.
        if (opts.output_file and opts.export_distance_slices and opts.export_distance_slices > 0
            and not(opts.distance_marginalization) and opts.internal_use_lnL):
          from RIFT.misc import distance_slices
          fname_output_dslice = opts.output_file + "_" + str(indx_event) + "_" + ".dslice"
          K = int(opts.export_distance_slices)
          # B2-reweight relies on a healthy main n_eff so that Omega samples
          # are a good importance sample at every slice distance. GMM at low
          # n_eff biases the reweighting silently; flag it so the user knows
          # to switch sampler or raise n-max.
          if opts.sampler_method == "GMM" and neff < 50:
            print("  WARNING: --export-distance-slices with --sampler-method GMM at main n_eff={:.1f} (<50). ".format(neff)
                  + "B2-reweight may be biased; prefer --sampler-method AV or raise --n-max.")
          # On GPU the sampler stores CUPY arrays in _rvs / pdf outputs; convert
          # to numpy at the boundary (identity_convert = cupy.asnumpy, or a no-op
          # without cupy) before ANY numpy math. Otherwise cupy raises "Implicit
          # conversion to a NumPy array is not allowed", analyze_event throws, and
          # EVERY binary is skipped -> empty .dslice (silent failure).
          dL_samp = np.asarray(identity_convert(sampler._rvs["distance"]), float)
          # ln(pi_d) at samples uses the actual sampler prior (volumetric or
          # pseudo-cosmo, whichever was registered).
          prior_pdf_d = sampler.prior_pdf["distance"]
          pi_d_samp = np.asarray(identity_convert(prior_pdf_d(dL_samp)), float)
          pi_d_samp = np.where(pi_d_samp > 0, pi_d_samp, np.finfo(float).tiny)
          ln_pi_d_samp = np.log(pi_d_samp)
          # ln(q_d) at samples; for the standard ILE path the proposal is the
          # normalized sampler.pdf['distance'] divided by sampler._pdf_norm
          # (for the basic mcsampler) or applied directly (Ensemble normalizes
          # internally). For our purposes the joint_prior/joint_s_prior column
          # already encodes the ratio across all dims, so we only need pi_d
          # and q_d at the SAMPLES to isolate the Omega-only factor. Compute
          # q_d as a normalized 1-D density on the supported range.
          try:
            q_d_raw = np.asarray(identity_convert(sampler.pdf["distance"](dL_samp)), float)
          except Exception:
            q_d_raw = np.ones_like(dL_samp)
          q_d_norm = float(getattr(sampler, "_pdf_norm", {}).get("distance", 1.0)) or 1.0
          q_d_samp = q_d_raw / q_d_norm
          q_d_samp = np.where(q_d_samp > 0, q_d_samp, np.finfo(float).tiny)
          ln_q_d_samp = np.log(q_d_samp)
          # Pick slice centers from the full posterior on d. Recover the
          # log-importance weights with the same fallback chain we use for
          # .dgrid.
          rvs = sampler._rvs
          _rvs = lambda k: np.asarray(identity_convert(rvs[k]), float)  # cupy-safe column read
          ln_w_full = ln_weights_for_posterior(rvs, sampler, convert=identity_convert,
                                               use_lnL=rvs_integrand_is_lnL)
          # Split K into core (reweight) and wing (fresh) slices.  --distance-slice-all-fresh
          # forces zero reweight core: EVERY slice is a fresh fixed-d integration.  Use it
          # when the main-loop n_eff is small -- the reweight core is then starved (the same
          # MC noise as the .dgrid fair-draw histogram), while each fresh slice is honest.
          all_fresh = bool(getattr(opts, "distance_slice_all_fresh", False))
          # THE REWEIGHT CORE CANNOT RUN ON A FAIR-DRAWN RECORD.  It reuses the Omega samples
          # as an importance sample at each slice distance, applying pi_Omega/q_Omega on top of
          # rows that were already resampled proportional to the full weight -- so the
          # prior/proposal ratio is counted twice, and N = len(rvs['distance']) is the resample
          # size rather than the number of draws.  Unlike .dgrid this is not a single spurious
          # factor that can be divided out: the correct estimator would need the pre-draw
          # record, which by then is gone.
          #
          # 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.
          # 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.")
              all_fresh = True
          if all_fresh:
            n_core, n_wing = 0, K
          else:
            n_core = int(opts.n_distance_slice_core) or int(np.ceil(0.6 * K))
            n_wing = int(opts.n_distance_slice_wing) or (K - n_core)
            n_core = max(1, min(n_core, K))
            n_wing = max(0, min(n_wing, K - n_core))

          # Core: importance-reweight at quantile centers of the posterior (skipped if all_fresh).
          if n_core > 0:
            d_core = distance_slices.quantile_slice_centers(dL_samp, ln_w_full, n_core)
            lnL_core, sigmaL_core, neff_core, ntotal_core = distance_slices.importance_reweight_slices(
                sampler, like_to_integrate, d_core,
                ln_prior_d_at_samples=ln_pi_d_samp,
                ln_proposal_d_at_samples=ln_q_d_samp,
                manual_overflow=manual_avoid_overflow_logarithm,
                return_lnL=return_lnL,
            )
            if opts.sampler_method == "GMM" and neff < 50:
              print("  WARNING: --export-distance-slices with --sampler-method GMM at main n_eff={:.1f} (<50). ".format(neff)
                    + "B2-reweight may be biased; prefer --sampler-method AV or raise --n-max.")
          else:
            d_core = np.array([])
            lnL_core = np.array([]); sigmaL_core = np.array([])
            neff_core = np.array([]); ntotal_core = 0

          # Wings: only run if (a) we asked for any, (b) core suggests a
          # real distance posterior shape worth probing. Otherwise the
          # likelihood is flat in d and fresh wings are wasted compute.
          d_wings = np.array([])
          lnL_wings = np.array([]); sigmaL_wings = np.array([]); neff_wings = np.array([]); ntotal_wings = np.array([], dtype=int)
          # Block size for the fresh per-slice integrations: inherit --n-chunk (the
          # main extrinsic loop's block size) unless --distance-slice-chunk says
          # otherwise. Resolved once here so the advisories print once per event.
          slice_chunk = distance_slices.resolve_slice_chunk(
              getattr(opts, "distance_slice_chunk", None), opts.n_chunk)
          if all_fresh:
            # All K slices fresh.  Place centers at posterior-d quantiles: rough
            # placement is fine even at low n_eff -- the precision of each row comes
            # from its own fresh fixed-d integral, not from where it is centered.
            d_wings = distance_slices.quantile_slice_centers(dL_samp, ln_w_full, K, randomize=getattr(opts, "distance_slice_randomize", False))
            print("    : running {} fresh integrations (all-fresh{}; no reweight core)".format(len(d_wings), ", randomized-d" if getattr(opts, "distance_slice_randomize", False) else ""))
            lnL_wings_raw, sigmaL_wings, neff_wings, ntotal_wings = distance_slices.fresh_sample_slices(
                sampler, like_to_integrate, d_wings,
                n_max=int(opts.distance_slice_wing_nmax),
                n_eff_target=int(opts.distance_slice_wing_neff),
                n_chunk=slice_chunk,
                return_lnL=return_lnL,
            )
            lnL_wings = lnL_wings_raw + manual_avoid_overflow_logarithm
          elif n_wing > 0:
            if distance_slices.is_uninformative(lnL_core, threshold=opts.distance_slice_skip_threshold):
              print("    : peak core lnL < {:.2f} nats (effectively undetected); skipping {} wing fresh integrations".format(
                  opts.distance_slice_skip_threshold, n_wing))
            else:
              # Place wings via the parabolic lnL(1/d) model fit to the core,
              # so wing budget concentrates where the likelihood has support.
              lnL_peak_core = float(np.nanmax(lnL_core)) if np.any(np.isfinite(lnL_core)) else None
              d_wings = distance_slices.pick_wing_centers(
                  float(sampler.llim["distance"]),
                  float(sampler.rlim["distance"]),
                  d_core, n_wing,
                  lnL_core=lnL_core, lnL_peak=lnL_peak_core,
                  delta_lnL_target=opts.distance_slice_wing_delta_lnL,
              )
              if len(d_wings) == 0:
                print("    : no room outside core for wing slices; skipping")
              else:
                print("    : running {} wing fresh integrations".format(len(d_wings)))
                lnL_wings_raw, sigmaL_wings, neff_wings, ntotal_wings = distance_slices.fresh_sample_slices(
                    sampler, like_to_integrate, d_wings,
                    n_max=int(opts.distance_slice_wing_nmax),
                    n_eff_target=int(opts.distance_slice_wing_neff),
                    n_chunk=slice_chunk,
                    return_lnL=return_lnL,
                )
                # fresh_sample_slices returns ln integral of L_with_overflow;
                # restore the overflow scale so wing lnL is on the same axis
                # as the core slice (and as log_res).
                lnL_wings = lnL_wings_raw + manual_avoid_overflow_logarithm

          # Combine core + wings, sort by distance.
          d_all = np.concatenate([np.asarray(d_core, float), np.asarray(d_wings, float)])
          lnL_all = np.concatenate([lnL_core, lnL_wings])
          sigmaL_all = np.concatenate([sigmaL_core, sigmaL_wings])
          neff_all = np.concatenate([neff_core, neff_wings])
          ntotal_all = np.concatenate([
              np.full(len(d_core), ntotal_core, dtype=int),
              np.asarray(ntotal_wings, dtype=int),
          ])
          method_all = np.concatenate([
              np.full(len(d_core), distance_slices.METHOD_REWEIGHT, dtype=int),
              np.full(len(d_wings), distance_slices.METHOD_FRESH, dtype=int),
          ])
          ln_pi_d_all = np.log(np.maximum(np.asarray(identity_convert(prior_pdf_d(d_all)), float), np.finfo(float).tiny))
          order = np.argsort(d_all)

          try:
            params_out  # noqa: F823
          except NameError:
            params_out = {
                "m1": P.m1/lal.MSUN_SI, "m2": P.m2/lal.MSUN_SI,
                "s1x": P.s1x, "s1y": P.s1y, "s1z": P.s1z,
                "s2x": P.s2x, "s2y": P.s2y, "s2z": P.s2z,
                "lambda1": P.lambda1, "lambda2": P.lambda2,
                "eccentricity": P.eccentricity, "meanPerAno": P.meanPerAno,
                "eos_index": getattr(P, "eos_table_index", 0),
            }
          # Pass per-row method (build_distance_slice_table accepts a scalar
          # method; we extend by writing the method field directly after).
          slice_table = distance_slices.build_distance_slice_table(
              d_all[order], lnL_all[order], sigmaL_all[order], neff_all[order],
              0, distance_slices.METHOD_REWEIGHT, params_out,
              ln_prior_d_at_slices=ln_pi_d_all[order],
          )
          slice_table["ntotal"] = ntotal_all[order].astype(float)
          slice_table["method"] = method_all[order].astype(float)
          distance_slices.save_distance_slice_table(fname_output_dslice, slice_table)
          print("    : wrote distance slices to {} ({} core + {} wings)".format(
              fname_output_dslice, len(d_core), len(d_wings)))

    # Comprehensive output (not yet provided)
    # Convert declination, inclination  parameters in sampler if needed
    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!
      if opts.fairdraw_extrinsic_output:
        # The sampler intentionally skips its internal fair draw when it would
        # not shrink a tiny retained record (PR #87).  Internal consumers still
        # have the weights in that case, but XML does not preserve their full
        # provenance.  Complete the draw at the serialization boundary so the
        # external file always satisfies the equal-weight contract promised by
        # --fairdraw-extrinsic-output.  This also flattens replica-pooled output
        # according to the pooled posterior weights.
        samples = _equal_weight_fairdraw_for_serialization(
          samples, sampler,
          min(opts.fairdraw_extrinsic_output_n_max, opts.n_eff),
          convert=identity_convert, use_lnL=rvs_integrand_is_lnL)
      # Polarization column under --psi-marginalization.  There is no per-sample psi draw:
      # NetworkLogLikelihoodPolarizationMarginalized integrated psi out, it never sampled one.
      # Write NaN, NOT a fiducial 0.0.  bin/convert_output_format_ile2inference copies this
      # column verbatim into the PE-samples 'psi' column under a header that does not mark it,
      # and the phi_orb/distance precedents for a fiducial value are always followed by a
      # resampling step that psi has none of -- so a fiducial 0.0 reaches a consumer as a delta
      # function at 0 that reads like a polarization MEASUREMENT.  NaN cannot.
      # Shaped off 'right_ascension' (always sampled on this path) rather than off 'psi', since
      # 'psi' is exactly the key that is missing.
      if opts.psi_marginalization:
        if "psi" in samples:
          raise ValueError(
            "--psi-marginalization: the sampler returned a 'psi' column, but psi was supposed "
            "to be integrated out analytically and never sampled.  Refusing to export a "
            "polarization column whose provenance is unknown.")
        samples["psi"] = np.full_like(
          np.asarray(samples["right_ascension"], dtype=np.float64), np.nan)
        print("  --psi-marginalization: exported 'psi'/'polarization' column is NaN -- psi was "
              "marginalized, not sampled; there is nothing to report per sample.")
      elif "psi" not in samples:
        # Never silently invent one: every other route to this block samples psi, so a missing
        # column here is a wiring bug, and the old fallback would have written a fake 0.0.
        raise KeyError(
          "no 'psi' column in the retained samples, and --psi-marginalization was not requested; "
          "refusing to write a fabricated polarization column")
      # Insert reference distance if it was marginalized over
      if "distance" not in samples:
        # Not distance output is the same as internal calculations: in *Mpc*
        samples["distance"] = np.full_like(
          samples["psi"],
          factored_likelihood.distMpcRef,  #*1e6*lal.PC_SI,
          )
      # Insert reference phase if it was marginalized over
      if "phi_orb" not in samples:
        samples["phi_orb"] = np.full_like(samples["psi"], 0.)
      if opts.inclination_cosine_sampler:
        samples["inclination"] = numpy.arccos(samples["inclination"].astype(numpy.float64))
      if opts.declination_cosine_sampler:
        samples["declination"] = numpy.pi/2 - numpy.arccos(samples["declination"].astype(numpy.float64))
      if opts.d_prior_redshift:
        samples['redshift'] = samples['distance'] # new field
        samples['distance']  = redshift_to_distance(samples['distance'])
      xmldoc = ligolw.Document()
      xmldoc.appendChild(ligolw.LIGO_LW())
      if opts.save_samples_process_params:
        #process.register_to_xmldoc(xmldoc, sys.argv[0], opts.__dict__)
        xmldoc.register_process(sys.argv[0], opts.__dict__)
      else:
        # process_params is REQUIRED, so put in an empty one
        #process.register_to_xmldoc(xmldoc, sys.argv[0], {})
        xmldoc.register_process(sys.argv[0], {})
      if not(opts.resample_time_marginalization): 
        if not opts.time_marginalization:
            samples["t_ref"] += float(fiducial_epoch)
        else:
            samples["t_ref"] = float(fiducial_epoch)*numpy.ones(len(samples["psi"]))
      # rotate sky to recover physical coordinates.  Note on CPU
      if opts.internal_sky_network_coordinates:
        tmp_th, tmp_ph = my_rotation_cpu(np.pi/2 - samples['declination'],samples['right_ascension'])
        samples['declination'] = np.pi/2 - tmp_th
        samples['right_ascension'] = np.mod(tmp_ph, 2*np.pi)

      # recover true phase, polarization samples
      if opts.internal_rotate_phase:
        phi_orb_true = np.mod((samples['phi_orb'] + samples['psi'])/2., 2*np.pi)
        psi_true = np.mod((samples['phi_orb'] - samples['psi'])/2., 2*np.pi)   # keep as 2 pi range, to be consistent with past work
        samples['psi']= psi_true
        samples['phi_orb'] = phi_orb_true
      samples["polarization"] = samples["psi"]
      samples["coa_phase"] = samples["phi_orb"]        
      if ("declination", "right_ascension") in sampler.params:
            samples["latitude"], samples["longitude"] = samples[("declination", "right_ascension")]
      else:
            samples["latitude"] = samples["declination"]
            samples["longitude"] = samples["right_ascension"]
      # NOTE: the sim_inspiral XML schema is deliberately sparse -- it carries lnL only (below, mapped
      # to alpha1), NOT the importance weight.  It does NOT persist log_joint_prior/log_joint_s_prior,
      # so a downstream consumer CANNOT reconstruct the true weight (log_integrand + log_joint_prior -
      # log_joint_s_prior) from this file and must not reweight it by likelihood for a weighted-posterior
      # or shape check.  For richer, ASCII, per-sample output that carries the full log-weight, use
      # --extrinsic-proposal-output (writes lnL + ln(prior) - ln(s_prior)) or --calibration-export-posterior.
      if "log_integrand" in samples:
          samples["loglikelihood"] = samples["log_integrand"] +  manual_avoid_overflow_logarithm
      elif rvs_integrand_is_lnL:
          # raw-field record whose 'integrand' ALREADY holds lnL (GMM under return_lnI): logging it
          # again would write log(lnL) into alpha1 -- and nan for every row with lnL < 0.
          samples["loglikelihood"] = samples["integrand"] + manual_avoid_overflow_logarithm
      else:
          samples["loglikelihood"] = numpy.log(samples["integrand"]) + manual_avoid_overflow_logarithm  # export with consistent offset
      if not opts.rom_integrate_intrinsic:
            # ILE mode: insert fixed model parameters
            samples["mass1"] = numpy.ones(samples["psi"].shape)*m1 # opts.mass1
            samples["mass2"] = numpy.ones(samples["psi"].shape)*m2 # opts.mass2
            samples["spin1x"] =numpy.ones(samples["psi"].shape)*P.s1x
            samples["spin1y"] =numpy.ones(samples["psi"].shape)*P.s1y
            samples["spin1z"] =numpy.ones(samples["psi"].shape)*P.s1z
            samples["spin2x"] =numpy.ones(samples["psi"].shape)*P.s2x
            samples["spin2y"] =numpy.ones(samples["psi"].shape)*P.s2y
            samples["spin2z"] =numpy.ones(samples["psi"].shape)*P.s2z
            samples["alpha4"] =numpy.ones(samples["psi"].shape)*P.eccentricity
            samples["alpha"] =numpy.ones(samples["psi"].shape)*P.meanPerAno
            samples["psi0"] =numpy.ones(samples["psi"].shape)*P.a6c
            samples["beta"] =numpy.ones(samples["psi"].shape)*P.p_phi0
            samples["psi3"] =numpy.ones(samples["psi"].shape)*P.E0
            samples["alpha5"] =numpy.ones(samples["psi"].shape)*P.lambda1
            samples["alpha6"] =numpy.ones(samples["psi"].shape)*P.lambda2
            # Below exist solely to placate XML export; new issue as of latest lalsuite say 7.15+ or so
            samples["alpha2"] = numpy.zeros(samples["psi"].shape)
            samples["alpha3"] = numpy.zeros(samples["psi"].shape)

      if opts.resample_time_marginalization:
        samples = resample_samples(samples,lookupNKDict, rholmArrayDict, ctUArrayDict, ctVArrayDict,epochDict, n_cal=n_cal_for_likelihood, cal_log_weights=calibration_log_weights, ctUArrayDict_cal=ctUArrayDict_cal, ctVArrayDict_cal=ctVArrayDict_cal)
        samples["loglikelihood" ] = samples["lnL_raw"]  # export the non-time-marginalized likelihood, if we are in the final stages
#        print(samples['t_ref'] - fiducial_epoch, len(samples['t_ref']))
        # Recovered CALIBRATION posterior (opt-in): for each fair-draw sample, draw ONE cal
        # realization in proportion to its posterior weight (per-realization L_c * importance
        # weight w_c) and write a SELF-CONTAINED sibling <out>_<event>_cal.dat with the FULL
        # draw -- intrinsic + extrinsic + the drawn realization's spline nodes as labeled
        # cal_<IFO>_amp_<k>/cal_<IFO>_phase_<k> columns.  (The main XML/.dat schema cannot carry
        # arbitrary columns, so the cal posterior rides this sibling file, row-aligned.)
        _cal_nodes = calibration_nodes; _cal_dets = calibration_node_dets; _cal_namp = calibration_n_nodes_amp
        if (_cal_nodes is None) and (_calpilot is not None) and (_calpilot.get('nodes') is not None):
            _cal_nodes = _calpilot['nodes']; _cal_dets = list(_calpilot['dets']); _cal_namp = int(_calpilot['n_nodes_amp'])
        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 = 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).
            _comp = factored_likelihood.DiscreteFactoredLogLikelihoodViaArrayVectorNoLoop(_tv,
                       P, lookupNKDict, rholmArrayDict, ctUArrayDict, ctVArrayDict, epochDict,
                       Lmax=opts.l_max, xpy=xpy_default, n_cal=n_cal_for_likelihood,
                       cal_method='loop', return_cal_components=True, time_interp=opts._noloop_time_interp,
                       ctUArrayDict_cal=ctUArrayDict_cal, ctVArrayDict_cal=ctVArrayDict_cal)
            _comp = np.atleast_2d(np.asarray(identity_convert(_comp), dtype=float))   # (n_samples, n_cal)
            _calw = np.zeros(n_cal_for_likelihood) if calibration_log_weights is None else np.asarray(identity_convert(calibration_log_weights), dtype=float)
            _logp = _comp + _calw[None, :]                                  # posterior weight per (sample, realization)
            _logp = _logp - _logsumexp(_logp, axis=1, keepdims=True)
            _wp = np.exp(_logp); _ns = _comp.shape[0]
            _idx = np.array([np.random.choice(n_cal_for_likelihood, p=_wp[_i]) for _i in range(_ns)])
            _nodes_drawn = np.asarray(_cal_nodes)[_idx]                     # (n_samples, 2*namp*ndet)
            for _i_det, _ifo in enumerate(_cal_dets):
                _base = _i_det * 2 * _cal_namp
                for _k in range(_cal_namp):
                    samples["cal_%s_amp_%d"   % (_ifo, _k)] = _nodes_drawn[:, _base + _k]
                    samples["cal_%s_phase_%d" % (_ifo, _k)] = _nodes_drawn[:, _base + _cal_namp + _k]
            _ekeys = sorted(k for k in samples if isinstance(k, str) and np.ndim(samples[k]) == 1 and np.size(samples[k]) == _ns)
            _mat = np.column_stack([np.asarray(samples[k], dtype=float) for k in _ekeys])
            _fn_cal = opts.output_file + "_" + str(indx_event) + "_cal.dat"
            np.savetxt(_fn_cal, _mat, header=" ".join(_ekeys))
            print(" Calibration posterior (full fair draws + cal nodes) -> {} ; {} samples x {} cols ({} cal cols over {})".format(
                  _fn_cal, _ns, len(_ekeys), 2*_cal_namp*len(_cal_dets), _cal_dets))
          except Exception as _e_cep:
            print(" WARNING: --calibration-export-posterior failed ({}); skipping cal-posterior export.".format(_e_cep))
      xmlutils.append_samples_to_xmldoc(xmldoc, samples)
        # Extra metadata
      dict_out={"mass1": m1, "mass2": m2, "spin1z": P.s1z, "spin2z": P.s2z, "alpha4": P.eccentricity, "alpha": P.meanPerAno, "alpha5":P.lambda1, "alpha6":P.lambda2, "event_duration": sqrt_var_over_res, "ttotal": sampler.ntotal, "psi0": P.a6c, "psi3": P.E0, "beta": P.p_phi0}
#      if 'distance' in pinned_params:
#            dict_out['distance'] = pinned_params["distance"]
      converged_result = False
      if "convergence_test_results" in dict_return:
        converged_result = dict_return["convergence_test_results"]["normal_integral"]
      xmlutils.append_likelihood_result_to_xmldoc(xmldoc, log_res+manual_avoid_overflow_logarithm, neff=neff, converged=converged_result, **dict_out)
      fname_output_xml = opts.output_file +"_"+str(indx_event)+"_" + ".xml.gz"
      utils.write_filename(xmldoc, fname_output_xml, compress="gz")




    if opts.maximize_only and opts.output_file:
      # Pick the best extrinsic parameters, except for time (assumed not set: time marginalization)
      if "integrand" in sampler._rvs:
        indx_guess = numpy.argmax(sampler._rvs["integrand"])   # start search near maximum-likelihood point. (WARNING: can be very close by)
      else:
        indx_guess = numpy.argmax(sampler._rvs["log_integrand"])
      P.radec=True
      P.phi = sampler._rvs["right_ascension"][indx_guess]
      P.theta = sampler._rvs["declination"][indx_guess]
      P.phiref = sampler._rvs["phi_orb"][indx_guess]
      P.incl = sampler._rvs["inclination"][indx_guess]
      P.psi = sampler._rvs["psi"][indx_guess]
      P.dist = sampler._rvs["distance"][indx_guess]*1e6*lal.PC_SI
      print( " ---- Best extrinsic paramers in MC   ---- ")
      P.print_params()
      lalsimutils.ChooseWaveformParams_array_to_xml([P],"notime_raw_maxpt_"+opts.output_file) # best point, not including time

      import scipy.optimize
      def fn_scaled(x):
          P.phi = float(x[0]*2*numpy.pi) # right ascension
          P.theta = float((x[1])*numpy.pi) # declination, really polar angle. NOT zero on equator
          P.tref = fiducial_epoch + float((x[2]-0.5)*2*t_ref_wind) # ref. time (rel to epoch for data taking)
          P.phiref = float(x[3]*numpy.pi*2) # ref. orbital phase
          P.incl = float(x[4]*numpy.pi) # inclination
          P.psi = float(x[5]*numpy.pi) # polarization angle
          P.dist = x[6]*dmax* 1.e6 * lalsimutils.lsu_PC # luminosity distance
    
          return -1.0* factored_likelihood.FactoredLogLikelihood(
                    P, rholms,rholms_intp, cross_terms, cross_terms_V, opts.l_max)

    # Pick the best extrinsic parameters, except for time (assumed not set: time marginalization)
      x0 =numpy.array( [ \
       sampler._rvs["right_ascension"][indx_guess]/(2*numpy.pi) , \
       (sampler._rvs["declination"][indx_guess]/numpy.pi),  \
       0.5, \
       (sampler._rvs["phi_orb"][indx_guess]/(2*numpy.pi)), \
       sampler._rvs["inclination"][indx_guess]/(numpy.pi),\
       sampler._rvs["psi"][indx_guess]/numpy.pi,\
       sampler._rvs["distance"][indx_guess]/dmax\
            ],dtype=RiftFloat)
      x0 = numpy.fmod(x0,numpy.ones(len(x0)))  # had BETTER be defined on this range!
      # Pick the best starting time. BRUTE FORCE METHOD: use grid
      def fn_scaled_t(t,x0):
        return fn_scaled( [x0[0], x0[1], t, x0[3], x0[4], x0[5], x0[6]])
      npts_guess = int(t_ref_wind*2/(0.5*1e-4))   # Need to have enough points to fully explore the peak, timing to sub-ms accuracy
      print( " Using ", npts_guess, " time points to select the best time, fixing the remaining extrinsic parameters ")
      tvals_scaled_guess = numpy.linspace(0,1,npts_guess)
      lnLvals = numpy.array([-1*fn_scaled_t(t,x0) for t in tvals_scaled_guess])   # note the two -1's cancel
    #    from matplotlib import pyplot as plt;  plt.plot(tvals_scaled_guess,lnLvals,'o'); plt.show(); numpy.savetxt("dump-lnL.dat", numpy.array([tvals_scaled_guess*2*t_ref_wind,lnLvals]).T)
      tbest = tvals_scaled_guess[numpy.argmax(lnLvals)]
      print( " ---- Best extrinsic paramers in MC, after time offset   ---- ")
      P.tref = fiducial_epoch + tbest
      P.print_params()
      lalsimutils.ChooseWaveformParams_array_to_xml([P],"withtime_raw_maxpt_"+opts.output_file) # best point, not including time
      x0[2] = tbest
      x0p = x0
    # Refine best starting time with a search
    #    t_scaled_est = scipy.optimize.brent(fn_scaled_t, brack=(tbest-0.01,tbest,tbest+0.01),args=(x0),maxiter=500)
    #    print "Scaled starting time estimate after, before ",  t_scaled_est, tbest
    #    x0p[2] = t_scaled_est
      t_best_now = lal.LIGOTimeGPS()   # create correct data type
      t_best_now = fiducial_epoch + float((x0p[2]-0.5)*2*t_ref_wind)
      t_best_now_s = int(numpy.floor(t_best_now))
      t_best_now_ns = int((t_best_now - t_best_now_s)*1e9)
      print( " Fitting best (geocentric) time : ", str(t_best_now_s)+ '.'+ str(t_best_now_ns) , " relative time ", (x0p[2]-0.5)*2*t_ref_wind)
      x0  = x0p
    # Full multi-d search for best point
      print( " Starting point [dimensionless] ", x0)
      print( " Starting point [physical] ", [x0[0]*2*numpy.pi, numpy.pi*(x0[1]),  t_ref_wind*2*(x0[2]-0.5), 2*numpy.pi*x0[3], numpy.pi*x0[4], numpy.pi*x0[5], x0[6]*dmax])
      lnLstart = -1*fn_scaled(x0)   # note the two -1's cancel. Compare to 'rho^2/2' value reported by code
      print( " lnL at start :", lnLstart, " [note can be lower than peak because of time offset]: best reported by ILE (including weights) is ", numpy.log(numpy.max(sampler._rvs["integrand"])))
      x = scipy.optimize.fmin(fn_scaled,x0, xtol=1e-5,ftol=1e-3,maxiter=opts.n_max,maxfun=opts.n_max)
      lnLmax = -1.0*fn_scaled(x)
      if lnLmax < lnLstart:
        print( " Maximization failed to improve our initial point ! ")
        x = x0p
        lnLmax = lnLstart
      t_best_now = fiducial_epoch + float((x[2]-0.5)*2*t_ref_wind)
      print( "Best lnL = ", lnLmax)
      print( "Best time =", t_best_now)
      print( "Best point", [x[0]*2*numpy.pi, numpy.pi*(x[1]),  t_ref_wind*2*(x[2]-0.5), 2*numpy.pi*x[3], numpy.pi*x[4], numpy.pi*x[5], x[6]*dmax])
      # Output best fit.  Note STANDARD output will occur as usual
      P_max = P.manual_copy()
      P_max.tref = t_best_now
      P_max.phi = float(x[0]*2*numpy.pi)
      P_max.theta = float(numpy.pi*(x[1]))
      P_max.phiref =float(x[3]*numpy.pi*2)
      P_max.incl = float(x[4]*numpy.pi)
      P_max.psi = float(x[5]*numpy.pi)
      P_max.dist = x[6]*dmax*1e6*lal.PC_SI
      P_max.m1 = lal.MSUN_SI*m1
      P_max.m2 = lal.MSUN_SI*m2
      print( " Sanity check: log likelihood for this set is ", factored_likelihood.FactoredLogLikelihood(
                    P_max, rholms, rholms_intp, cross_terms, cross_terms_V, opts.l_max))
      print( " ---- Best extrinsic paramers after polishing   ---- ")
      P_max.print_params()
      P_list = [P_max]
      lalsimutils.ChooseWaveformParams_array_to_xml(P_list,"maxpt_"+opts.output_file)

    # Clear sampler _rvs, to avoid side effects when called again.
    # Reset the dict rather than setting each key to []: a key added AFTER the
    # fairdraw subset -- notably 'integrand', set in mcsamplerAdaptiveVolume.integrate()
    # under --internal-use-lnL -- would otherwise survive as a stale empty list and
    # crash the NEXT binary in a batched (n-events-to-analyze>1) run, where its
    # integrate_log() fairdraw does self._rvs[key][indx_list] and raises
    # "list indices must be integers or slices, not ndarray". This silently dropped
    # every binary after the first in each ILE batch whenever fairdraw export was on.
    # SEQUENTIAL WARM-START SEED must be captured BEFORE the _rvs wipe below.  The caller's capture
    # block runs after this function returns, by which point _rvs is empty -- so
    # --sampler-sequential-warmstart was silently inert (it always saw no samples and never seeded
    # the next point).  Stash the seed here instead; the caller consumes _SEQ_WS_PENDING.
    global _SEQ_WS_PENDING
    _SEQ_WS_PENDING = None
    if getattr(opts, 'sampler_sequential_warmstart', False) and hasattr(sampler, 'bootstrap_from_samples'):
        try:
            # SEED FROM THE RETAINED POINTS, AND JUDGE THE SEED BY RANK -- the same two rules
            # the L0 auto-rescue was given (PR #78), for the same reasons, because this is the
            # same construction one code path away.  Both were wrong here:
            #
            #   1. IT READ THE FAIR DRAW.  sampler._rvs has been rebound to
            #      min(n_extr, 1.5*eff_samp, 1.5*neff) rows resampled WITH REPLACEMENT for
            #      EXPORT.  --fairdraw-extrinsic-output is not an exotic setting: every
            #      extrinsic stage built by create_event_parameter_pipeline_BasicIteration,
            #      cepp_basic_htcondor and create_event_nr_pipeline_with_cip passes it
            #      unconditionally.  So on the collapsed high-amplitude pass this feature is
            #      most wanted for, the seed for the NEXT intrinsic point was a handful of
            #      rows -- one, in the rho_net 146.8 logs -- several of them the same point
            #      twice, while the live set held a thousand.
            #
            #   2. THE GUARD WAS A COUNT (`_lnv.size >= 2`, `np.sum(_keep) >= 2`), which is
            #      exactly the rule build_warm_seed exists to replace: n points span at most
            #      n-1 affine dimensions, so two rows drawn with replacement can be rank 0 in
            #      6 and still pass.  The next point then warm-starts inside a degenerate
            #      sliver and reports a healthy n_eff over truncated support -- the quiet
            #      failure, not the loud one.
            #
            # Fall back to _rvs only for a sampler that keeps no reserve, so the feature
            # degrades to its previous behaviour rather than to no seed at all.
            _res_ws = _warm_seed_reserve_for(sampler)
            if _res_ws is not None:
                _cols = np.asarray(_res_ws['X'], dtype=float)
                _lnv = np.asarray(_res_ws['lnL'], dtype=float).ravel()
                _src_ws = 'retained'
            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 is not None and all(p in sampler._rvs for p in sampler.params_ordered))
                        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))))
                _src_ws = 'fairdraw'
            if _lnv.size >= 1 and np.any(np.isfinite(_lnv)):
                # build_warm_seed applies the deltalnL window, the affine-rank test and the
                # puff-to-full-rank.  Reuse the L0 rescue's puff knobs rather than adding a
                # parallel set: it is the same geometry problem (a rank-deficient cloud about
                # a known peak) and two independently-tuned widths would be one more pair to
                # keep in step.
                _ax_ws, _lo_ws, _hi_ws = _warm_seed_geometry(sampler)
                _seed_ws, _info_ws = mcsamplerAdaptiveVolume.build_warm_seed(
                    _cols, _lnv, _lo_ws, _hi_ws, _ax_ws,
                    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)
                _SEQ_WS_PENDING = _seed_ws if len(_seed_ws) >= 2 else None
                if _info_ws['puffed']:
                    print("  [seq warm-start] seed from {} source: {} point(s) had affine rank"
                          " {}/{}: PUFFED to rank {}/{} with {} points".format(
                              _src_ws, _info_ws['n_core'], _info_ws['rank_core'], _info_ws['dim'],
                              _info_ws['rank_final'], _info_ws['dim'], _info_ws['n_puff']))
                    if _info_ws['rank_final'] < _info_ws['dim']:
                        print("  [seq warm-start] *** the puffed seed is STILL rank-deficient"
                              " ({}/{}); the next point may be reported as collapsed.".format(
                                  _info_ws['rank_final'], _info_ws['dim']))
        except Exception as _e_cap:
            print("  [seq warm-start] could not capture seed ({})".format(_e_cap))

    sampler._rvs = {}
    # _rvs_is_pooled is set by THIS function (the replica block), not by the sampler, so the
    # sampler's own per-pass reset cannot clear it.  Drop it with the record it describes, or
    # the next event inherits "pooled" and its exports lose the equal-weight treatment.
    sampler._rvs_is_pooled = False

    return res


lnL_sofar = -np.inf
no_adapt_sky = False

# L3: load a proposal field (from a previous ILE iteration) once, if provided
_proposal_field = None
if opts.extrinsic_proposal_field and hasattr(sampler, 'bootstrap_from_samples'):
    try:
        from RIFT.integrators.proposal_field import ProposalField, lambda_from_P
        _proposal_field = ProposalField.load(opts.extrinsic_proposal_field)
        print(" [proposal-field] loaded {} entries from {}".format(len(_proposal_field), opts.extrinsic_proposal_field))
    except Exception as _e_pf:
        print(" [proposal-field] could not load {} ({}); ignoring".format(opts.extrinsic_proposal_field, _e_pf))
        _proposal_field = None

_seq_ws_proposal = None   # L1 sequential hot-feed: previous point's extrinsic seed (in-memory)
_SEQ_WS_PENDING = None    # seed captured INSIDE analyze_event, before it wipes sampler._rvs
for indx in numpy.arange(len(P_list)):
 try:
  # UNCONDITIONAL per-point reset.  mcsamplerPortfolio.integrate_log does NOT call self.setup()
  # (it is commented out at mcsamplerPortfolio.py:874), so member state -- including the AV live
  # volume contracted around the PREVIOUS point -- survives into the next integral.  With
  # --n-events-to-analyze > 1 the second point then draws from a box shaped by the first, and if
  # its support falls outside, lnZ is biased low with a healthy-looking n_eff and no error.  This
  # is independent of any warm-start feature: it bites users who never enable one.  Reset FIRST,
  # so an intentional seed installed just below survives.
  if indx > 0:
    _clear_warm_state(sampler)
  # Warm-start this point's extrinsic integral.  Order is preserved (no grid
  # reordering), so a truncated worker still drops a spatially-unbiased subset; a
  # coverage floor + inflation margin make a poorly-matched transfer degrade to
  # cold rather than bias.  AV only.  Priority: L3 proposal field (cross-iteration)
  # over L1 sequential (same worker).
  if _proposal_field is not None and len(_proposal_field) > 0:
    try:
      from RIFT.integrators.proposal_field import lambda_from_P
      _seed = _proposal_field.warm_seed_for(lambda_from_P(P_list[indx]), k=1)
      if _seed is not None and len(_seed) >= 2:
        sampler.bootstrap_from_samples(_seed,
                                       params=_proposal_field.extrinsic_params,
                                       cover_frac=opts.extrinsic_proposal_field_cover_frac,
                                       inflate=opts.extrinsic_proposal_field_inflate)
        print("  [proposal-field] point {} seeded from nearest entry ({} pts, cover_frac={}, inflate={})".format(
            indx, len(_seed), opts.extrinsic_proposal_field_cover_frac, opts.extrinsic_proposal_field_inflate))
    except Exception as _e_pfq:
      print("  [proposal-field] seed skipped for point {} ({})".format(indx, _e_pfq))
      _clear_warm_state(sampler)
  elif opts.sampler_sequential_warmstart and (_seq_ws_proposal is not None) and hasattr(sampler, 'bootstrap_from_samples'):
    try:
      sampler.bootstrap_from_samples(_seq_ws_proposal,
                                     cover_frac=opts.sampler_sequential_warmstart_cover_frac)
      print("  [seq warm-start] point {} seeded from previous point ({} pts, cover_frac={})".format(
          indx, len(_seq_ws_proposal), opts.sampler_sequential_warmstart_cover_frac))
    except Exception as _e_sw:
      print("  [seq warm-start] skipped for point {} ({})".format(indx, _e_sw))
      _clear_warm_state(sampler)
  res = analyze_event(P_list, indx, data_dict, psd_dict, fmax, opts)
  # capture this point's converged high-likelihood extrinsic samples as the seed
  # for the next point (in-memory; no files leave the job)
  if opts.sampler_sequential_warmstart and hasattr(sampler, 'bootstrap_from_samples'):
    # Consume the seed captured inside analyze_event BEFORE it wiped sampler._rvs.  Reading
    # sampler._rvs here would always find it empty (that wipe is required: it fixes a fairdraw
    # export bug that silently dropped every binary after the first).
    _seq_ws_proposal = _SEQ_WS_PENDING
    if _seq_ws_proposal is None:
      print("  [seq warm-start] no seed captured from point {}".format(indx))
    _clear_warm_state(sampler)   # clear before the next point (re-seeded above if enabled)
  # abort if horrible (nan event) - done with 'raise'
  lnL_sofar = np.max([lnL_sofar,res])
  if opts.force_reset_all:  # depends on integrator!  May not always be availble
    if opts.sampler_method == "adaptive_cartesian_gpu": 
      for name in sampler.params:
        sampler.reset_sampling(name)
    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
    elif opts.sampler_method == 'AV':
      print(" AV always resets every iteration ! ")
    else:
      print(" force-reset-all not defined for this integrator ")
      sys.exit(1)
  if opts.no_adapt_after_first and (not no_adapt_sky):
    if lnL_sofar > 20:  # Use absolute threshold.  Expect this will give modest sky localization.
      # remove right_ascension, declination from adaptive parameters
      params_adapt = sampler.adaptive
      params_adapt  = list(set(params_adapt) - set(['right_ascension','declination']))
      sampler.adaptive = params_adapt
      # Disable saving of the integrand -- saves on memory and will reduce speed. 
      # Note this needs to be done in a few places
      pinned_params.update({"force_no_adapt":True,"save_intg":False, "igrand_threshold_deltalnL":20})  # massively reduce memory usage in logic branch, don't save all. Highly redundant sequence to self-document all related logic branches
 except Exception as exception_failure:
  print( "  ===> FAILED ANALYSIS <==== ")
  print( exception_failure)
  # The message alone ("boolean index did not match...", "index out of range", ...) is rarely enough
  # to locate a failure inside the sampler stack, and this handler is often the ONLY record a batch
  # job leaves behind.  Print the traceback too -- it costs nothing on the success path.
  import traceback as _tb_mod
  _tb_mod.print_exc()
  if opts.internal_make_empty_file_on_error:
    fname_output_txt = opts.output_file +"_"+str(indx)+"_" + ".dat"
    open(fname_output_txt,'a').close()  # create empty file
  if opts.internal_hard_fail_on_error:
    sys.exit(1)
#  if len(exception_failure) >0:
#    if "CUBLAS" in exception_failure[0]:  # Hard fail if a cuda error!
#      sys.exit(1) 
  if ("CUDA" in str(exception_failure)) or ('CUBLAS' in str(exception_failure)) or ('cuda' in str(exception_failure)) or ('compilation' in (str(exception_failure)) ): # Hard fail if a cuda error with a catchable error code
    if (opts.internal_soft_fail_on_cuda_error):
      sys.exit(0)
    sys.exit(62)
  if 'Out of memory' in str(exception_failure):   # should never happen, most likely a crappy node or failure to set correct memory limit for ILE.
    sys.exit(63)
  str_err = " {} ".format(exception_failure)
  if ('Zero prior failure' in str_err) or ('effective samples' in str_err):
    # common failures that require reset of sampler
    #    - zero prior : very rare case where the prior is exactly zero for some reason. User error most likely (floors, etc). Should never happen
    #    - effective samples = nan : error with AC where the integrator gets confused. Reset
    # reset all variables sampling, so we don't contaminate subsequent versions
    #    - again, this should only be a problem if we are using multiply adaptive sampling, so it should NEVER happen on the first time through
    for name in sampler.params:
      sampler.reset_sampling(param)

  #a sketch of how I would do custom failure modes, but for sake of speed I'm just setting the above right now
  #for i,failure_mode in enumerate(opts.custom_fails):
  #  if failure_mode in str(exception_failure):
  #    sys.exit(opts.custom_fail_codes[i])

  # Attribute the failure to something the traceback actually supports.  This line used to
  # read "Probable reasons: SEOB nyquist or starting frequency limit or signal duration"
  # UNCONDITIONALLY, for every exception raised anywhere in the block above.  That pointed
  # the high-SNR integrator collapse below -- the dominant extrinsic-export failure at
  # rho_net >~ 100, where >90% of exports died -- at the waveform generation code instead.
  # The NAMED exception is the reliable signal.  The bare numpy/cupy empty-reduction error
  # is kept only as a fallback for trees that predate LiveVolumeCollapse -- and it must be
  # corroborated by the traceback, because this handler also covers waveform generation,
  # data conditioning and the whole likelihood stack, any of which could reduce over an
  # empty array for reasons that have nothing to do with the live volume.
  _tb_txt = _tb_mod.format_exc()
  _is_named_collapse = (mcsampler_AV_ok
                        and isinstance(exception_failure, mcsamplerAdaptiveVolume.LiveVolumeCollapse))
  _is_legacy_collapse = ('zero-size array' in str_err and 'no identity' in str_err
                         and 'mcsamplerAdaptiveVolume' in _tb_txt)
  if _is_named_collapse or _is_legacy_collapse:
    print( " Probable reason: the INTEGRATOR's live volume collapsed -- no samples survived the")
    print( " adaptive-volume likelihood threshold.  This is NOT a waveform problem: nyquist, start")
    print( " frequency and segment duration are all irrelevant to it.  At high network SNR the")
    print( " likelihood underflows (exp() of a lnL more than ~745 nats below the peak returns 0),")
    print( " so a cold extrinsic prior yields almost no finite draw.  Narrow the extrinsic prior")
    print( " (--limit-right-ascension/--limit-declination, tighter --d-min/--d-max) or seed the")
    print( " sampler with --sampler-warmstart-retry-neff.")
  elif ('nyquist' in str_err.lower() or 'srate' in str_err.lower() or 'duration' in str_err.lower()
        or 'ChooseFDWaveform' in str_err or 'ChooseTDWaveform' in str_err or 'gwsignal' in str_err):
    print( " Probable reasons: SEOB nyquist or starting frequency limit or signal duration ")
  else:
    print( " Cause not classified -- read the traceback above; it names the failing call.")
  print( " Skipping the following binary! ")
  # Zero out extrinsic parameters -- these are CUDA-populated / meaningless, but could cause errors if populated
  P_list[indx].incl = P_list[indx].tref = P_list[indx].dist = P_list[indx].phiref = P_list[indx].psi =P_list[indx].theta = P_list[indx].phi =0
  P_list[indx].print_params()


# ---- Calibration pilot output (Option C / adaptive driver) -------------------------
# Write the per-realization cal responsibilities (accumulated over all analyzed intrinsic
# points) plus the prior cal node draws.  util_CalPilotFit.py fits these into a Gaussian
# proposal breadcrumb that seeds the next iteration's wide ILE jobs.
if opts.calibration_dump_responsibilities and (_calpilot is not None) and len(_calpilot_logresp_list):
    from scipy.special import logsumexp as _logsumexp
    _allp = np.array(_calpilot_logresp_list)            # (n_points, n_cal)
    _logresp = _logsumexp(_allp, axis=0)                # sum_points int dOmega L_c (unnormalized)
    # fold the importance weight: responsibility for fitting the POSTERIOR is
    # log_w + log L  (= log prior + log L - log proposal).  log_w==0 for prior draws.
    _logresp = _logresp + np.asarray(_calpilot.get('log_w', np.zeros_like(_logresp)))
    np.savez(opts.calibration_dump_responsibilities,
             nodes=_calpilot['nodes'], log_resp=_logresp,
             prior_mean=_calpilot['prior_mean'], prior_sigma=_calpilot['prior_sigma'],
             node_log_f=_calpilot['node_log_f'],
             n_nodes_amp=np.int64(_calpilot['n_nodes_amp']),
             dets=np.array(list(_calpilot['dets']), dtype=object))
    print(" Calibration pilot responsibilities written to {} ({} points x {} realizations)".format(
          opts.calibration_dump_responsibilities, len(_calpilot_logresp_list), _calpilot['nodes'].shape[0]))
