DRAFT: give the retained set and the export resample separate names - #94
DRAFT: give the retained set and the export resample separate names#94oshaughnessy-junior wants to merge 10 commits into
Conversation
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.
…t the LISA question
1. NAMING -> _rvs_record, underscored per review. Local to the sampler, even though the goal
is to standardise the concept across integrators.
2. RETAINED ROWS -> measured, and the answer differs by sampler, which the question did not
anticipate. measure_retained_set_memory.py, run with no fair draw so _rvs IS the retained set:
AV ~0.9 MB per million nmax -> ~4 MB at nmax=4e6
portfolio ~91.6 MB per million nmax -> ~384 MB at nmax=4e6
They differ because AV keeps only the in-volume subset, which grows far more slowly than
ntotal, while the portfolio's _rvs holds EVERY draw, so its cost tracks nmax directly. 384 MB
per ILE process is a real operational cost when many ILE jobs share a node.
RECOMMEND NOT holding the raw retained set unbounded. The portfolio's is mostly ballast: on
the collapsed pass this work is about, the finite fraction is ~1e-5, so nearly all of that
384 MB is -inf rows no consumer can use. make_warm_seed_reserve already keeps a bounded,
finite-stratified copy with the exact pre-cap weight total -- so have the record REFERENCE the
reserve rather than take its own copy, and treat full retention as an AV-only opt-in where it
costs ~4 MB. That gets the value #79's lnZ fallback needs at a cost already being paid.
3. LISA -> the question was badly posed and implied something untrue. There is NO separate
integrator: both drivers import the identical set (mcsampler, Ensemble, GPU, AdaptiveVolume,
Portfolio), so _rvs_record reaches LISA for free and there is no LISA-side decision here.
The divergence is the DRIVER: integrate_likelihood_extrinsic_batchmode_lisa is 2,526 lines
against the main driver's 4,563, a fork of an older ILE with ZERO occurrences of
ln_weights_from_rvs, _pool_replica_rvs, _lnZ_of_rvs, _kish_neff_of_rvs, the L0 rescue, the
sequential warm start, replicas, .dgrid or the proposal breadcrumb. So LISA has no consumer to
migrate -- its 36 post-rebind reads are the MAP-seed/export pattern, already BENIGN/PER_ROW in
the ledger. The real issue is two forks of one ILE, one of which silently misses every fix.
That is a separate and larger problem, noted so it is not mistaken for this one.
125 passed, 2 skipped. Still a no-op: nothing reads the record.
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.
33dc399 to
c4baefb
Compare
|
All three answered; rebased onto 1. Naming →
|
| sampler | nmax | ntotal | retained rows | cols | record MB |
|---|---|---|---|---|---|
| AV | 200k | 200,886 | 7,934 | 9 | 0.5 |
| AV | 800k | 322,587 | 25,374 | 9 | 1.7 |
| portfolio | 200k | 200,000 | 199,641 | 12 | 18.3 |
| portfolio | 800k | 800,000 | 799,637 | 12 | 73.2 |
AV ~0.9 MB per million nmax (4 MB at 4e6); portfolio ~92 MB per million (384 MB
at 4e6). They diverge because AV keeps only the in-volume subset, which grows far more slowly
than ntotal, while the portfolio's _rvs holds every draw.
Recommendation: don't hold it unbounded. 384 MB per ILE process is real when many ILE jobs
share a node — and the portfolio's retained set is mostly ballast: on the collapsed pass this
work is about, the finite fraction is ~1e-5, so nearly all of it is -inf rows no consumer can
use. make_warm_seed_reserve already keeps a bounded, finite-stratified copy with the exact
pre-cap weight total. So: have the record reference the reserve rather than take its own
copy, and treat full retention as an AV-only opt-in where it costs ~4 MB. That gets what #79's
lnZ fallback needs at a cost already being paid.
3. LISA — you're right, and my question was badly posed
There is no separate integrator, and there shouldn't be. Both drivers import the identical
set (mcsampler, Ensemble, GPU, AdaptiveVolume, Portfolio), so _rvs_record reaches
LISA for free the moment the samplers set it. No LISA-side decision exists in this design. I
should have said "driver", not "integrator".
The divergence is the driver script: ..._batchmode_lisa is 2,526 lines against the main
driver's 4,563, a fork of an older ILE with zero occurrences of ln_weights_from_rvs,
_pool_replica_rvs, _lnZ_of_rvs, _kish_neff_of_rvs, the L0 rescue, the sequential warm
start, replicas, .dgrid, or the proposal breadcrumb.
So LISA has nothing to migrate — its 36 post-rebind reads are the MAP-seed/export pattern,
already BENIGN/PER_ROW in the ledger, and it never pools or reweights. The real issue is two
forks of one ILE where one silently misses every fix. Larger than this design; flagged so it
isn't mistaken for part of it.
One thing worth noting
The --check gate that merged with #87 caught this draft's own new _rvs read, on the very
next change to touch one — a case nobody wrote it for. Classified PER_ROW (the record takes
the rebound columns as a view plus the pre-draw count, and reads no statistic of them). 133
sites now carry verdicts; 186 passed, 3 skipped.
Still a no-op — nothing reads the record. Ready for a decision on whether option A is worth
pursuing, and if so I'd do the reserve-reference version of Q2 rather than full retention.
#95 Reviewer: "B sounds super dangerous ... flag as in plan for longer-term, but not anytime in the next month or two." Agreed, and recorded where the reasoning will be found rather than in a PR comment: option B is parked in issue #95 with the measured blast radius (306 reads, 131 post-rebind, 7 rebind sites) and a definition-of-done, and the doc now says so at the option itself as well as in the recommendation. A stays the direction, and with the memory question settled the concrete next step is to have the record REFERENCE the existing bounded reserve rather than take its own copy -- ~4 MB for AV but ~384 MB for a portfolio at nmax=4e6, most of it -inf ballast no consumer can use. Also notes that A is what makes B cheap later: once consumers ask a record instead of indexing a dict, B becomes a change of what the default view returns rather than a 306-site rename.
|
All three settled and recorded in the branch ( 2 — retained rows. Agreed: the record will reference the existing bounded reserve 3 — LISA. Agreed, and logged as recurring maintenance rather than a one-off. The measured A vs B. Agreed — B is parked as long-term in #95, with the blast-radius numbers Worth stating explicitly, since it changes how A should be judged: A is what makes B cheap Still a no-op — nothing reads the record. 13 record tests pass, gate green at 133 sites. |
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).
|
Option A started — What's in itReserve by reference. AV records both paths — Pooling builds a pooled record carrying First consumer migrated: How the migration is kept honestTwo descriptions of one thing is A's real cost, and all four #87 review rounds were "two
Next, in order
26 record tests; 213 passed, 3 skipped; Nothing here touches the LISA driver; that's the separate catch-up session, and its 36 Still marked draft — say the word and I'll take it out of draft, or keep going to step 2 first. |
…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.
|
Steps 2 and 3 done — Step 2: every consumer asks the record
All three go through one lookup, Step 3: all seven rebind sitesWired by one patcher against #87's own markers, so they're identical rather than seven hand Two things found doing it
State31 record tests (every wired sampler agreeing with its flags on both draw settings); 248 Remaining: step 4 deletes |
… fix it
Raised in review: the backends are structurally different per backend, which is a landmine for
developers. Agreed, and it is a SEPARATE problem from the naming one -- RvsRecord does not
address it -- so the first step is to stop it being invisible.
audit_backend_contracts.py records what each backend actually does, and --check (now in CI)
fails when one CHANGES without the recorded table changing with it. It deliberately does not
forbid the differences: several are load-bearing, and none should be "tidied" without a
decision. It makes a change show up as a diff instead of as a wrong number months later.
WHAT IT FOUND, and it is worse than the "log vs linear" I first assumed. _rvs['integrand']
holds THREE different things:
linear L mcsampler, mcsamplerGPU
lnL (aliased) mcsamplerAdaptiveVolume, mcsamplerNFlow, mcsamplerPortfolio
L *or* lnL mcsamplerEnsemble, depending on the return_lnI kwarg
The last is the dangerous one: for that backend the column's meaning is a RUNTIME property of
how the pass was called, so reading the consumer cannot tell you which it is. That is exactly
why ln_weights_from_rvs demands use_lnL explicitly and why it must be the STORED convention
rather than opts.internal_use_lnL -- a constraint that was already documented at that function
but nowhere discoverable from the backends themselves.
The failure is asymmetric, which is what makes it a landmine rather than a nuisance: a log
callable into a linear entry point makes the fair draw compute NEGATIVE weights and raise; the
same mistake downstream does NOT raise, it takes log() of a log and returns a plausible,
almost-flat weight vector. It cost time twice in one afternoon wiring the record, which is the
only reason it is written down rather than rediscovered.
Two more differences recorded because consumers must cope with them: only AV and the portfolio
keep a _warm_seed_reserve (so retained_points() answers None for the other four), and the
portfolio's _rvs holds EVERY draw against AV's retained subset -- ~92 vs ~0.9 MB per million
nmax, so n_retained means different things per backend.
Verified the gate by removing NFlow's integrand aliasing: it reports the exact field that moved
and the exact before/after, then passes again on restore.
251 passed, 3 skipped; both gates green (141 _rvs sites, 6 backend contracts). Still DRAFT.
|
Flags kept, as agreed. And I took the backend point seriously — It's a separate problem from the naming one, and What it found is worse than I'd assumedI'd said "log vs linear integrand". It's three things, not two:
The last is the dangerous one: for that backend the column's meaning is a runtime property of The failure is asymmetric, which is what makes it a landmine rather than a nuisance: a log Two more differences recorded, because consumers must cope with them: only AV and the portfolio Deliberately not "fixed"The gate does not forbid the differences — several are load-bearing, and none should be Verified by removing NFlow's integrand aliasing: it names the exact field that moved and the State251 passed, 3 skipped; both gates green (141 If you want the divergence actually reduced rather than just visible, that's a third piece of |
…nI can go stale
Review reframed this better than the draft had it: _rvs is an INTERNAL variable, consumers
should call a first-class API with clear meaning, and a universal output format fully
disambiguates the backends rather than merely documenting their differences.
rec = sampler.samples() # public; RvsRecord or None
rec.log_likelihood() # ln L -- the SAME thing on all six backends
rec.log_prior() / rec.log_sampling_prior()
rec.log_weights() # lnL + ln pi - ln q, and NO use_lnL argument
All log space: it is the only convention all six can express without loss, since the linear
column underflows to 0 at ~745 nats -- exactly the regime this work is about.
HOW return_lnI BECOMES HISTORICAL. log_likelihood() prefers the unambiguous log_integrand
column, which already covers AV, NFlow, portfolio, GPU, and Ensemble when it ran under use_lnL.
Only two cases have a bare `integrand` whose meaning is not on the record, and both are recorded
where the answer is actually known:
mcsampler integrand_is_log=False (writes no log columns at all)
mcsamplerEnsemble integrand_is_log=bool(use_lnL) (log columns only under use_lnL)
That is the trick. The convention was always a RUNTIME property recoverable only by the
sampler; it now says so once, instead of every caller threading use_lnL through and one of them
eventually passing opts.internal_use_lnL by mistake -- a bug already documented at
ln_weights_from_rvs. Once consumers are on this API, return_lnI is an implementation detail of
one backend rather than something the ILE must know.
A record with a raw `integrand` and NO recorded convention RAISES rather than guessing.
Verified: that is what mcsamplerEnsemble did before its convention was wired, which is how I
know the path works rather than merely exists.
Delivered as SamplerOutputMixin because the six MCSampler classes share no base -- five are
`class MCSampler(object)`, only mcsamplerNFlow inherits MCSamplerGeneric. Giving them a real
common base is a bigger change than this draft should make.
Tests: 45 in the record suite (1 skipped -- mcsamplerNFlow needs the optional `nflows` package,
so that case checks the class declaration in SOURCE and skips only the import, rather than
silently covering nothing). 262 passed, 4 skipped overall; both gates green.
Still DRAFT; flags still in place.
|
That framing is better than the draft had it, and it subsumes the backend divergence instead of rec = sampler.samples() # public; RvsRecord or None
rec.log_likelihood() # ln L -- the SAME thing on all six backends
rec.log_prior() / rec.log_sampling_prior()
rec.log_weights() # lnL + ln pi - ln q, and NO use_lnL argumentAll log space: the only convention all six can express without loss, since the linear column How
|
| backend | recorded |
|---|---|
mcsampler |
integrand_is_log=False (writes no log columns at all) |
mcsamplerEnsemble |
integrand_is_log=bool(use_lnL) (log columns only under use_lnL) |
That's the whole trick. The convention was always a runtime property recoverable only by the
sampler — so the sampler states it once, instead of every caller threading use_lnL through
and one of them eventually passing opts.internal_use_lnL by mistake, which is a bug already
documented at ln_weights_from_rvs. Once consumers are on this API, return_lnI is an
implementation detail of one backend rather than something the ILE has to know about.
A record with a raw integrand and no recorded convention raises rather than guessing. I
know that path works rather than merely exists because Ensemble hit it during wiring, before its
convention was recorded.
Shape
SamplerOutputMixin, because the six MCSampler classes share no base — five are
class MCSampler(object), and only mcsamplerNFlow inherits MCSamplerGeneric. Giving them a
real common base is a bigger change than this draft should make; the mixin gets the API onto all
six without one. That inconsistency is itself worth knowing about and is now in the recorded
backend contracts.
State
45 record tests, 1 skipped — mcsamplerNFlow needs the optional nflows package, so that case
asserts the class declaration in source and skips only the import. A plain skip would have
quietly stopped covering a backend the day a dependency dropped out. 262 passed, 4 skipped
overall; both gates green.
Still draft, flags still in place.
Two honest limits: nothing in the ILE calls samples() yet — the consumers still go through
_rvs_record directly, and moving them is the next increment. And the API can only disambiguate
what a sampler chooses to record, so it makes the divergence harmless to consumers rather than
smaller — reducing it is still the separate piece of work.
…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.
|
Done — The two ILE writes were the interesting partReplica pooling legitimately produces a record the sampler cannot build (it's a mixture of I also folded the record into Getting the boundary test right took three attemptsWorth reporting, because attempt 2 was actively dangerous:
The LISA driver has its own case: as a deliberate fork it may legitimately have none of this, State271 passed, 4 skipped; both gates green (141 The interface is now what you described: What I have not done: consumers still call |
…the change Agreed this is the tricky one -- it touches the number every science product is built from -- so the plan goes in first. Nothing is started. THE KEY MEASURED FACT, checked rather than assumed: at a fixed --run-seed the shape gate is deterministic to the bit. Two runs of the quick preset on identical code differ in exactly one field, `wallclock` (8.455 vs 3.753); js, n_eff, lnI, mean_pull, width_ratio, corr_diff_max, bias_ln, rel_err, n_ess and n_eval are all identical. Worth having checked: the first comparison printed "DIFFER" and looked like it had killed the approach outright, until the diff turned out to be the timer. So the acceptance criterion for a REFACTOR is BIT-IDENTITY, not "within tolerance". That is far more sensitive than compare_shape_results.py's own thresholds (js 0.005, pull 0.05, width 0.05), and it sidesteps the stochastic-flip problem run_shape_recovery.sh warns about at length -- its --confirm-repeats machinery exists for cells sitting on the n_eff>=100 floor, and a refactor should never produce a differing cell at all. Any non-wallclock difference is a signal. THE FALSIFICATION TIER: shape_recovery.py carries its OWN log_weights_from_rvs(), a third implementation independent of both ln_weights_from_rvs and RvsRecord.log_weights(), written to be tolerant of the heterogeneous _rvs conventions. Asserting the three agree per backend -- including mcsamplerEnsemble in BOTH use_lnL modes -- is the check that can actually falsify the migration rather than test it against itself. THE TRAP IN THE SEQUENCING: shape_recovery.py is ITSELF an _rvs consumer, so it is both the ruler and a migration target. Migrate the ILE weight path first, validate with the gate UNCHANGED, and migrate the gate afterwards as its own step. Changing the ruler and the subject in one commit destroys the independence that makes the falsification tier worth anything. Also records what would make me stop, so that is decided now rather than under pressure later: a confirmed differing cell, any disagreement among the three weight implementations, or an inability to run the full ILE tier -- in which case the migration is provisional and says so, rather than shipping on the cheap tiers and calling it validated.
|
Agreed, so I wrote the validation plan before starting the change rather than after — One measured fact changes the whole approachAt a fixed
(Worth having checked rather than assumed — the first comparison printed So for a refactor the acceptance criterion is bit-identity, not "within tolerance". That's The tier that can actually falsify it
The trap in the sequencing
Practical constraints, stated nowTier 3 ( Also recorded: what would make me stop — a confirmed differing cell, any disagreement among the Say the word and I'll start on tier 0 + the ILE weight path; or if you'd rather run the full ILE |
Draft, not for merge. Opened to argue about a design against something concrete. Nothing
reads the new code — this branch is a no-op on every output.
Follows from #87, where four successive review rounds each found a defect in the boolean
bookkeeping around
_rvs, and none in the physics.The problem
sampler._rvsmeans two things at two times in one function:The name doesn't change. The type doesn't change. A consumer written against the first meaning
keeps working, silently, against the second.
Why this is design, not luck
Nine defects of one 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 in the bookkeeping, none in the physics:
rows resampledvsglobally equal-weight)Each was a second source of truth that some site touching the first failed to maintain.
What's here
RvsRecordcarries rows and provenance together, replacing the booleans with namedquestions:
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.
Deliberately not a dict subclass: that would let every existing
sampler._rvs[...]keepworking against an object whose meaning it doesn't check — the original problem with more steps.
The test suite is one section per review-round failure shape; each test would have caught its
round had provenance lived with the rows from the start.
Blast radius, and why option A
Measured by
audit_rvs_fairdraw.py: 306 reads, 131 post-rebind, 7 rebind sites._rvskeeps its meaning, migrate one consumer at a time.unrepresentable — but needs all 131 post-rebind reads told which object they want, in one
change, to code that writes science products.
that catches a new consumer.
Recommendation: A now, B later, C regardless.
Three open questions I'd like answered before going further
rvs_recordvs_rvs_record— public reads better for something consumers should use,but every comparable sampler attribute is underscored.
BROKENentry(L0 rescue: the reject gate was comparing two different estimators (stacked on #78) #79's cross-source lnZ fallback) and let
.dslicereweight properly instead of falling backto all-fresh. It costs memory on a portfolio, whose
_rvsholds every draw._warm_seed_reserveshows a bounded, finite-stratified copy is affordable; whether the full set is, I have not
measured and would want to before committing.
reads and none of the helpers this work added.
Verification
132 passed, 3 skipped across the AV/L0/portfolio suites. A collapsed AV pass records
n_retained=288against 1 exported row — the case this whole line of work is about.Depends on nothing in #87; both are off
rift_O4d. If #87 lands first this rebases cleanly(disjoint files bar the one AV hunk).