Skip to content

Sync junior rift_O4d into upstream rift_O4d - #188

Merged
oshaughn merged 333 commits into
oshaughn:rift_O4dfrom
oshaughnessy-junior:rift_O4d
Sep 4, 2026
Merged

oshaughn merged 333 commits into
oshaughn:rift_O4dfrom
oshaughnessy-junior:rift_O4d

Conversation

@oshaughnessy-junior

Copy link
Copy Markdown

Summary

Sync upstream rift_O4d with the 44 PRs merged into the junior fork since the shared base:

Integration notes

  • Live comparison: 328 commits across 144 files; the head is 11 commits behind the current base.
  • An isolated git merge-tree --write-tree check completed without conflicts.
  • No aggregate suite was rerun for this roll-up PR; validation remains recorded on the constituent PRs.

oshaughn and others added 30 commits August 27, 2026 05:22
create_event_parameter_pipeline_BasicMultiApproxIteration evaluates several
waveform models on ONE shared intrinsic grid and marginalizes over models at
each grid point,

    L_marg(lambda) = sum_m p(m) L_m(lambda)

so the adaptive grid refines on the model-marginalized posterior.  That is the
difference from running N single-model pipelines and mixing posteriors
afterwards: there, each run's grid refines on its own model's posterior, and no
post-hoc mixing recovers support neither run sampled.

Prior work by hand: Yelikar; Jan.  The claim is the production workflow, not
the idea of waveform marginalization.

MOST OF THIS WAS ALREADY HERE.  util_CleanILE.py keys on the intrinsic
parameters, so the same lambda evaluated under several models collapses to one
entry, and it combines them LINEARLY in L -- which is model marginalization, not
an lnL average (that would be the geometric mean, a logarithmic opinion pool,
and is not Bayesian).  The loop ILE also read an UNTAGGED overlap-grid, i.e. one
grid shared by every model.  The workflow was a half-finished shared-grid
marginalization; the per-model CIP fork inside the iteration loop was the part
that did not belong.

THE LOOP MERGES.  Every model's ILE runs on the shared grid and consolidates per
model; one unify pools the composites and marginalizes; one CIP fits that net
and writes the single grid every model reads next iteration.

THE TERMINAL STAGE FORKS.  Per-model unify -> per-model CIP (posterior AND ln Z
via --fname-output-integral) -> per-model extrinsic chain -> a new tool,
util_CombineApproximantPosteriors.py, mixing them with weights p(m) Z_m.  The
per-model posteriors stay on disk deliberately: that is what a systematics study
looks at.

WEIGHTS ARE A PRIOR, NOT SAMPLING EFFORT.  util_CleanILE.py gains
--model-group-regex, switching on a two-level combine: ntot-weighted within a
model (replicas of one quantity), p(m)-weighted across models (the quantity
itself differs).  Flat pooling silently used replica COUNTS as model weights.
On a two-model point with lnL 100 (two replicas) and 104 (one), flat gives
102.937 where uniform-prior marginalization gives 103.325 -- 0.39 nats, growing
with model disagreement and replica imbalance.  Opt-in; ONE model reduces to the
previous behaviour byte-identically, and the default path is unchanged (verified
against the pre-change file).

PARTIAL COVERAGE IS REPORTED.  A lambda evaluated under only some models is
marginalized over that subset, so the estimator changes point to point -- a
model- and lambda-dependent change in the effective prior, caused by the
sigmaOverL>0.9 cut firing for one model and not another, or a model's ILE
failing.  Always warned; --require-all-approx drops such points instead.  That
is not free either (it biases toward where the worst-resolved model converged),
so it is a choice, not a default.

BUGS FIXED ALONG THE WAY, each confirmed on an emitted two-approximant DAG.
None could be seen with one approximant, and this builder has no caller
(--pipeline-builder does not offer it, and asimov drives pseudo_pipe), so
nothing exercised them:

  * the terminal extrinsic stage read overlap-grid-<n_iterations-1> while the
    final CIP wrote overlap-grid-<n_iterations>: `if not ('it' in globals())`
    preserved an `it` the loop had already left one short.  At one iteration it
    read the raw seed grid.
  * join_grids.sh interpolated $(macroapprox) INSIDE A BASH SCRIPT -- command
    substitution, not a condor macro, since the .sub passes only $1 $2.  It
    matched nothing.  Gone now the loop grid is not model-tagged.
  * CIP.sub's initialdir named iteration_N_cip while the mkdir loop created
    approx_<A>_iteration_N_cip; the consolidate/unify log directories were the
    mirror image.  Either holds the job on the execute node.
  * parent_fit_node was one variable spanning `for it: for approx:`, so model
    B's iteration-0 ILE waited on model A's iteration-1 convert.  The models
    were serialized and cross-coupled; they now run in parallel.
  * the terminal convert wrote one untagged posterior_samples-N.dat from every
    model, while the convergence test read a per-model name nothing wrote.

SHARED CODE.  write_unify_sub_simple gains script_name and glob_pattern (a
workflow needing two unify jobs otherwise has the second overwrite the first's
script); write_convert_sub no longer appends the literal string "None" when a
tool has no trailing positional.  Both default to previous behaviour: building
BasicIteration with and without this change gives 0 of 27 emitted files
differing.

TESTS.  test/test_multiapprox_marginalization.py, 16 cases, ~11 s, self-
contained (it writes its own args and seed grid, since the builder has no
caller).  Two groups: the combination arithmetic against hand-computed values --
including a guard that we do NOT compute the geometric mean -- and the emitted
DAG's shape.  The DAG cases build TWO approximants deliberately; with one, every
cross-model path is vacuously satisfied, which is how the defects above
survived.  test_every_job_directory_exists resolves each node's own macros, so a
missing directory OR an unresolved $(macro) fails -- the latter is how ILE_extr
came to interpolate an empty approximant into both --approx and its initialdir.

Added to the ci.yml job that carries the CIP prior suite: this workflow runs
explicitly-named files, so a new test file otherwise runs nowhere.

NOT VALIDATED ON DATA.  These are build-shape and unit-arithmetic checks, not
physics.  The prototype is a rerun of one of the Jan/Yelikar cases -- cheap, and
with large between-model differences -- framed as a reproduction claim first and
an efficiency claim second.  Nothing here belongs in a paper until that runs.
Design record: RIFT/misc/DESIGN_multiapprox_marginalization.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The multi-approximant builder had no caller: --pipeline-builder offered only
BasicIteration, AlternateIteration and Hyperpipe, and asimov drives pseudo_pipe.
That is why its defects survived, and it also meant a cross-model run had to
hand-write args_ile.txt / args_cip_list.txt / args_test.txt -- exactly the
settings helper_LDG_Events.py already writes for production.

Now:

    util_RIFT_pseudo_pipe.py ... --approx SEOBNRv5HM --approx-extra IMRPhenomD

--approx-extra is repeatable and IMPLIES --pipeline-builder
BasicMultiApproxIteration (asking for a second waveform model is asking for the
cross-model builder; requiring the user to say it twice only invites them to
disagree with themselves).  --approx-prior and --require-all-approx pass through.
The models reach the builder as repeated --approx, so every one is evaluated on
the SAME shared grid.

The point is not convenience.  It makes a cross-model analysis production-matched
BY CONSTRUCTION -- PSD, cache, channel names and CIP settings come from the same
helper that writes them for a single-model production run, instead of being
chosen by whoever set up the comparison.  On this project four confident
conclusions turned out to be artifacts of an unmatched stand-in.

API COMPATIBILITY, THE HONEST WAY.  pseudo_pipe emits one command line shaped for
BasicIteration's option surface, which declares 63 options this builder does not
implement -- and one of them, --n-iterations-subdag-max, is emitted
unconditionally, so the builder died on it immediately.

This builder now ACCEPTS all 63 (hidden from --help) and REFUSES any that is
actually used, naming it.  It does not ignore them.  A flag that parses and does
nothing changes a run without saying so and is invisible in a config diff, which
is a worse failure than crashing.  Verified both ways: a pseudo_pipe-shaped line
builds, and the same line with --n-iterations-subdag-max 5 exits 1 with the
option named.

test_multiapprox_pseudo_pipe.py re-derives the option surfaces from the two
builders' sources and fails if BasicIteration ever declares something this
builder neither implements nor explicitly refuses.  Without that, the next option
added upstream breaks cross-model runs at DAG-build time in someone's campaign,
and the tempting fix -- ignoring it -- is the failure above.  Added to ci.yml,
which runs explicitly-named files.

109 tests pass across the multi-approximant suites and the CIP prior suite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… gate registration pending)

Two exact angle-marginalization schemes for the distphipsimarg path, both
built on the analytic fact that lnL_t at fixed (t, distance) is a bivariate
trig polynomial (phi order <= 2*m_max, u=2psi order <= 2): 'exact'
(Nyquist-sized coefficient bootstrap + dense reconstruction) and 'laplace'
(analytic psi-Laplace, error O(1/A)).  Wrapper/driver get
--angle-marg-scheme {grid,exact,laplace,auto}; DEFAULT stays 'grid'.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…n setup

Driving a real cross-model run from pseudo_pipe -- --event-time +
--fake-data-cache + --use-online-psd-file, i.e. the natural synthetic-injection
path -- turned out to be broken in ways only running it exposes.  All four are
pre-existing except the last, which is the new builder meeting the driver.

1. NameError: name 'config' is not defined.  --use-online-psd-file read the
   ini's [analysis] ifos UNCONDITIONALLY, but `config` only exists on the
   --use-ini path (the commented-out lines above it show where it used to be
   built).  So the flag was unusable without an ini.  Now: the ini when there is
   one, else --manual-ifo-list, else the PSD file's own instruments -- which
   cannot disagree with the file being used.

2. KeyError: 'IFOs'.  --fake-data-cache indexes event_dict["IFOs"], which is
   populated only by the ini path and the gracedb lookup -- never by
   --event-time.  Resolved from --manual-ifo-list or the instruments already
   read off the PSD file, and stored so the later consumers of
   event_dict["IFOs"] see it too.

3. The pipeline builder's exit status was discarded.  `os.system(cmd)` with no
   check, so a builder that refused its arguments left pseudo_pipe reporting
   SUCCESS with every args_*.txt present and no DAG in the run directory --
   which looks exactly like a finished setup.  Observed: the builder exited 1
   and pseudo_pipe exited 0.  Now checked, with the reason printed.

4. os.system(cmd) where os.system(cmd_enough) was meant, inside
   --internal-ile-check-good-enough: it built the `find ... -exec touch` command
   and then re-ran the PIPELINE BUILDER instead, rebuilding the whole DAG.

Also, two options are no longer emitted to the multi-approximant builder:
--n-iterations-subdag-max (a subdag concept; it was emitted unconditionally, so
the builder refused every cross-model run) and the two
--last-iteration-extrinsic-samples-per-ile controls, which it does not
implement.  The latter prints a notice naming what was dropped rather than
changing the extrinsic sample count in silence.

VERIFIED END TO END.  pseudo_pipe now writes the args_*.txt via
helper_LDG_Events.py and builds a cross-model DAG: 566 jobs without the
extrinsic stage, 8773 with it.  Structure confirmed on the emitted DAG -- both
models on the loop ILE, SEVEN CIP nodes none of which carries macroapprox (one
fit per iteration over the marginalized net), unify waiting on every model, and
the terminal fork present as 2 unify_model + 2 CIP_terminal + 2 cat + 1
combine_models.

Sizing note for the record: 8000 of those 8773 jobs (91%) are the
convert_extr/resample pair, at 2 x n_output_samples_last per model.  That is the
deprecated per-point extrinsic path quantified for this workflow, and it is why
teaching this builder fairdraw-extrinsic is the prerequisite for running it at
campaign scale.

21 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
simulation_manager: container universe and output-transfer timing
…emory defaults

- register test_angle_marg_exact.py in .travis/test-jax.sh (FILES + ledger),
  EXPECTED_TESTS 139 -> 163 by collection; ci.yml cost notes updated.
- record the measured injection-ladder calibration (SEOBNRv4 35+30 HLV,
  SNR 10-80) in the crossover-constant note; laplace-exact falls from
  -1.1e-3 (A=50) to -2.8e-6 (A=3200) while the legacy grid errors grow to
  -27 (32x8) / -51 (8x8) nats.
- laplace kernel: guarded log-add-exp and branch-safe inputs (jnp.where VJP
  sends a zero cotangent through the unselected branch and 0*inf = nan),
  Newton under stop_gradient + one differentiable polish step, curvature
  floors relative to amplitude; kernel FD-gradient and O(1/b) error-law
  tests added; scheme-level laplace gradient checked AD-vs-AD against the
  exact scheme (measured ~5e-4 relative).
- exact scheme memory defaults dense_chunk=8, grid_block=32 (inner slab
  ~0.8 GB f64 at batched S=64, npts=614).
- full gate run: PASS, 163 tests, 0 skipped, 981 s local.
- mutation audit: 8 mutations (DFT sign flip, dropped harmonic row, halved
  sample grid, dropped normalization, single stationary start, flipped
  selector, inert wrapper flag, dropped driver kwarg) each fail >=1 test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
All three confirmed against the source and fixed; the third also had a gap in my
own gate, which is closed.

P1: THE TERMINAL FIT USED THE WRONG ARGUMENTS.  cip_args is cip_args_lines[0],
but the iteration loop advances through the list via cip_job_list, so with a
multi-line --cip-args-list the run finishes on a LATER configuration.  The
terminal per-model CIP was built from cip_args, so the per-model posterior and
evidence could use different coordinates, fit method or resolution than the
iteration that produced the grid they fit -- and those evidences are what the
final mixture is weighted by.  Now expands cip_args_n into a per-iteration list
and takes the entry for iteration n_iterations-1.

P1: MODEL DISAGREEMENT WAS BEING ENCODED AS INTEGRATION ERROR.  The cross-model
combine folded the between-model scatter into sigmaOverL, on the reasoning that
it is the waveform-systematic contribution and should widen the downstream fit.
It does the opposite.  sigmaOverL is an INTEGRATION error and CIP drops every
row above --sigma-cut (default 0.6,
util_ConstructIntrinsicPosterior_GenericCoordinates.py:320,2211), so scatter
large enough to matter DELETES precisely the intrinsic points where the models
disagree -- the points this workflow exists to fit.  Measured on the two-model
test case: scatter 0.964, i.e. above the cut, so that point would have been
thrown away.  Across models sigmaOverL is now the propagated per-model
integration uncertainty only; the model variation is already carried by Lbar.
The scatter is still computed and REPORTED as a diagnostic, never folded in.
(Within a model the scatter term is correct and is unchanged: those are
replicas of one quantity.)

P1: THE PLOT JOB NAMED A MODEL IT NEVER BINDS.  plot.sub's log paths contained
$(macroapprox) while plot_node supplies only the iteration macros, so with
plotting enabled the macro never resolves and the job has no valid log
directory.  The plot node is model-independent -- it summarises the run once --
so its log paths and its mkdir are now model-independent too.

AND THE GATE MISSED IT.  _assert_multiapprox_job_directories_exist checks for
exactly this, but plotting is off by default and the test never passed
--plot-args, so the plot stage was never built and no assertion could see it.  A
stage that is not built is a stage nothing checks.  The test now builds with
plotting on; mutation-tested, it fails with "plot.sub: unresolved macro in
...approx_$(macroapprox)_iteration_2_plot/...".

21 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
test_dag_chains_levels_per_sim asserted `len(list(dags_dir.iterdir())) ==
1`. Where condor_submit_dag is actually installed it runs and drops four
companion files next to the DAG -- .condor.sub, .dagman.log, .lib.err,
.lib.out -- so the directory holds 5 entries and the assertion fails.

The test's own comment says "condor_submit_dag missing -> noop dispatch",
which is the tell: it was written on a machine without HTCondor and is
green only there. There is exactly one DAG either way; the product is
fine.

Glob for *.dag instead, and put the directory listing in the assertion
message so the next person sees immediately what is there rather than
just a count.

This was the one red test on rift_O4d, and it has been cited as
"pre-existing, not mine" in three separate PRs while nobody looked at
it. Whole suite green now: 227 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…a-derived sizing)

Defect 1: the psi-Laplace kernel seeded Newton only at the FIRST harmonic's
extrema (u0 = beta, beta+pi), which fails outright when that harmonic
cancels -- c1=0, c2=-d, d>0.5 put both seeds on MINIMA and returned -inf
for a finite integral.  Rewritten angle-free with ALL stationary points
enumerated: f' is a degree-2 trig polynomial (resultant quartic in e^{iu}),
so it has at most 4 transversal zeros; a 24-cell sign scan brackets each
(interval-based -> no duplicate roots, including zeros exactly on a grid
node), bisection under stop_gradient converges them, one differentiable
Newton polish step carries the implicit derivative, and near-degenerate
maxima (|H| < h_floor) are kept with floored curvature instead of dropped
(-inf can no longer be returned for a finite integral).  Measured: the
counterexample family now errs O(1/d) (0.16 at d=0.7 down to 2.5e-4 at
d=500); a 200-draw randomized (b,d,beta,delta) sweep including b<<d gives
worst |err|*(b+d) = 1.75; gradients FD-exact including at b=0.

Defect 2: dense-grid sizing keyed on guess_snr, so a missing or
underestimated SNR silently pinned amp_sizing at the 450 floor and
under-resolved the quadrature (measured -1.04 nats on a kappa_boost=200
synthetic with true amplitude ~1.7e4) -- the n_psi=8 failure mode again.
Sizing and selection now key on estimate_angle_amplitude: a DATA-DERIVED
bound from the coefficient tables themselves (eager 64-point sky scan at
wrapper build, max over sky/time/actual distance nodes of
(x*M_A - x^2/2*B0)+, margin 2), unfloored for selection and floored at the
crossover for sizing.  The fused functions now REQUIRE amp_sizing (raise on
None -- never a silent default); guess_snr no longer influences either.

Both regressions verified to bite pre-fix (kernel -inf repro; floor-sized
wrapper off by 1.04 nats) and are pinned in 5 new tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…9 (review item 4)

The test file grew by 5 (the two review-defect regressions plus kernel
sweep/no-default/estimator pins) while EXPECTED_TESTS still said 163; the
floor is >= so the gate passed while leaving the new tests unprotected.
Recomputed by pytest --collect-only (168) in the same commit as the ledger
text, per the gate's own rule.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…uard, empirical amplitude estimator

Item 2 (confirmed): the hard series/Laplace switch at b+2d=0.5 left a wide
bad window (0.27-0.53 nats value error, SIGN-INVERTED |c2| gradient across
b+2d in [0.5,~5]).  The Bessel series is extended to cross-term order k=8
with each I_n a fixed 26-term power series (elementary ops, inputs clamped
so the polynomial cannot overflow on 0-weighted bins), phases division-free
via powers of w = c2*conj(c1)^2, and the two branches are blended
C1-smoothly over b+2d in [10,16].  Measured: machine precision through
t=10 (the review's probes at 0.5001 and 2.0 exact in value and gradient),
O(1/A)-bounded and sign-correct through the band (worst 0.17 val / 0.47
grad on adversarial draws at t~15 -- bins that are exp(-(A-16))-subdominant
in any marginal the laplace branch serves), max step-to-step blend jump
2.4e-4.  Pinned by a window test that does NOT filter the band out, and the
randomized sweep's low-amplitude filter is removed.

Item 3 (confirmed): the driver AST guard accepted any angle_marg= keyword,
so the inert-flag mutant angle_marg="grid" at the call site passed the
suite.  The guard now requires the keyword's VALUE to be the angle_marg
variable and that variable to be read from opts; both the reviewer's mutant
and the re-hardcoded-variable mutant now fail it (verified).

Item 5 (suspected, addressed): estimate_angle_amplitude's analytic
M_A-with-mean-B expression is heuristic in exactly the reviewed direction.
The PRIMARY estimate is now the EMPIRICAL max of the exponent over a dense
96x24 angular reconstruction (near-exact for the band-limited trig
polynomials; closed-form concave-in-x distance max per point); the analytic
expression is kept as a runtime cross-check that prints loudly if it ever
reads below the empirical max.  Docs corrected: auto engages from true
A ~ 225 (SNR ~ 21) because the margined bound is compared to the crossover.

Item 1 was fixed in 6600060 (the selector floor bug -- caught by this
suite when run); item 4 in 5d3f9c2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…d asserted

A mutation audit found the estimator's hardcoded 96x24 angular grid was
unpinned: a gutted grid read within 2% of a 512x128 reference on the broad-
peaked test target (and a finer-grid self-comparison was common-mode blind
to the same mutant).  The grid is now DERIVED from m_max and asserted
fail-closed, mirroring angle_sample_grid_sizes; the estimator test gains an
INDEPENDENTLY-computed dense reference (not routed through the estimator).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…pdated

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Running the first real cross-model DAG found that it could not do the comparison
it exists for.  With --use-gwsignal applied globally -- which is what
util_RIFT_pseudo_pipe.py emits -- every IMRPhenomD ILE job died with

    ValueError: Invalid Argument   (gwsignal gen_modes)
    XLALSimInspiralGenerateTDModes: generator does not provide a method to
    generate time-domain modes

and contributed ZERO rows.  util_CleanILE then saw ONE model, and the run
silently degraded to single-model with no marginalization at all.  Measured
across families:

    SEOBNRv5HM     gwsignal OK (4 modes)
    IMRPhenomD     gwsignal FAILS
    IMRPhenomXAS   gwsignal FAILS
    IMRPhenomXPHM  gwsignal FAILS

The phenom family has no time-domain mode generator in gwsignal; SEOBNRv5* is
only available there.  So a single global flag cannot serve an EOB-vs-phenom
comparison, and that is exactly the comparison this builder is for.

New --approx-gwsignal (repeatable) names the models that need gwsignal.  The
builder strips any global --use-gwsignal out of the ILE arguments and binds the
route as $(macrogwsignal) per node, so one DAG mixes families:

    SEOBNRv5HM -> " --use-gwsignal "
    IMRPhenomD -> " "

Gated by test_generator_route_is_per_model, which fails if ILE.sub carries a
global --use-gwsignal instead of the per-node macro.

HOW THIS HID, and worth stating because the shape recurs: a model contributing
zero rows is INVISIBLE to the partial-coverage check, which compares only the
models it has SEEN.  "model-aware combination over 1 models" reads like status,
not like an error.  Making util_CleanILE take the expected model list and fail
when one is entirely absent is the follow-up.

22 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…om the sample rate

The time integral of the marginalized likelihood is taken with Simpson's rule at
the FIXED spacing deltaT = 1/srate.  That spacing is a property of the DATA; the
width of the integrand is a property of the SIGNAL,

    sigma_t = 1 / (2 pi rho sigma_f)

and shrinks as 1/rho.  Production therefore under-resolves its own integrand,
worse at higher SNR, and Simpson's (4 T_h - T_2h)/3 form makes an under-resolved
peak worse than trapezoid rather than better, because it carries the coarser
T_2h and inherits its 2h alias.

kappa(t) is band-limited below Nyquist by construction (the rholm timeseries are
inverse FFTs of a product truncated at fmax <= fNyq) and rho_sq is
time-independent on this path, so the samples the code ALREADY computes determine
the continuous integrand exactly.  One zero-padded FFT per row recovers it: no
extra likelihood evaluations, no extra precompute, no extra accumulator passes.

Adds an opt-in 'bandlimited' quadrature.  THE DEFAULT IS UNCHANGED: 'simpson'.
The refinement factor is derived from the measured peak width and re-asserted on
the refined grid -- there is deliberately no resolution option, because this
defect class exists because a resolution was once a settable number.  Restricted
to n_cal == 1 on the non-rotating likelihood; every other path refuses rather
than silently applying an argument that does not hold there.

Scope is DiscreteFactoredLogLikelihoodViaArrayVectorNoLoop only.  NoLoopOrig and
DiscreteFactoredLogLikelihoodViaArrayVector keep Simpson unchanged, knowingly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… branch

selection, and honest language about the amplitude bound

Three defects from a third independent review:

1. The final dense quadrature ignored m_max.  The previous fix made the
   COEFFICIENT RECONSTRUCTION grid scale with mode content, which looks like it
   covers higher modes but does not: integrating exp(lnL) needs phi resolution
   proportional to the highest harmonic 2*m_max, ON TOP OF the ~sqrt(A)
   amplitude density.  _dense_grid_sizes() only ever saw the amplitude.
   Measured with the 2x margin: a pure order-8 term at true A=450 was
   phase-dependently wrong by ~+-0.037 nats, and order 16 ranged -1.20 to
   +0.57.  _dense_grid_sizes now takes m_max and scales n_phi by it.
   This survived two earlier reviews because higher-mode coverage stopped at
   the reconstruction step and never tested the FINAL MARGINAL.

2. Branch selection was global where the kernel is local.  The tolerance for
   branch-window error assumed bins at t~10-16 are exponentially subdominant
   whenever the GLOBAL amplitude exceeds the crossover -- but selection happens
   once, from a maximum over sampled sky positions, while the kernel runs at
   EVERY proposed sky position.  At a low-response proposal, moderate-t bins can
   be locally dominant: b=11.8866, d=3.5163, beta=-1.8900, delta=-0.6497 gives
   t=18.9 and +0.251 nats against dense quadrature.  Selection is now local.

3. The "upper bound" was 64 random sky/inclination draws plus a fixed 2x margin,
   which is an ESTIMATOR, not a bound -- and the test that checked it reused the
   SAME sampled positions, so it was blind to a missed sky maximum by
   construction.  The language and the fail-safe now say what it actually is.

Committed by the coordinating session: this work sat uncommitted in a shared
worktree for several hours, where it had no reflog and was one stray command
from being lost.  Authorship is the implementing agent's; the commit is mine
because it should not have stayed uncommitted.
The edge-guard classification left one gap: a row with no resolvable curvature
(an extrinsic sample in an antenna null, where kappa is numerically zero) was
excluded from the wrap guard -- correctly, so the diagnostic stops reporting
"mis-centred window" for rows that simply have no signal -- but then fell
through to the dense path at factor 1, which integrates on the COARSE grid with
TRAPEZOID rather than Simpson.  Numerically a non-event on a featureless
integrand, but it silently changes the rule for rows this option was never meant
to touch, and it forfeits the one property a reviewer can actually check.

Make the rule exact instead: a row is refined only if it has a trustworthy peak
AND the derivation asks for a finer grid.  Everything else -- wrap-exposed,
unmeasurable, or already resolved -- gets the historical Simpson value.  So a
row's returned value differs from the historical one if and only if its
integrand was under-resolved, and `last_report()['n_refined_rows']` is exactly
the set of rows that moved.

Raised by the parallel rift_O4c effort, which kept the guard blanket and let
n_flat_rows overlap it instead; this resolves both the diagnostic and the
guarantee rather than trading one for the other.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The 169-test gate hit the job's 60-minute timeout-minutes cap and was CANCELLED
at 65 minutes (run 33121111049), which the PR then displayed as a FAILING check
-- indistinguishable at a glance from a broken test.  The 139-test baseline ran
in 13m53s, so the growth was not gradual-looking from the badge.

RO'S: some tests have to be development checks rather than per-commit gates,
the way full RIFT analysis runs already are.

Deselected (NOT deleted, and not weakened):
  test_laplace_high_amplitude_accuracy_and_trend        scale=100
  test_higher_mode_dense_sizing_self_convergence        4x oversized grid

The split is drawn on a principle rather than on convenience: EXACTNESS DOES
NOT DEPEND ON AMPLITUDE.  The low-scale brute-force comparisons that remain
gated (scale 2/4/6) are what prove the schemes correct, including
test_higher_mode_marginal_vs_bruteforce, which is the coverage the third review
asked for on the final marginal.  What moves out establishes the ERROR LAW at
production amplitude -- a property of the mathematics that does not change
commit to commit.  Cost is dominated by the dense reconstruction, ~sqrt(A) per
axis with A ~ scale^2; the PR's own SNR-320 row records the oversized
construction as "13M dense points, eager-CPU intractable".

Each entry carries its reason and a copy-pasteable command to run it by hand.

EXPECTED_TESTS 169 -> 173 recomputed BY COLLECTION with the deselects applied,
never by arithmetic (the file also gained tests in the third-review commit, so
the count went UP even though two tests left the gate -- exactly the reason this
script forbids doing the sum in your head).
…floor 171

Three fixes from the fourth external review, plus the count it caught.

1. EXPECTED_TESTS was 173 where CI collects 170.  I computed 173 by collection
   in the igwn python; CI's environment collects fewer, so the floor tripped
   before any test ran and the gate went red in 1m3s.  The number now comes
   from CI's own measurement (170) plus the one test added below.  Lesson for
   the ledger: "recount by collection" must mean collection IN THE GATE'S
   ENVIRONMENT -- a local count is not authoritative here.

2. The undersizing fail-safe warned and then published.  A jax.debug.print
   inside jit stops nothing: a production run would finish and emit biased
   likelihoods, samples and evidence while the "fail-safe" scrolled past in a
   log.  _runtime_amp_failsafe now RETURNS a poison term that both schemes add
   to their result, so the output is NaN when the estimator is undersized.  A
   NaN cannot be silently consumed; an under-resolved finite number is
   indistinguishable from a good one.  Still under stop_gradient.

3. Moving test_higher_mode_dense_sizing_self_convergence out of the gate
   removed the ONLY test that distinguishes the corrected sizing rule -- my
   justification for the split ("exactness does not depend on amplitude") is
   false for the dense quadrature, and the old m_max-blind rule passes every
   low-scale brute-force test here.  Replaced with
   test_dense_phi_sizing_must_scale_with_m_max: pure numpy, milliseconds,
   closed-form reference (I0), phase-swept.  MEASURED at amp=450, b=150,
   order=16: blind rule (n=352) errs 4.98e-01 nats, m_max-aware (n=1360) errs
   1.17e-10.  It carries a self-check that FAILS if it ever stops biting.

   Two earlier attempts at this test did NOT bite and were discarded rather
   than shipped: a pure order-8 harmonic at b=6 is resolved exactly by the
   blind rule (err 8.9e-16).  The error only appears once the harmonic
   amplitude scales with A, which is the real situation.
…il-open guards

Findings from a four-lane adversarial review plus a run on real GPU hardware.

WRONG ANSWER, three of five production sample rates.  bandlimited_upsample split
the spectrum at h = n//2 unconditionally.  For ODD n that files the highest
POSITIVE frequency under a negative frequency.  marginalization_time_grid gives
npts = 153 / 307 / 614 / 1228 / 2457 at srate 1024 / 2048 / 4096 / 8192 / 16384 --
odd at three of them, including 16384.  Measured against an analytic band-limited
truth at factor 4: 1.4e-12 at n=614, but 4.1e-1 at n=613, 5.4e-1 at n=307 and
6.0e-2 at n=2457.  Invisible to the obvious test: the reconstruction stays exact
AT the samples, and the old fixture used only even npts with an empty top bin.
Test is now parametrised over the real production sizes and fills every bin
below Nyquist.

CRASH, 100% of GPU runs.  The likelihood called the helper without passing its
own `simps`, so the helper defaulted to scipy's, which raises on a cupy array.
Every --vectorized --gpu run of the option died.  Found by executing on
ldas-pcdev13, not by reading the cupy API; all 46 CPU tests were green.  The
scipy default now refuses for a non-numpy backend instead of leaving the trap
armed.  (Related, and PRE-EXISTING: scipy's simpson and the vendored
optimized_gpu_tools.simps disagree for EVEN npts, so CPU and GPU RIFT already
return lnL differing by up to 0.405 nats.  Reported, not fixed here.)

BROKE THE STANDARD EXTRINSIC STAGE.  The guard raised on return_lnLt whenever the
module default was bandlimited.  --add-extrinsic-time-resampling maps to
--resample-time-marginalization, whose resample_samples() calls with
return_lnLt=True and no explicit quadrature -- so the run did the whole
integration and then died at export.  return_lnLt takes no time integral, so the
quadrature is inapplicable there rather than ignored; it now raises only on an
explicit request.

ABORTED THE RUN ON A NaN.  The rho_sq time-independence tripwire used a bare
`==`, so `nan != nan` fired it and killed the process, blaming a rotating-response
path not in use.  NaN rows are normal -- the defensive proposal component draws
them deliberately.  Compares finite entries only.

FAIL-OPEN CEILING.  A float factor above 2**63 wrapped to a negative int64, which
maximum(factors,1) turned into 1, silently classifying an unresolvable row as
"nothing to refine".  Clamped before the cast so it raises.

CLAIM CORRECTED.  "A row's value changes iff it was under-resolved" was false: the
log-sum-exp offset moves from a global to a per-row maximum for EVERY row, so
rows that underflowed to -inf now come back finite.  The guarantee is about the
RULE, not the value.  The test that "proved" it compared against a per-row-offset
helper -- common-mode with the code under test, and every fixture ran one row,
where the two offsets coincide by construction.  Rewritten against the shipped
global-offset expression on a multi-row batch with >745 nats of dynamic range.

Also: pass the already-computed coarse lnL instead of recomputing it, and fix two
broken assertions in the new simps-handover test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
RO'S: hard failing "would kill an entire run", and excision is the worse
outcome.  Both are right, and the NaN I added in the previous commit was the
wrong fix -- it CAUSED the harm it was meant to prevent.

Why NaN was wrong.  Every consumer of this likelihood FILTERS non-finite
values: flowMC/MALA reject such proposals as invalid, the SMC path drops
non-finite lnL, and write_samples discards non-finite rows.  So a NaN over a
hot sky region the estimator missed halts nothing -- it EXCISES exactly that
region and publishes a clean-looking posterior and evidence over what remains.
Invisible mutilation beats visible failure only from the code's point of view.

Why aborting is also wrong.  This is a configuration ESTIMATE.  Destroying a
multi-hour run over a recoverable condition trades a labelled result for no
result.

So: the value is untouched, the run completes, and the condition is recorded on
the HOST (jax.debug.callback, outside the traced graph, never altering a value).
The driver resets it per EVENT -- batch runs analyze several events in one
process, so event 0 must not label event 1 -- and appends
"SUSPECT-ANGLE-GRID amp_failsafe=TRIPPED worst_amp=... amp_sizing=... scheme=..."
to the provenance line write_samples embeds in the exported file, plus a loud
stderr message naming the recourse.  An operator gets a labelled artifact they
can judge, rather than a vanished region or a dead run.

Also removed two `except Exception:` wrappers I had put around the guard calls.
A swallowed NameError there would have degraded a tripped run to "clean" --
the same silent-degradation failure this guard exists to remove.  The import is
now explicit and the calls are unguarded.

Tests (the previous one asserted only that a warning printed, so replacing the
poison with 0 would have passed it unchanged):
  * finiteness of the undersized result is now asserted, so reintroducing the
    poison fails;
  * the host-side record is asserted, since without it the driver cannot label
    anything and the condition dies with the log line;
  * a new driver test pins the import, the per-event reset, the label reaching
    the WRITTEN provenance, and the absence of the swallowing except.

EXPECTED_TESTS 171 -> 172 for the added driver test.
The edge guard bounds WHERE, not HOW MUCH.  lnL is linear in kappa, so the wrap
error in nats scales with amplitude: for a row just outside the guard, measured
-8.0e-4 / -8.1e-3 / -8.1e-2 / -0.846 nats at peak lnL 5.3e2 / 5.3e3 / 5.3e4 /
5.3e5 (rho ~ 33 / 103 / 326 / 1031).  The companion O4c effort measured the same
linear scaling on a different fixture and a different implementation, reaching
the same magnitude at the same amplitude -- so this is corroborated across lines,
not a single-fixture artefact.  Adequate through O4, weaker for 3G.

And the guard was justified with a claim that is simply false: that such rows are
truncated under either rule.  Often they are not -- at 20-60 samples from the
edge the peak sits entirely inside the window, and those rows are handed a
Simpson value measured 2.87 nats wrong where the reconstruction would have been
0.007-0.02.  The guard is deliberately conservative; the crossover where the
reconstruction actually loses is nearer 5-10 samples.

Also documents what "exactly" is exact ABOUT.  The reconstruction is exact for
the integrand the code actually forms, which is the true kappa only under
time_interp='nearest' (the default).  With 'cubic'/'sinc' the gathered values are
a fixed FIR filter applied to Q -- still band-limited, so the sampling argument
survives, but the refinement then converges to the integral of a stencil-BIASED
integrand.  Measured at peak lnL ~5300: 'nearest' +0.0002 nats against an
analytic truth where Simpson is -521; 'sinc' -2.29 where Simpson is +1.28, with
Simpson winning about half a scan over seeds and grid phases.  The quoted
advantages are for the default stencil, which no version of this said before.

CI: a collected-count guard, matching test-slowrot.sh and test-jax.sh.  set -e
already catches a total collection failure (pytest exits 5), but a silent shrink
would read as green.  EXPECTED=60 obtained by running collection; verified it
passes at 60 and trips at 61.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…local follow-up

The PR quoted a dozen measured tables that were not reproducible from the tree --
no harness, no fixture, no pointer.  This records the numbers beside the module,
names the harnesses that produced them, separates what was measured on the JAX
mirror from what was measured on this path, and writes up the peak-local design
RO'S redirected to, with its prototype numbers and the merging detail that makes
it one algorithm rather than a regime switch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A test-quality review ran 41 mutations and 13 survived.  The numerical core held
(28 killed; the accuracy fixtures have teeth -- substituting the Simpson value
fails all 24 parametrised cases), but the WIRING layer was close to untested,
which is precisely what this file's docstring claims to protect.

THE FLAG COULD BE MADE COMPLETELY INERT.  Deleting the driver's one load-bearing
line -- the assignment to factored_likelihood.TIME_QUADRATURE_DEFAULT -- left all
four driver tests green, as did hardcoding 'simpson' there.  The banner those
tests inspect was built from `opts`, so it reported what was ASKED FOR rather than
what was in force, and the in-process test set the module attribute ITSELF with a
comment saying "exactly what the driver does" -- a hand-copy of the line, not the
line.  Nothing joined the two halves.  The banner now prints the value read back
OUT of the module, which is what makes it, and the tests asserting on it,
load-bearing.

THE UPPER EDGE GUARD WAS UNEXERCISED.  Every fixture parked peaks near sample 0,
so dropping the right-hand term returned +88.8 nats above truth -- the exact
number the docstring cites as the reason the guard exists, in the
evidence-inflating direction.  An off-by-one in the same term also survived.

phase_marginalization=True NEVER REACHED THE NEW PATH.  Dropping the abs()
entirely changed nothing any test could see, yet
--distance-marginalization --phase-marginalization is the standard production call
site.  Every fixture used the affine helper, for which lnL(t) is ITSELF exactly
band-limited -- an easier problem than production poses.  Added coverage for both
settings and for a nonlinear, -inf-off-table distmarg-shaped callback.

THE MEMORY-CHUNKING PATH NEVER RAN.  Dropping all but the first chunk was
invisible: it needs 72 rows to trigger and the largest batch was 4.  Production
runs --n-chunk 10000, so every real call takes that branch.

TWO DRIVER GUARDS WERE BREAKABLE.  Making the refuse-guard fire unconditionally
would reject EVERY ordinary ILE run, and the banner was free to claim
"honoured: True" always; no driver test ever launched a default configuration.

Also pinned: the one-ulp factor bump, the non-finite argmax mask, sigma_t_min in
the report, and the tuned constants themselves (the suite otherwise pinned
EDGE_GUARD_FRACTION only to within a factor of ~10 while the docstring justifies
it with a measured table).  Replaced a vacuous assertion -- unmeasurable rows can
never also be exposed, since the guard is gated on has_peak -- with one that the
five counters PARTITION the batch, so no row can fall through a gap and be
invisible in a log.

72 tests; count guard raised by running collection.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Re-ran the mutation sweep against the CURRENT tree rather than trusting the
earlier one, whose sed anchors no longer matched anything -- a stale anchor
reports as a survivor, indistinguishably from a real one, so that sweep was
describing a revision that no longer exists.  15 mutations; the fixed harness
asserts each anchor matches EXACTLY ONCE and verifies the pristine restore
byte-for-byte between mutants.

Two survivors, both defects in the tests I had just added to kill them:

The banner test matched the wrong LINE.  `--interpolate-time` prints the
identical phrase "honoured by this configuration", so asserting that the
substring "...: False" appears somewhere in the output passes as long as EITHER
banner says False.  A mutation making the quadrature banner claim True
unconditionally survived on the stencil line's False.  Now matched with an
anchored regex that pins the quadrature line's value and its honoured flag
together.

The right-edge test was not at the edge.  It parked peaks at samples 583-613,
far enough inside that `jmax > npts-1-guard` and `jmax > npts-guard` agree; the
off-by-one leaves exactly one row's worth of the guard band open.  Now driven to
the boundary indices themselves -- guard-1 / guard and npts-1-guard / npts-guard
-- and it also asserts the accepted rows are actually REFINED there, so the guard
is deciding something rather than being masked by a factor of 1.

Documents a corner found while writing it: a peak on the very first or last
SAMPLE classifies as flat rather than wrap-exposed, because the curvature stencil
is clipped inward and measures a positive second difference.  That understates
the window-centring problem in the diagnostic but is safe -- such a row is never
refined, so it gets the historical value either way.  Pinned as such.

Also a skip guard in the CI gate.  `pytest -q` exits 0 with skips, so a test that
quietly stops running reads as green, and the count guard catches DESELECTION not
SKIPPING.  Exactly one skip is expected on a CPU runner (the GPU parity test);
zero on a GPU runner, where RIFT_CI_REQUIRE_GPU=1 makes it fail instead.

Final: 73 tests, 15/15 mutations killed, gate green end to end.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…e record

Fifth external review, three findings, all confirmed.

1. The label reached only the SAMPLE file.  write_samples() early-returns when
   --save-samples/--output-file is absent, and write_dat() then published the
   numeric evidence row unchanged -- so a run with export disabled got neither
   warning nor any persistent label, and the primary .dat could be collected as
   an ordinary integration.  There is now angle_grid_suspect_note(), write_dat
   puts it in the .dat header, and analyze_one emits the stderr warning ONCE
   per event BEFORE either artifact is written, independently of export flags.

2. jax.debug.callback cannot carry correctness-critical provenance: effects may
   be dropped, duplicated or reordered under transformation and may land after
   the result is ready -- so a reader can see CLEAN while a tripped callback is
   in flight, or reset for the next event before the previous event's callback
   arrives.  amp_failsafe_state() and reset_amp_failsafe() now call
   jax.effects_barrier() first, and the docstrings say plainly that this is a
   best-effort LABEL, not a gate: a clean read is not proof of adequacy.

3. The callback was unconditional, so every ordinary likelihood evaluation
   transferred to the host and mutated n_calls -- once per MALA/flowMC
   proposal, per chain, destroying accelerator throughput even when undersizing
   never occurs.  It now sits inside lax.cond; only the rare tripped branch
   pays anything.

Tests: the driver test now checks write_dat carries the note and analyze_one
reports per event; a new test pins that debug.callback is INSIDE lax.cond and
that both state accessors barrier.  EXPECTED_TESTS 172 -> 173.

Not yet fixed, and the reason CI is still red: the gate run is being killed by
the runner ("received a shutdown signal", exit 143) at ~15 min, i.e. resource
exhaustion rather than a test failure -- the collection floor now matches
(172/172).  m_max-scaled dense sizing enlarged the remaining gated cases; that
is the next thing to reduce.
… bites

Three CI failures, three different symptoms, one cause -- the suite is too
expensive for a per-PR gate on a 2-core runner:
  * 169 tests: CANCELLED at the job's 60-minute timeout-minutes cap (65 min);
  * next head: OOM-killed the runner at 19 min;
  * next head: reached 83%, then "the runner has received a shutdown signal"
    (exit 143) six minutes later with no further progress.
The 139-test baseline ran in 13m53s.  The collection floor was never the
problem after the first fix -- 172/172 matched.

So test_angle_marg_exact.py is EXCLUDED, with its reason, its cost history and
a copy-pasteable hand-run command written next to the entry.  It is a
development check in the same sense full RIFT analysis runs are: it establishes
the schemes' error law at production amplitude, which is a property of the
mathematics and does not change commit to commit.

What could NOT simply be excluded is the sizing regression.  My earlier
justification for the split -- "exactness does not depend on amplitude" -- is
false for the dense quadrature, and every low-scale brute-force test in that
file PASSES under the old m_max-blind rule.  Excluding the file wholesale would
therefore have made a revert of _dense_grid_sizes green.  So
test_dense_phi_sizing_must_scale_with_m_max is extracted into its own gated
file, test_angle_marg_sizing_rule.py: pure numpy, closed-form I0 reference,
10.8 s including the RIFT import, and it FAILS under the broken rule (0.498
nats vs 1.17e-10).  It carries a self-check that fails if it ever stops biting.

EXPECTED_TESTS 173 -> 141 (139 baseline + the extracted regression + parametric
siblings), counted by collection.  Ledger entry rewritten to describe what is
actually gated rather than what used to be.
The first real end-to-end run died in the terminal stage: both per-model CIP
jobs failed with

    IndexError: too many indices for array: array is 1-dimensional

because approx_<A>_all.net was EMPTY.  The cause, from unify_model.sh's stderr:

    unify_model.sh: line 2: macroapprox: command not found
    ls: cannot access '.../approx__*.composite': No such file or directory

Inside bash, $(macroapprox) is COMMAND SUBSTITUTION.  It runs a nonexistent
command, expands to the empty string, and the glob silently matches nothing.
Condor never sees it: macros are expanded in the SUBMIT file, not in the script
the submit file invokes.

This is the THIRD instance of the same bug in this builder, and the second I
wrote myself.  The first was join_grids.sh globbing approx__overlap-grid-*,
which this PR's own design note describes at length.  I documented the trap and
then walked into it in a file I added.

Fixed the same way join_grids.sh already worked: when the pattern carries a
macro, the script takes it as $1 and the .sub passes it as an argument, so
condor does the expansion.  Applied to write_unify_sub_simple and write_cat_sub;
both default to the previous behaviour when no macro is present.

AND A GENERAL GATE, because two fixes and a design note did not stop a third
instance: test_no_condor_macro_survives_into_a_shell_script scans every emitted
.sh for $(macro...).  It found catjob.sh on its first run -- a live bug not yet
hit at run time, where every model's cat job wrote
extrinsic_posterior_samples_$(macroapprox).dat, i.e. the SAME file
"..._.dat" for both models, silently overwriting each other's extrinsic
posterior.  That is the output the whole workflow exists to produce.

The class is invisible to every check that existed: DAG shape is fine, the
directories exist, the macros in the .sub files all resolve.  It only appears at
run time as a missing file, which is why the assertion is at build time.

23 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The `_DENSE_CHUNK_BYTES` docstring claimed chunking "cannot change the answer",
and the test asserted bit-identity between a chunked and an unchunked run.  Both
were too strong.  The batch shape reaches numpy's FFT and its pairwise summation,
so a differently-chunked run reassociates; the companion peak-local
implementation inherited this same wording, had a bit-identity test written to
it, and measured 0, 0 and 2 ULPs.

This fixture happens to come out bit-identical, which is precisely why asserting
it was a latent flake rather than a passing test: it would have held here and
failed on a different row count or chunk boundary.  The assertion is now a
tolerance far below anything that could hide a real assembly bug -- dropping a
chunk moves rows by nats, not ULPs -- and the docstring states what is actually
guaranteed.

Found by an independent reviewer of the peak-local work, who scoped it out of
their diff and left it here where it belongs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
oshaughnessy-junior and others added 20 commits September 3, 2026 06:06
…call sites

FOURTH P2 finding on this PR, and the third on the SAME predicate.  It went
`flag` -> `flag + envelope` -> `flag + envelope + path`, each round adding a
conjunct the dispatch-time expression already had in effect, each round found by
a reviewer rather than by a test.  It could not be otherwise: an over-broad
predicate downgrades the inherited stencil to 'nearest', which is EXACTLY the
historical behaviour, so nothing fails, nothing logs, and no value test
distinguishes it.

THE DUPLICATION IS THE DEFECT, so the fix is structural rather than a third
conjunct.  New fused_calmarg_in_use(opts, calibration_marginalization=None) is
the single definition; both the startup stencil guard (_fused_calmarg_would_run)
and the dispatch-time use_fused_calmarg now call it.  A fused kernel runs only
where all of these hold, and each is a REACHABILITY fact checked against the
call sites, not a guess:

  * calibration marginalization configured;
  * --calibration-fused-kernel (opt-in);
  * --calibration-n-realizations > 1 -- factored_likelihood returns from its
    `n_cal == 1` branch before cal_method is read at all;
  * NOT --rotation-slow and NOT --freqresponse -- both REPLACE the likelihood:
    the non-distmarg fused call site sits in the `else` of their dispatch, and
    the two distmarg sites are unreachable because both options refuse distance
    marginalization at startup;
  * NOT --calibration-dump-responsibilities -- that pilot evaluates with an
    explicit cal_method='loop' and return_cal_components=True (which the library
    also refuses to fuse) and RETURNS before production integration is built.

The last one matters beyond this PR: the pilot is a legitimate diagnostic mode,
not an incompatible combination, so no rejection layer should ever remove it and
this predicate has to keep excluding it on its own.  The rotation-slow and
freqresponse conjuncts stay correct whether or not those combinations are
rejected earlier elsewhere -- rejection makes them unreachable, not wrong.

WHAT THE EARLY CALL CANNOT SEE, stated instead of left to be rediscovered.  At
startup `calibration_marginalization` does not exist yet; it is set several
hundred lines later by `if opts.calibration_envelope_directory:` from a False
initial value, so passing None substitutes that option -- a restatement of the
same condition, not an approximation.  It is also CHECKED: the dispatch-time
call passes the real variable and the driver REFUSES if the two disagree, so a
change to how calibration_marginalization is derived fails loudly instead of
silently re-opening the drift.  Nothing else in the predicate is unavailable at
startup; every other term is a command-line option.

TESTS PIN THE STRUCTURE, NOT A VALUE, because the next forgotten conjunct will
be a fourth expression that agrees with every case anyone thought to write down:

  test_the_fused_predicate_has_ONE_definition_shared_by_BOTH_call_sites
      -- one FunctionDef, >=2 call sites, neither old hand-written shape back in
         the file, and the startup/dispatch agreement tripwire still present.
  test_no_fused_configuration_that_cannot_fuse_downgrades_the_default
      -- the five negative cases, one per REASON rather than folded together,
         since they fail differently and one expression has already been wrong
         about three of them.

The positive edge is unchanged and still pinned: a genuine baseline fused run
still downgrades (test_default_stays_off_the_fused_calibration_kernel).

MUTATION-CHECKED, 8 mutants, 8 killed:
  drop the rotation-slow / freqresponse / dump-responsibilities /
  n-realizations conjunct (4)  -> test_no_fused_configuration_that_cannot_fuse...
  drop the envelope substitution -> 2 tests
  predicate never true           -> 3 tests
  re-inline an early copy        -> test_the_fused_predicate_has_ONE_definition...
  delete the agreement tripwire  -> initially SURVIVED (it cannot fire until
      someone changes how calibration_marginalization is derived, so no
      behaviour test can reach it); now killed by an ast check asserting the
      tripwire is PRESENT.  Labelled in the test as a structural pin, not
      claimed as behaviour coverage -- an unexercisable guard that can be
      silently deleted is worse than one that is honestly marked.

RELATED, FOUND WHILE SURVEYING THE OTHER CONSUMERS, NOT FIXED HERE.  :535 and
:548 guard --rotation-slow / --freqresponse on GPU against calibration
marginalization using `getattr(opts, 'calibration_marginalization', False)`.
There is no --calibration-marginalization option and nothing ever assigns that
attribute, so BOTH guards are dead and have never fired; the live state is the
module-level calibration_marginalization, not set until much later, so the check
would have to read opts.calibration_envelope_directory -- the same substitution
fused_calmarg_in_use documents.  Recorded in 9.6.3 and reported on the PR for
the separate early-rejection change, which covers exactly those combinations.

Gates: q-window-stencil-check list 64 passed, 2 skipped; the three companion
suites 115 passed, 1 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…nt that lied

External review, correct: the whole-cell fallback does not add nodes.  It spreads the
SAME n_nodes over a wider interval, so rejecting a stalled peak made the resolution
WORSE, not safer -- and the comment I had written there claimed the branch "can only add
nodes, never move the centre", which asserts the opposite of what the code does.  That is
the defect class this work keeps finding in other people's code, in mine.

Reproduced by search rather than taken on report: a table whose fallback cell moves the
inner integral by 1.7e-03 nats between 64 and 1024 nodes -- worse than the 7.2e-4 the
reviewer measured.

The node count is now DERIVED, not fixed: |d2g/du2| <= M2u = |c1| + 4|c2| exactly, so
nothing on this axis is narrower than sigma_min = 1/sqrt(M2u), and a spacing of
sigma_min/_PTS_PER_SIGMA resolves the sharpest feature the coefficients admit.  That
takes the counterexample from 1.7e-03 to 2.2e-04 nats at essentially no cost (2.2-6.4 s
across the amplitude range, errors 0.0).

WHAT WAS DELIBERATELY NOT DONE, with the number that decided it.  A boundary-peaked
fallback cell is not Gaussian there -- exp(g) falls off like exp(-|g'| du), so full
convergence needs a spacing set by 1/M1u rather than 1/sqrt(M2u).  Implemented and
measured, that costs a 25x slowdown (2 s -> 49 s) to recover the remaining 2.2e-04 nats.
Not taken.  2e-04 nats is orders below anything this rule is asked to decide -- its
acceptance tolerance is 23 nats -- and the trade is written into the code with its
measured price so the next reader can make it knowingly rather than rediscover it.
Raising _PTS_PER_SIGMA or passing n_nodes are the honest knobs if it is ever wanted.

Scope, checked rather than assumed: this path is reachable from nothing.  grep finds no
importer of phi_local_marginalize or u_profile outside their own module, so no production
calculation -- 3g included -- can reach it.

26 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…marg_wiring

Wire 'peak-local' into ILE as an angle-marg scheme (explicit-only, ready for a head-to-head pilot)
The drift ledger caught the new module-level function in the main ILE driver and
refused it, which is the gate working: every function that appears in one driver
and not the other has to carry a recorded decision about LISA, written as a RULE
with a reason rather than hand-edited into the JSON.

NA.  Every term of this predicate is a LIGO/Virgo calibration concept -- an
envelope directory, a realization count, --calibration-fused-kernel, the
responsibilities pilot -- and the LISA driver has none of them: it declares no
--calibration-* option, never sets cal_method, and models no instrument
calibration at all (`grep -c calibration_fused_kernel|calibration_envelope_
directory|cal_method` on integrate_likelihood_extrinsic_batchmode_lisa is 0, 0,
0).  Same reason as the existing --calibration-* rule this one sits beside.

Called out in the rule because it is the one thing that could mislead a later
reader: this function is READ BY THE STENCIL GUARD, and the LISA driver does have
--interpolate-time, so it looks like a stencil gap.  It is not.  LISA's
--interpolate-time is a separate BOOLEAN parsed by
legacy_time_interpolation_enabled, with no fused kernel to protect, so there is
nothing for this predicate to decide there.  If LISA ever models calibration, the
thing to port is the one-definition discipline, not this function.

Ledger regenerated with the generator, not edited: 91/91 classified, NA=48
PORT=43, and the JSON diff is the four lines of the single new entry.
test_lisa_driver_drift.py: 8 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…eference

Reviewer: "marker validation is fail-open -- any syntactically valid marker is treated as
coverage."  Correct, and the docstring made it worse by asserting the opposite: it claimed
all three reachability signals could only UNDER-report, never falsely clear a file.  That
was false for two of the three, and both were live.

1. MARKER NAME UNVALIDATED.  MARKER_RE matched shape, not meaning, so
   `# RIFT-CI-GATE: totally-made-up-job` -- which no script greps for -- cleared a file.
   Planting it on test/test_hlm.py, a file that cannot even be IMPORTED because it wants the
   pre-package `factored_likelihood`, moved it into the "reachable from CI" count and made
   its LEGACY roster entry report as STALE.  That is the inert-guard failure this census
   exists to catch, reintroduced inside the census.
   Fix: KNOWN_GATES maps gate name -> the script that discovers files by that marker.  A
   marker counts only if its name is registered AND that script really greps the literal.  An
   unknown name is a hard error naming the typo, not a silent demotion to "unregistered",
   because the actionable diagnosis is "fix the marker", not "add a roster entry".
   KNOWN_GATES is a shared line -- one per JOB, edited when a gate is created rather than
   when a test is added, so it does not carry the per-test conflict cost that motivated PR
   #242's marker.

2. A NAME IN A COMMENT COUNTED AS A REFERENCE.  A comment is exactly where a CI file explains
   what it does NOT run, so it is the one place a name appears without being invoked:
   ci.yml's comment saying why the two cupy parity files are EXCLUDED was enough to mark them
   covered.  Worse, _cfg_blob() read .travis/*.py including this script, so a filename typed
   into an explanatory comment HERE marked that file covered -- which is how it was found,
   while writing the fix for (1).
   Fix: strip comment-only lines, and exclude this script from the blob.  Every genuine
   reference is in a run: block, a pytest argument or a FILES array; none is on a '#' line.
   Consequence: the two GPU parity files are now correctly rostered rather than carried by a
   comment, which retires the apologetic note that stood in their place.

The same hole existed one level down in the fix for (1): test-q-window-stencil.sh quotes its
own marker inside the comment block explaining the mechanism, so a bare `literal in text`
passed even with the live MARKER= assignment renamed away.  Checked, it did.  Both uses now
go through _strip_comment_lines.

Five new mutations, each broken and seen to fail; the original eight re-run and still fail.
Table in the PR.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ocal

DRAFT: phi-localization -- both angle axes localized, cost flat in amplitude
Review finding on #237 (P1): the two `getattr(opts, 'calibration_marginalization',
False)` guards never fire, and gating on `opts.gpu` would leave the CPU
replacement paths unprotected even if they did. Both halves are correct. This
gate already removes those guards and refuses on the envelope alone, on either
backend -- but nothing PINNED the backend-independence, so re-adding an
`opts.gpu and` condition would have passed the whole suite.

The two existing 3G tests both run through `_honoured(...)`, which sets
gpu=True. Adding the CPU case is not a one-line flip: at gpu=False the
configuration also earns the separate --gpu refusal, so `len(r) == 1` would then
pass for the wrong reason. The new test asserts by MEMBERSHIP on
(--calibration-envelope-directory, --rotation-slow / --freqresponse) instead.

Mutation-tested rather than assumed. Reintroducing the reviewer's defect --
`if rotation_slow and xpy_evaluator:` and the same for freqresponse -- fails the
new test and nothing else (1 failed, 23 passed); restored, 24 pass. So the test
discriminates the backend condition specifically.

Verified on rift_O4d while confirming the finding, since the refusal messages
assert it: neither replacement entry point takes n_cal or cal_method
(factored_likelihood_with_rotation.py:770, factored_likelihood_freqresponse.py:328),
and factored_likelihood_freqresponse.py contains no calibration reference at all.
Stronger still, the rotation kernel's own docstring (:778) already states
"Requires n_cal=1 (no glitch/calibration marginalization)" -- a documented
precondition whose only enforcement was the inert guard.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…NG an expiry

Three P2s, all correct.  Each was reproduced before it was fixed.

[P2] MARKER VALIDATION DID NOT PROVE CONSUMPTION.  Checking that the marker literal appears
on a non-comment line passes an ORPHANED `MARKER=` assignment whose discovery grep has been
deleted.  Tightening it to "and a line matching grep.*MARKER" does not fix it either:
test-q-window-stencil.sh contains `grep -qxF -- "${MARKER}" "${e}"` for the opposite purpose
-- asserting an EXCLUDED file does NOT carry the marker -- so deleting the real discovery
line still satisfied the pattern.  Tried, and it passed green.  No regex separates "uses the
marker to find files" from "uses the marker".

So the inference is gone.  A gate is honoured only if its script can be ASKED:
RIFT_CI_GATE_LIST=1 must print the files it would run and exit 0, and that listing is what
the census believes -- a file carrying a valid marker that the gate does not return is now an
error against the file.  Placement is part of the contract (discovery above the dependency
probes) because this job has no `needs: install`.  A marker naming a gate that cannot list is
an error quoting the snippet; a registered gate with no marked files costs nothing.

[P2] DIRECTORY COVERAGE SURVIVED JOB REMOVAL.  DIR_TARGETS checked only that the directory
existed.  Deleting the asimov-integration job from ci.yml AND .gitlab-ci.yml left
.travis/test-asimov.sh on disk, still naming the directory, and the census went on reporting
its three files as covered -- green, with nothing running them.

Root cause was wider than that entry: the config blob was "every file in .travis/", so ANY
script no job invokes still conferred coverage.  It is now the transitive closure from the
workflow entry points -- a script joins when something already live names it -- so deleting a
job drops its script out and its files go red.  Removing the lisa job now flags 14 files.
DIR_TARGETS additionally requires the directory to appear in that live set.

[P2] PENDING NEVER EXPIRED.  Its exemption from the stale check was unconditional, which
bought merge-order independence at the price of the one status nothing could ever force out.
It now carries a condition that can be evaluated: the reason must name `gate:<name>`, and the
entry is legal only while that gate is NOT live.  When the gate lands, the entry is an error
naming itself.  The cost -- merging the companion PR needs a one-line deletion here -- is a
forcing function, and is stated rather than engineered away.

Verified against a real merge with the q-window-stencil branch: merges clean, and with an
8-line hoist there (discovery above the probes plus the list-mode line) the census passes and
that gate still reports 8 registered files / 46 collected / 44 passed.  Patch offered on #242.

14 mutations, each broken and seen to fail, plus the honoured path checked positively.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…tencil-manifest

ci: stop q-window-stencil-check silently losing tests (in-file marker + fail-closed manifest)
#242 is merged, so the q-window-stencil gate now exists on rift_O4d and the census's
requirements against it are live rather than hypothetical.  All three edits predicted in the
PR body, and nothing else:

1. .travis/test-q-window-stencil.sh: hoist CODEDIR + the marker discovery above the
   pytest/numpy/lal probes, and answer RIFT_CI_GATE_LIST=1 with the discovered files.
   Discovery needs only grep, so nothing is lost by running it first, and ci-roster-check --
   which has no `needs: install` -- can now ASK this gate what it runs instead of pattern-
   matching its source.  That distinction is not pedantic: this script contains both the
   mapfile that discovers files AND a `grep -qxF -- "${MARKER}"` that asserts an EXCLUDED file
   does NOT carry the marker, so any regex-based check keeps passing with the discovery
   deleted.  Verified: the gate still reports 8 registered files, 46 collected, 44 passed.

2. The two cupy parity legs leave the roster.  #242's EXCLUDED array records them with the
   same reason plus its own fail-closed existence check, which is a better home -- the
   decision now sits beside the gate it belongs to.

3. The PENDING entry expires, exactly as designed: its gate went live, the census named the
   entry, and the entry is gone.  The status keeps its definition and its tests; it simply has
   no users.  Its header text is corrected too -- it still described the old unconditional
   "legal in either merge order" semantics, which stopped being true when the expiry landed.

Census on the merged tree: 201 test files, 147 reachable, 54 rostered, PASS.  The merge also
brought new tests from #235 and #239; all are already registered, which is the census doing
its job quietly.  core-unit-check unchanged at 278/266/12.

Eight mutations re-run against the now-live gate, including four that only became testable
once it existed: list mode removed, discovery deleted, listing narrowed to drop a marked file,
and the gate script left in place but invoked by no job.  All fail; the honoured path passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…test with it

The conflict was in .github/workflows/ci.yml and it is the conflict #242 was
written to abolish: this branch hand-added test_batchmode_stencil_default.py to
the shared pytest list, and #242 replaced that list with
.travis/test-q-window-stencil.sh, which discovers members from a marker line
inside each test file. Resolved to the script.

BUT RESOLVING IT THERE WOULD HAVE SILENTLY DROPPED THIS PR'S TEST. The script
discovers by marker, and the new file carried none -- it was only ever registered
by the ci.yml line the merge deleted. So the merge as `--theirs` alone builds
green with 23 tests no longer running. Marker added to the test file.

AND THE FAIL-CLOSED MANIFEST WOULD NOT HAVE CAUGHT IT, which is the part worth
fixing rather than working around. The manifest fails the job for any file
matching SCOPE_GLOBS that carries no marker -- but the globs owned
test_q_window_*, test_time_interp_*, test_interpolate_time_*,
test_calmarg_stencil_* and test_noloop_*, and this file is
test_batchmode_stencil_*. It fell in the gap the script's own header admits
("a new stencil test filed under some OTHER prefix"). Added
test_batchmode_stencil_*.py to SCOPE_GLOBS, so the next one is caught rather than
depending on whoever resolves the merge noticing.

Mutation-tested, not assumed: with the marker stripped the job now FAILS with
"neither registered nor explicitly excluded" and prints the line to add; restored,
it passes. Before the glob was added the same mutation was green and silent.

Floors re-derived as the script's header requires: EXPECTED_TESTS 46 -> 69,
EXPECTED_PASSED 44 -> 67 (the new file collects 23). MAX_SKIPS unchanged at 2 --
the two cupy legs. Raised, not lowered.

Verified on citlogin6 with the IGWN CVMFS python: 9 registered files, 69
collected, 67 passed, 2 skipped, rc=0. A first attempt on the session host died
mid-run at 454/500 threads and reported success from the launcher rather than the
script -- the run above is the real one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CI caught what my local run could not: 69 collected as pinned, but 3 skips
against the cap of 2, so 66 passed against a floor of 67. The gate did exactly
its job -- a skip is how a gate gets disabled quietly, which is why it is capped
rather than tolerated.

THE SKIPPED TEST WAS test_the_two_ile_drivers_ship_the_same_default -- issue
#233's single assertion, the one tying the batchmode and jax defaults together.
It imported RIFT.likelihood.jax_ile.core, and q-window-stencil-check runs on a
numpy+lal image with no jaxlib BY DESIGN ("Needs numpy + lal only", the script's
own header). So the assertion was skipped in the only job that runs this file: a
gate skipped exactly where it is needed is not a gate. It passed on citlogin6
because the IGWN conda env has jax, which is why I did not see it.

FIXED BY REMOVING THE SKIP, NOT BY RAISING THE CAP. The script does allow
"raise MAX_SKIPS and say which test and why", and I could have justified it --
the docstring already argued the skip was benign because core.py ALIASES the
shared default rather than copying it, so drift is structurally impossible. That
argument is true and it is still the wrong fix: it accommodates a hole the
docstring itself calls "a real hole".

The test now reads the binding out of jax_ile/core.py with ast and never imports
the module, so it runs everywhere. That is also a STRONGER assertion than the one
it replaces: the old version compared two values, so two independently written
literals both reading 'sinc' would have satisfied it -- and two independently
written literals are exactly how the drivers came to ship opposite defaults.
The new one requires JAX_INTERP_DEFAULT = TIME_INTERP_DEFAULT as a NAME binding.

Mutation-tested: replacing the alias with a literal of the SAME value ('sinc')
fails the new test and would have passed the old one. Restored, it passes.

Floors therefore unchanged at 69 / 67 / 2 -- nothing lowered. Full gate on
citlogin6: 9 files, 69 collected, 67 passed, 2 skipped (the two cupy legs), rc=0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… > 1

Review P2 on #244. Verified against the source rather than the flag names, and
the reviewer's request to "audit the other options guarded by n_cal > 1" found a
second one.

--calibration-fused-kernel at n_cal == 1. factored_likelihood's `if n_cal == 1:`
branch RETURNS (:2942) before `cal_method == 'fused'` is read (:3041), so the
fused reduction is unreachable with one realization. The run is not wrong -- the
ordinary reduction is correct -- but it advertises a kernel it did not use, which
is this gate's whole subject.

--calibration-burn-in-neff at n_cal == 1, which the P2 did not name. The driver's
burn-in block is `if opts.calibration_burn_in_neff and calibration_marginalization
and n_cal_for_likelihood > 1:` (:4058), so one realization means no burn-in and a
silently ignored target. Same prerequisite, same silence.

NOT INCLUDED, deliberately: --calibration-conjugate-phase and
--calibration-global-norm. They reach the likelihood through extra_kwargs on the
PRECOMPUTE and are honoured at n_cal == 1 too -- that branch uses rho_sq_cal[0] --
so refusing them would be a false positive, which is the failure this gate exists
to avoid rather than commit. Pinned by a test that asserts they stay accepted.

n_realizations is passed in but stays OUT of CAL_OPT_IN_FLAGS: it has a non-None
default (100), so a rule over its presence would refuse every calmarg run. A test
pins that the new rules are silent when the default is left alone.

MUTATION SWEEP, three mutants, all killed: disabling the predicate, an off-by-one
(`< 1` for `<= 1`), and an over-broad version that fires without the flag.

METHOD NOTE, because it nearly cost a wrong conclusion: the over-broad mutant
first appeared to SURVIVE with 31 passed. It had not survived -- the test host
reads this tree over NFS and was still serving the pre-mutation file. Re-run with
md5sum compared on both sides, it fails at test_option_compat.py:314 as intended.
A mutation that "survives" because the runner never saw it is a false negative
that reads exactly like a coverage gap, so every mutant here was confirmed with
the remote md5 matching the local one.

31 tests pass on the IGWN CVMFS python.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ion_gate

ILE batchmode: refuse calibration-option combinations that cannot work (calmarg x 3G is untested, so it must not silently degrade)
…tencil_default

batchmode: default the Q_lm sub-sample stencil to sinc, and downgrade rather than refuse (result-changing)
…kout

[P2] PENDING REMAINED STALE WHEN ANOTHER JOB REGISTERED THE FILE.  Correct.  The PENDING branch
checked its named gate and then `continue`d UNCONDITIONALLY, skipping the staleness check
below.  So a file that became reachable through some OTHER job -- while the gate it named
stayed dormant -- kept its PENDING entry for ever.  That is the same "never expires" defect the
previous round removed the blanket exemption to close; it survived one level in.

PENDING now carries an EXTRA condition rather than a weaker one: it falls through, so it goes
stale the moment the file is covered BY ANY JOB, and separately when its gate goes live.  The
message says which, because "delete this line" for the two reasons has different follow-up.

Reproduced both ways before landing: a reachable file rostered PENDING on a dormant gate now
FAILS ("listed as PENDING but IS now reachable"), and with the `continue` restored the same
roster passes green.  The legitimate case -- unreachable file, gate not yet landed -- still
passes.

TEST-CALMARG.SH TESTED WHATEVER WAS INSTALLED.  Its first check runs
`python <path>/test_precompute_alignment.py`, which puts the SCRIPT'S directory on sys.path and
not Code/, so bare `import RIFT` resolved to the installed package.  In CI that is the editable
install of this checkout, so it passes and the gap is invisible; on a plain CIT checkout it
picked up the CVMFS IGWN RIFT, which predates a kwarg the checkout added, and reported that
staleness as a failure of this branch:

    PrecomputeLikelihoodTerms() got an unexpected keyword argument 'calibration_realizations'

I nearly filed that as a #237 x #244 interaction.  One `export PYTHONPATH` prepend at the top
fixes the whole script and matches the invariant its sibling gates state explicitly.  The
`python -m RIFT.calmarg.*` runs were already safe (cwd is on sys.path under -m); this makes the
rest safe the same way.  Verified: 31 passed on a bare checkout, where it previously died at
import.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…r-census

CI: census every test file against a roster, and gate the 31 that were running nowhere
@oshaughnessy-junior
oshaughnessy-junior deployed to private-review-dispatch-rift-upstream September 4, 2026 11:30 — with GitHub Actions Active
@oshaughn
oshaughn deployed to private-review-dispatch-rift-upstream September 4, 2026 12:49 — with GitHub Actions Active
@oshaughn
oshaughn merged commit fbcb136 into oshaughn:rift_O4d Sep 4, 2026
30 checks passed
@oshaughn
oshaughn deployed to private-review-dispatch-rift-upstream September 4, 2026 13:42 — with GitHub Actions Active
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants