Skip to content

_rvs fair-draw audit: enumerate every consumer, fix what it found, gate the rest - #87

Merged
oshaughnessy-junior merged 4 commits into
rift_O4dfrom
claude/rvs-fairdraw-audit
Aug 14, 2026
Merged

_rvs fair-draw audit: enumerate every consumer, fix what it found, gate the rest#87
oshaughnessy-junior merged 4 commits into
rift_O4dfrom
claude/rvs-fairdraw-audit

Conversation

@oshaughnessy-junior

Copy link
Copy Markdown
Owner

Closes the audit the last four PRs left open: "Every other consumer of _rvs rows or lengths
in the ILE is unaudited. Everything found here was in code this change happened to touch."

sampler._rvs is not the sample set. integrate_log rebinds every key to a subset drawn WITH
REPLACEMENT proportional to weight — an EXPORT resample. Five defects had come from consumers
reading it as the sample set, each found only by accident. This sweep is mechanical and
re-runnable, and everything it found is fixed here rather than left as follow-up.

The census

306 reads; 131 see the resample. audit_rvs_fairdraw.py --check runs in CI and fails on any
post-rebind read without a recorded verdict, keyed by a hash of the read itself rather than its
line or its function — analyze_event alone holds 40 sites, and every known defect was added
next to correct code. Ledger BROKEN: 8 → 1 (the remainder is #79's documented
cross-source fallback).

Two results worth keeping:

  • Inside the integrators nothing post-rebind is broken. All 26 lexically-AFTER sites are the
    rebind's own RHS or the cupy→numpy conversion loop. The hazard is entirely in the consumers.
  • This is never exotic. create_event_parameter_pipeline_BasicIteration,
    cepp_basic_htcondor and create_event_nr_pipeline_with_cip all pass
    --fairdraw-extrinsic-output to the extrinsic stage unconditionally.

Fixed

Sequential warm-start seed. --sampler-sequential-warmstart read _rvs and guarded on a row
COUNT — PR #78's defect one code path away. Both directions are quiet: on the measured pass the
draw leaves ONE row so the count declines and the feature is silently inert; at ~5 rows it
accepts a rank-2-of-6 seed and the next point warm-starts into a sliver reporting a healthy
n_eff. Now seeds from the retained reserve and judges by affine RANK. The L0 rescue's inline
lookup is replaced by a shared _warm_seed_reserve_for so the pair cannot drift.

Three double-weighting sites. A fair-drawn record is already equal-weight; weighting it by
w again gives . _pool_replica_rvs has guarded against this since the replica work; these
had no equivalent — the --extrinsic-proposal-output GMM breadcrumb (worst: its
over-concentrated proposal is handed to the NEXT iteration, so truncation compounds), .dgrid,
and the .dslice reweight core. The last is a different shape and cannot be corrected once the
pre-draw record is gone, so it is routed to the exact all-fresh path, loudly, since that changes
cost.

The predicate is not the CLI flag: samplers skip the draw when it would not shrink the
record, and those rows still carry real weights — flattening them would be the same error
reversed. All seven rebind sites now mark the rebind itself.

Pooled n_eff. Kish of the pooled record overwrites the reported neff, but on a fair-drawn
export _pool_replica_rvs has flattened each block, and Kish of piecewise-constant weights is
just the row count (5K at the default --fairdraw-extrinsic-output-n-max 5). Now Kish over the
BLOCKS, (Σ Z_k)² / Σ(Z_k²/neff_k) — reduces to Σ neff_k when replicas agree, falls below it
when they do not.

Measured: --sampler-l0-rescue-reject-dlnZ, default raised 0.5 → 3.0

160 known-lnZ passes across AV and portfolio. The gate caught 0 of 55 genuinely truncated warm
passes at every threshold from 0.25 to 4.0 nats
, while at 0.5 it binned 25% of good portfolio
warm passes
. 0.5 was strictly dominated.

                 AV  (28 good, 52 truncated)   portfolio (77 good, 3 truncated)
    dlnZ      FPR        TPR                 FPR        TPR
    0.50       0%         0%                 25%         0%
    2.00       0%         0%                  5%         0%
    3.00       0%         0%                  0%         0%

Rates are conditioned on what the warm pass actually did, not the intended condition — a
portfolio's defensive GMM component means a seeded warm pass usually still reaches every mode
(77 of 80), so scoring against the label would have counted false positives as successes.

On AV the gate's premise is false outright: the collapsed cold pass reports lnZ 70–92 nats
below truth
while the warm pass is within a nat. Not a helper bug — lnZ_from_reserve
reproduces integrate_log's own lnZ to the digit. Pre-#79 the +log(n/eff_samp) inflation
occasionally pushed the difference positive, which is where the observed 10-of-12 rejections came
from: the artifact, not the mechanism.

Support containment is the recommended replacement and is deliberately not attempted here — a
new detector wants its own validation.

Tests

34 new across test_seq_warmstart_seed.py and test_fairdraw_double_weighting.py, both added to
CI, plus the --check gate. 197 passed, 3 skipped across the integrator suites. Measurement
scripts, trimmed run logs and a per-replicate JSON dumper are committed, so every number above is
reproducible without re-running the passes.

Reviewing this

Squashed to one commit deliberately: an earlier history had a commit concluding "leave the
default", which the measurement later reversed. The reasoning trail lives in
RVS_FAIRDRAW_AUDIT.md and L0_REJECT_DLNZ_MEASUREMENT.md, where it stays accurate.

Scope limits are stated in the measurement doc: synthetic targets, rho=100, one bimodal geometry,
no real GW likelihoods or GPU backends. The portfolio truncated-pass count is only 3, so the
strong statement comes from AV's 52.

@oshaughnessy-junior
oshaughnessy-junior deployed to private-review-dispatch-rift August 13, 2026 12:46 — with GitHub Actions Active

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Independent automated review completed at the recorded exact commit. Detailed findings were withheld from public output by the private-context egress policy and require private human declassification.

…te the rest

sampler._rvs is not the sample set.  integrate_log rebinds every key to a subset drawn WITH
REPLACEMENT proportional to weight, of size min(n_extr, 1.5*eff_samp, 1.5*neff) -- an EXPORT
resample.  Five defects had come from consumers reading it as the sample set, each found only
because a change happened to touch it.  This is the mechanical sweep that was left open.

THE CENSUS.  306 reads, 131 of which see the resample.  audit_rvs_fairdraw.py enumerates and
classifies them; --check runs in CI and fails on any post-rebind read without a recorded
verdict, keyed by a hash of the read itself rather than its line or its function (analyze_event
alone holds 40, and every known defect was added NEXT TO correct code).  Ledger BROKEN count:
8 -> 1.  Two results worth keeping: inside the integrators nothing post-rebind is broken -- all
26 lexically-AFTER sites are the rebind's own RHS or the cupy->numpy conversion loop -- and the
production pipelines pass --fairdraw-extrinsic-output unconditionally, so this is never exotic.

FIXED, sequential warm-start seed.  --sampler-sequential-warmstart read _rvs and guarded on a
row COUNT: PR #78's defect one code path away.  Both directions are quiet -- on the measured
pass the draw leaves ONE row so the count declines and the feature is silently inert; at ~5
rows it accepts a rank-2-of-6 seed and the next point warm-starts into a sliver reporting a
healthy n_eff.  Now seeds from the retained reserve and judges by affine RANK via
build_warm_seed.  The L0 rescue's inline reserve lookup is replaced by the shared
_warm_seed_reserve_for so the pair cannot drift.

FIXED, three double-weighting sites.  A fair-drawn record is already equal-weight; weighting it
by w again gives w^2.  _pool_replica_rvs has guarded against this since the replica work; these
had no equivalent: the --extrinsic-proposal-output GMM breadcrumb (worst -- its over-concentrated
proposal is handed to the NEXT iteration, so the truncation compounds), .dgrid, and the .dslice
reweight core.  The last is a different shape -- it double-counts pi_Omega/q_Omega and takes N
from the resample, and cannot be corrected once the pre-draw record is gone -- so it is routed
to the exact all-fresh path, loudly, since that changes cost.
  The predicate is NOT the CLI flag: the samplers skip the draw when it would not shrink the
record, and those rows still carry real weights, so flattening them would be the same error
reversed.  All seven rebind sites now mark the rebind itself (self._rvs_is_fairdraw, reset per
pass since samplers are reused across events).  ln_weights_for_posterior answers "how should
these rows represent the posterior", distinct from ln_weights_from_rvs, which answers "what is
this record's importance weight" and was always right about that.

FIXED, pooled n_eff.  Kish of the pooled record overwrites the reported neff, but on a
fair-drawn export _pool_replica_rvs has flattened each block, and Kish of piecewise-constant
weights is just the row count -- 5K at the default --fairdraw-extrinsic-output-n-max 5.  Now
Kish over the BLOCKS, (sum Z_k)^2 / sum(Z_k^2/neff_k), which reduces to sum(neff_k) when the
replicas agree and falls below it when they do not: the property the original comment asked
for.  Pooled Kish still applies when the export was not fair-drawn.

MEASURED, --sampler-l0-rescue-reject-dlnZ: default raised 0.5 -> 3.0.  160 known-lnZ passes
across AV and portfolio.  The gate caught 0 of 55 genuinely truncated warm passes at EVERY
threshold from 0.25 to 4.0; at 0.5 it binned 25% of GOOD portfolio warm passes.  0.5 was
strictly dominated.  Rates are conditioned on what the warm pass ACTUALLY did, not the intended
condition -- a portfolio's defensive GMM component means a seeded warm pass usually still
reaches every mode (77 of 80), so scoring against the label would have counted false positives
as successes.  On AV the gate's premise is false outright: the collapsed cold pass reports lnZ
70-92 nats BELOW truth while the warm pass is within a nat.  Not a helper bug -- lnZ_from_reserve
reproduces integrate_log's own lnZ to the digit.  Pre-#79 the +log(n/eff_samp) inflation
occasionally pushed the difference positive, which is where the observed 10-of-12 rejections
came from: the artifact, not the mechanism.  Support containment is the recommended replacement
and is deliberately NOT attempted here -- a new detector wants its own validation.

Tests: 34 new across test_seq_warmstart_seed.py and test_fairdraw_double_weighting.py, both
added to CI, plus the --check gate.  197 passed, 3 skipped across the integrator suites.
Measurement scripts, trimmed run logs and a per-replicate JSON dumper are committed so every
number above can be reproduced without re-running the passes.
…eserve survived rejection

Both P1, both the same shape, and it is the shape worth naming: a fix that is correct in
isolation and wrong once another code path runs after it.  Every unit test from the original
change passed with both bugs present, because each tested its helper rather than the
composition.

1. THE FAIR-DRAW MARKER SURVIVED _pool_replica_rvs.  The pooled record is equal-weight WITHIN
each block but weighted BETWEEN blocks by exactly the replica evidences Z_k/K.  With the
marker still set, ln_weights_for_posterior returned zeros, so .dgrid and the proposal
breadcrumb would have mixed replicas by exported ROW COUNT instead of by evidence --
discarding the disagreement the replicas are run to measure.  The marker is now cleared after
pooling, and ONLY when pooling actually happened: every fallback path in _pool_replica_rvs
returns one of its INPUT records (too few replicas, no sampling-prior column, an exception),
and such a record is still the fair draw it arrived as.  Object identity, not length, is the
reliable test for that.

2. A REJECTED WARM RESCUE LEFT ITS RESERVE BEHIND.  The reject path restored _rvs, the estimate
and dict_return, but not _warm_seed_reserve, which still described the rejected warm pass.
Note the direction: this was latent until THIS BRANCH made the sequential warm start read the
reserve.  Before that the capture read _rvs, which the reject path does restore -- so the
sequential fix is what activated it, and the next intrinsic point would have been seeded from
the very truncated cloud the evidence gate had just thrown away.

Snapshot and restore now travel together through _snapshot_pass_state / _restore_pass_state,
carrying the reserve, the fair-draw marker and 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.  Both restore sites, the reject path and the exception
handler, go through the one helper so they cannot drift in what they put back.

On the tests, because it is a real limitation: analyze_event needs data, PSDs and a waveform,
so the call sites cannot be driven from a unit test.  The behavioural tests pin the contracts
the call sites depend on; the wiring is pinned at source level, including a guard that the
reject branch does not assign _rvs directly again.  Verified by reverting each fix -- only the
wiring tests fail, which is stated in the suite rather than left for the next reader to
discover.

Rebased onto rift_O4d (picks up #88 and #66); no conflicts.  7 new tests, 28 in the suite;
204 passed, 3 skipped across the integrator suites, and the ensemble script-style test still
recovers AC/GMM/AV to ~1.0.
@oshaughnessy-junior
oshaughnessy-junior force-pushed the claude/rvs-fairdraw-audit branch from 92c320f to 94f8c0e Compare August 14, 2026 08:36
@oshaughnessy-junior
oshaughnessy-junior had a problem deploying to private-review-dispatch-rift August 14, 2026 08:36 — with GitHub Actions Failure
@oshaughnessy-junior

Copy link
Copy Markdown
Owner Author

Both P1s fixed, and rebased onto rift_O4d (picks up #88 and #66 — no conflicts). New commit
94f8c0e2, kept separate so the delta is reviewable.

Both findings were correct, and they are the same shape — a fix that is correct in isolation
and wrong once another code path runs after it.
Every unit test from the original change
passed with both bugs present, because each tested its helper rather than the composition.

1. The marker survived _pool_replica_rvs

Confirmed: nothing cleared it after sampler._rvs = _pool_replica_rvs(...). The pooled record
is equal-weight within a block but weighted between blocks by exactly Z_k/K, so
ln_weights_for_posterior returning zeros would have made .dgrid and the breadcrumb mix
replicas by exported row count instead of by evidence.

Cleared after pooling — and only when pooling actually happened. Every fallback in
_pool_replica_rvs (too few replicas, no sampling-prior column, an exception) returns one of
its input records, which is still the fair draw it arrived as. The test is object identity
rather than length, since a fallback record can be the longest.

Regression with unequal replica lnZ, as requested: with rep_lnZ = [7.0, 9.0] the pooled
weights are constant within each block and the between-block offset is exactly 2.0 nats.

2. The rejected warm rescue left its reserve behind

Confirmed, and worth stating precisely: this branch is what activated it. Before the
sequential-warmstart change, the capture read _rvs, which the reject path does restore.
Switching it to the reserve is what made the leftover attribute reachable — so the fix
introduced the exposure, exactly as you describe.

Snapshot and restore now travel together via _snapshot_pass_state / _restore_pass_state,
carrying the reserve, the fair-draw marker, and the per-member reserves —
_warm_seed_reserve_for falls through to portfolio_realizations, so restoring only the
aggregate would have left that fallback pointing at the warm pass. Both restore sites (reject
path and exception handler) go through the one helper.

On the tests — a limitation I'd rather state than paper over

analyze_event needs data, PSDs and a waveform, so the call sites can't be driven from a unit
test. I verified the new tests by reverting each fix: only the source-level wiring tests
fail.
The behavioural tests pin the contracts the call sites depend on, but the bugs lived at
the call sites, so the wiring checks are what actually catch these. That's now said in the
suite and in RVS_FAIRDRAW_AUDIT.md (Finding 5) rather than left for the next reader to
discover. I also added a guard that the reject branch does not assign sampler._rvs directly
again, since that is the specific move that decoupled the reserve.

Verification

7 new tests (28 in the suite); 204 passed, 3 skipped across the integrator suites; the
--check gate is green at 132 sites; the ensemble script-style test still recovers AC/GMM/AV
to ~1.0 after #88 landed underneath.

…r replica

Both P1s were correct, and the first is a defect I introduced answering the previous round.

ONE FLAG, TWO QUESTIONS.  Clearing _rvs_is_fairdraw after pooling fixed the weighting question
and broke two others, because the flag carried two distinct properties:

    rows were drawn proportional to w   -- per BLOCK   -- TRUE for a pooled record
    the record is globally equal-weight -- whole RECORD -- FALSE for a pooled record

Clearing it stopped the .dslice all-fresh safeguard firing on a pooled record whose blocks ARE
resampled, and made the block-Kish n_eff branch UNREACHABLE: it sits below the line that
cleared the flag it tested, and is only ever reached when pooling happened.  So it was dead
code from the moment I wrote it.

Now two predicates over two markers: _rvs_is_export_resample (rows resampled, survives pooling)
and _rvs_is_equal_weight (fairdraw and not pooled).  The block-Kish branch keys on neither -- it
uses a local computed where pooling happens, because "did pooling flatten a block" is a fact
about that step, not a property of the record.  _rvs_is_pooled is set by analyze_event rather
than by a sampler, so it is dropped with the record it describes and carried through
snapshot/restore.

already_resampled WAS THE CLI OPTION, NOT WHAT EACH PASS DID.  The draw is skipped per pass when
it would not shrink that pass's record, so one global boolean either flattens a replica whose
importance weights are genuine, or leaves a resampled replica double-weighted -- and a run near
the n_extr boundary produces a MIXTURE, which a boolean cannot describe at all.
_pool_replica_rvs now takes a per-replica sequence and decides per block; the ILE captures each
replica's marker beside the record it describes.  While there: the empty-record filter is now
lockstep.  It dropped from rep_rvs alone, shifting every later block against its own lnZ -- and
would now have shifted it against its own resampled flag too.

Tests: 10 new (38 in the suite), including a raw+resampled mixture that pins BOTH directions --
the raw block keeps its weight shape, the resampled block is flattened, and both still carry
Z_k/K.  Verified by reverting each fix: the property split fails 4 tests, the per-replica
provenance fails 1.  Three tests from the previous round were stale against the new contract and
were updated rather than deleted.

214 passed, 3 skipped across the integrator suites; --check green at 132 sites.
@oshaughnessy-junior
oshaughnessy-junior had a problem deploying to private-review-dispatch-rift August 14, 2026 09:56 — with GitHub Actions Failure
@oshaughnessy-junior

Copy link
Copy Markdown
Owner Author

Both fixed in ef9bc0b3. The first one is a defect I introduced answering the previous round,
so thank you for catching it — it was dead code from the moment I wrote it.

1. One flag, two questions

Confirmed exactly as described. _rvs_is_fairdraw was carrying two distinct properties:

property scope pooled record?
rows drawn proportional to w per block yes
record is globally equal-weight whole record no

Clearing it after pooling fixed the second and broke the first in two places, both verified by
line order: the .dslice safeguard (4228) and the block-Kish n_eff branch (3837) both sit
below the pooling step (3779) that cleared the flag they test. And since the block-Kish branch
is only reachable when pooling happened, it could never fire at all.

Now two predicates over two markers — _rvs_is_export_resample (rows resampled; survives
pooling) and _rvs_is_equal_weight (fairdraw and not pooled). The block-Kish branch keys on
neither: it uses a local computed where pooling happens, because "did pooling flatten a
block" is a fact about that step rather than a property of the record. _rvs_is_pooled is set by
analyze_event, not by a sampler, so it is dropped with the record it describes (the sampler's
per-pass reset cannot clear it) and carried through snapshot/restore.

2. already_resampled was the CLI option

Confirmed. Now a per-replica sequence: _pool_replica_rvs decides per block, and the ILE
captures each replica's marker beside the record it describes. The mixture case is pinned both
ways — the raw block keeps its weight shape, the resampled block is flattened, and both still
carry Z_k/K.

While in there I found an adjacent latent bug: the empty-record filter ran on rep_rvs alone,
so a single empty replica shifted every later block against its own lnZ — and would now have
shifted it against its own resampled flag too. Filtering is lockstep now, with a test.

Verification

Reverting each fix: the property split fails 4 tests, the per-replica provenance fails 1. Three
tests from the previous round were stale against the new contract — updated rather than deleted,
and they failed loudly first, which is the behaviour I want from them.

10 new tests (38 in the suite); 214 passed, 3 skipped; --check green at 132 sites. Branch
is current with rift_O4d (5004ccbf), no rebase needed.

One thing I'd flag for your judgement

sampler._rvs_is_fairdraw = any(_rep_fairdraw) after pooling is deliberately conservative: if
any block was resampled, .dslice forces the all-fresh path for the whole record. With a
mixture that is stricter than strictly necessary — the raw blocks could in principle still be
reweighted — but the reweight core has no way to weight rows differently by provenance, and
paying extra likelihood evaluations beats a silently double-counted slice. Say the word if
you'd rather it be all().

…turn

_reject_if_collapsed RAISES after pooling, the caller's `except Exception` swallows it and
moves to the next event, and _rvs_is_pooled survived.  The next ordinary fair draw was then
read as pooled, _rvs_is_equal_weight went False, and .dgrid and the extrinsic-proposal
breadcrumb applied importance weights to rows that already carried them -- the w^2 defect this
branch exists to remove, resurrected on the event after any failure.

Reset on ENTRY to analyze_event.  Entry is reached on every call by construction, needs no
restructuring of a 2000-line function, and stays correct for a caller that never returns
normally at all; a `finally` would buy nothing extra here and would mean wrapping the whole
body.  The end-of-function clear stays as well, so a sampler handed to anything else afterwards
is not carrying a stale marker.

Tests pin three things: the reset exists and sits strictly before anything that can raise or
export; the raising gate really does run after pooling (so the test cannot quietly stop testing
anything if that order changes); and the numeric consequence of a stale marker -- the helper
stops returning uniform weights for a plain fair draw and hands back w.  Verified by removing
the reset: the ordering test fails.

FOURTH round, fourth defect in the provenance bookkeeping rather than in the physics.  Recorded
as Finding 7 with the four recurring shapes, because the pattern is now the argument for the
naming change, not an incidental observation.

217 passed, 3 skipped; --check green at 132 sites.
@oshaughnessy-junior

Copy link
Copy Markdown
Owner Author

Fixed in 34230734. Confirmed exactly as described, and traced the leak end to end:
_reject_if_collapsed raises (raise _exc(...)) → the caller's except Exception at the outer
event loop swallows it and continues → _rvs_is_pooled survives → the next ordinary fair draw
reads as pooled → _rvs_is_equal_weight goes False → .dgrid and the extrinsic-proposal
breadcrumb apply importance weights to rows that already carry them. The defect this branch
exists to remove, resurrected on the event after any failure.

Reset on entry to analyze_event rather than in a finally: entry is reached on every call
by construction, needs no restructuring of a 2000-line function, and stays correct for a caller
that never returns normally at all. The end-of-function clear stays too, so a sampler handed
elsewhere afterwards isn't carrying a stale marker.

Three tests: the reset exists and sits strictly before anything that can raise or export; the
raising gate really does run after pooling (so the test can't quietly stop testing anything if
that order changes); and the numeric consequence of a stale marker. Verified by removing the
reset — the ordering test fails. 217 passed, 3 skipped; --check green at 132 sites.


On the pattern. That's four rounds, four defects, all in the provenance bookkeeping and none
in the physics:

  1. correct in isolation, wrong in composition
  2. one flag answering two questions
  3. the CLI option used where "what the pass actually did" was needed
  4. cleared only on the happy path

Every one was a second source of truth about _rvs that some site touching _rvs failed to
maintain. I've recorded that as Finding 7 in RVS_FAIRDRAW_AUDIT.md — as the argument for the
naming change rather than as an incidental observation.

Opened #94 as a draft for that: RvsRecord, carrying rows and provenance together, with the
three questions as three named methods instead of booleans a caller has to combine correctly.
It's a no-op — wired into one sampler, read by nothing — and it carries the blast-radius numbers
(306 reads / 131 post-rebind / 7 rebind sites), the A-vs-B trade-off, and three open questions I
would want answered before going further. Its test suite is one section per failure shape above.

I'd rather that be argued separately than ride along here.

@oshaughnessy-junior
oshaughnessy-junior merged commit 364a22f into rift_O4d Aug 14, 2026
21 of 22 checks passed
@oshaughnessy-junior
oshaughnessy-junior deployed to private-review-dispatch-rift August 14, 2026 13:35 — with GitHub Actions Active
oshaughnessy-junior added a commit that referenced this pull request Aug 14, 2026
NOT FOR MERGE.  One worked example of the proposal in DESIGN_rvs_naming.md, wired into a single
sampler, so the shape can be argued about against something concrete instead of against prose.
Nothing reads it: this branch is a no-op on every output.

THE PROBLEM.  sampler._rvs means the RETAINED SET while integrate_log accumulates, and an
EXPORT RESAMPLE afterwards.  The name does not change, the type does not change, so a consumer
written against the first meaning keeps working -- silently -- against the second.

THE EVIDENCE THAT THIS IS DESIGN AND NOT LUCK.  Nine defects of this shape.  Five before the
audit (CIP export; L0 seed #78; reject gate #79; reserve cap and its logarithm #84), three
found by the mechanical sweep in #87, and then FOUR MORE IN REVIEW OF THAT FIX -- every one of
the four in the boolean bookkeeping introduced to describe _rvs from outside, none in the
physics:

  1. a fix correct in isolation, wrong once pooling ran after it
  2. one flag answering two questions (rows-resampled vs globally-equal-weight)
  3. the CLI option used where "what this pass actually did" was needed
  4. a marker cleared only on the normal return, surviving a raised event

Each was a second source of truth that some site touching the first failed to maintain.  That
is what a naming problem looks like once you refuse to rename anything.

WHAT THIS DRAFT CONTAINS.  RvsRecord carries rows AND provenance together, and replaces the
booleans with named questions -- rows_are_resampled() (per BLOCK, survives pooling),
is_equal_weight() (whole RECORD, pooling destroys it), blocks_were_flattened() (a fact about
the pooling STEP).  Those are the three the flags kept conflating; they are three methods with
three names because the failure was never that the answer was hard to compute, it was that one
name suggested one question while a caller asked another.  Provenance is per-BLOCK, so a
mixture of raw and resampled replicas is representable at all.

The suite is written as one section per review-round failure shape: each test would have caught
its round had provenance lived with the rows from the start.  If the design is adopted they
justify it; if not, they are the specification any replacement has to satisfy.

Deliberately NOT a dict subclass: that would let every existing sampler._rvs[...] keep working
against an object whose meaning it does not check, which is the original problem with more
steps.  Consumers reach for .columns, which is visible in a diff and greppable by the audit.

BLAST RADIUS, measured by audit_rvs_fairdraw.py: 306 reads, 131 post-rebind, 7 rebind sites.
That is why this is option A (two names, incremental) rather than option B (the fair draw
returns a new object), which is the correct end state but cannot be done in one change to code
that writes science products.  Both are written up, with the trade-offs and three open
questions, in DESIGN_rvs_naming.md.

Verified no-op: 132 passed, 3 skipped across the AV/L0/portfolio suites, and a collapsed AV
pass records n_retained=288 against 1 exported row -- the case the whole line of work is about.
oshaughnessy-junior added a commit that referenced this pull request Aug 14, 2026
The --check gate merged in #87 caught this draft's new sampler-side _rvs read on the very next
change to touch one -- which is the behaviour it was built for, on a case nobody wrote it for.

Verdict PER_ROW: the record takes the just-rebound columns as a VIEW plus the pre-draw row
count, and reads no statistic of them.  It records that they ARE the export resample, at the
moment that becomes true.
oshaughnessy-junior added a commit that referenced this pull request Aug 14, 2026
Agreed direction from review.  Deliberately one worked example rather than a sweep, so the
shape can be judged before the mechanical part.

RESERVE BY REFERENCE, not a copy.  retained_points()/retained_lnL()/n_retained() point at the
bounded, finite-stratified _warm_seed_reserve.  From the measurement: holding raw retained rows
costs ~0.9 MB per million nmax for AV (nothing) but ~92 MB per million for a PORTFOLIO, i.e.
~384 MB at nmax=4e6 per ILE process -- and it would be mostly ballast, since the portfolio's
finite fraction on the collapsed pass this work is about is ~1e-5.  The reserve already keeps
the affordable thing, with the exact pre-cap weight total so a capped reserve still yields an
unbiased lnZ.  A pooled record carries NO reserve: it is a mixture of several passes, so there
is no single retained set, and pointing at one arbitrary pass's would be worse than None.

AV RECORDS BOTH PATHS.  fair_draw when the draw fires, retained when it does not.  "Absent" and
"not resampled" are different statements, and a consumer forced to distinguish them is back to
combining conditions by hand -- which is the failure this design exists to remove.

POOLING BUILDS A POOLED RECORD carrying _rep_fairdraw PER BLOCK.  That is precisely what the
two booleans cannot express, and why a raw/resampled mixture needed a special case in
_pool_replica_rvs; the record represents it directly.

FIRST CONSUMER MIGRATED: ln_weights_for_posterior, chosen because it is the exact site of the
one-flag-two-questions defect, so converting it demonstrates the point instead of merely
exercising the API.  It trusts a record only when `.columns is rvs` -- _rvs is a mutable dict
that may have been replaced since the record was built -- and otherwise falls back to the flags.

KEEPING TWO DESCRIPTIONS HONEST is the real cost of A, and four review rounds on #87 were all
"two descriptions drifted apart", so it is asserted rather than promised:
  * the record and the flags agree across retained / fair draw / pooled / pooled-mixed /
    pooled-raw;
  * on a real collapsed AV pass the record path and the flag path return BIT-IDENTICAL weights,
    on both branches -- the conversion is a refactor, and stays checkable until the flags go.

26 record tests; 213 passed, 3 skipped across the integrator suites; --check green at 134 sites
(it caught the new sampler-side read again, now classified).
oshaughnessy-junior added a commit that referenced this pull request Aug 15, 2026
…mplers set it

STEP 2 -- the remaining consumers.  .dgrid and the extrinsic-proposal breadcrumb already went
through ln_weights_for_posterior, so they moved with it; the .dslice guard and the pooled n_eff
now ask the record directly.  Note each asks a DIFFERENT question, which is the point:
  .dslice          rows_are_resampled()      -- survives pooling; reweighting resampled rows
                                               double-counts whether or not they were pooled
  pooled n_eff     blocks_were_flattened()   -- a fact about the pooling STEP, and keying it on
                                               either other question is what made that branch
                                               dead code in review round 2
  weights          is_equal_weight()         -- whole-record

All three go through ONE lookup, _rvs_record_for(sampler, rvs), which declines a record whose
.columns is not the dict being held: _rvs is replaced in place, so "the sampler has a record"
and "the record describes these rows" are different questions.  The PRODUCER at the pooling site
asks a third -- _sampler_keeps_records -- and has its own name rather than an exemption, because
it is about to replace sampler._rvs and would otherwise be told "no record" and silently skip
building the pooled one.

STEP 3 -- all seven rebind sites, wired by one patcher against PR #87's own markers so they are
identical rather than seven hand edits.  Each site now resets the record, builds a `retained`
record before the draw, and replaces it with a `fair_draw` record after.  The reserve rides
along by reference where the sampler keeps one (AV, portfolio); None elsewhere is the honest
answer rather than a gap.

TWO THINGS FOUND DOING IT, both recorded in the design doc:

* n_retained HAD TO BE CAPTURED EAGERLY.  RvsRecord.retained(self._rvs) holds a reference to the
  live dict, which the draw then rebinds, so len(record) afterwards returns the POST-draw count.
  Reading it made a collapsed pass report n_retained == rows -- "nothing was discarded", the
  exact opposite of the truth.  This project's own bug class, in the code written to prevent it.
  Caught because the end-to-end check printed n_ret == rows and that looked wrong.

* mcsampler and mcsamplerEnsemble take a LINEAR integrand; AV and the portfolio take log.  The
  wrong kind makes the fair draw compute negative weights and raise.  Confirmed to fail
  IDENTICALLY on the pristine file before concluding anything, so it is a harness contract, not
  a defect I introduced.

Tests: 31 in the record suite, including every wired sampler agreeing with its flags on both
draw settings, and a structural check that all seven sites are wired the same way (one patcher
means one mistake would be replicated everywhere -- the case worth testing rather than eyeballing
a diff).  248 passed, 3 skipped overall; --check green at 141 sites.

Still DRAFT.  The flags stay until every consumer is migrated; deleting them is step 4.
oshaughnessy-junior added a commit that referenced this pull request Aug 16, 2026
…r that owns it

Enumerated mechanically before touching anything, since this touches a lot: 7 sampler
self-reads (the producer reading its own attribute -- those stay), 11 in the ILE of which 2
were WRITES, 14 in the tests.

The two ILE writes are the interesting ones.  Replica pooling legitimately PRODUCES a record
the sampler cannot build (it is a mixture of several passes), and with no public writer that
code had to assign sampler._rvs_record directly -- reaching into another object's private
attribute, which is the habit this design exists to end.  So the mixin grew set_samples(): a
writer needs an API as much as a reader does.

Also folded the record into _snapshot_pass_state/_restore_pass_state, so everything describing
a pass still moves together.  A stale record was already declined by _rvs_record_for's identity
check, so this is belt-and-braces -- but "everything moves together" is the invariant, and
carving an exception into it is how review round 1 happened.

THE BOUNDARY IS NOW A TEST, and getting that test right took three attempts:

  1. substring search -- counts the COMMENTS explaining the hazard, which in these files are
     most of the occurrences.  Same false alarm as PR #87.
  2. strip comments, count tokens -- MISSES getattr(sampler, '_rvs_record'), where the name is
     a string literal, and that is exactly the form a consumer reaching inside would use.  This
     version PASSED against a deliberately reintroduced violation: worse than no test.  Found
     only by revert-checking it, which is the habit that keeps paying.
  3. AST -- attribute access where the object is not `self`, plus getattr/setattr/hasattr with
     the name as a string constant.  Verified to fail on BOTH violation forms and pass on
     restore.

The LISA driver gets its own case: as a deliberate fork it may legitimately have none of this,
but "none" and "half" are different, and half is how a fork rots.

271 passed, 4 skipped; both gates green; the ensemble script-style test still recovers AC/GMM/AV
to ~1.0.  Still DRAFT; flags still in place.
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.

1 participant