parameter: an over-long value vector, a one-sided zero error, and a dead barrier arm (2.2.4, 2.2.5, 5.2.3) - #254
Merged
Conversation
…(2.2.4) to_vec copied the first n_elements of a longer array and dropped the rest with no message, so a mis-sized initval/lower/sigma/mu/init_scale array built a model out of whichever values happened to land first -- indistinguishable from one that was sized correctly. REPRODUCED at the behaviour level, not only in to_vec: a shape-(2,) Parameter handed a length-3 initval built and evaluated with element 2 simply gone and no warning, and _initval_present returned the same two-element "present" mask it returns for a correctly sized vector. Controls fired (a length-2 array and a length-1 broadcast both came back intact), so the silence was the code's and not the probe's. SHORTER than the element count stays legal, and is load-bearing: resolve leaves the elements nobody named as NaN, to_vec fills the tail, and that fill is exactly what _initval_present reads as "this element was never set". LONGER has no reading at all, so it raises -- which is what every other spelling of this sizing hazard does (1.1.1's _check_broadcast_covers_vector, 2.5.2's ambiguous inline mask, manifest.normalize_selector), rather than inventing a third convention. _initval_present refuses the same array for the same reason: the mask mirrors to_vec's broadcasting rules, so if only one of the two refused, the pair could disagree about which element is which. The live caller is a component writing a manifest OPTION -- lower/upper/ sigma straight onto the entry, which win outright and reach Parameter unsized -- sized from the component's config list instead of from the parameter's own manifest shape. The user's vectors cannot reach it (resolve returns one entry per element and a per-seed initval list collapses to seed 0 there) and neither can a component "overrides" array (applied element by element). to_vec gains a where= argument that only names the field in the error. Start logp is bit-identical: no shipped config takes this path. The mulens acceptance harness (strict tier, EXOZIPPY_ACCEPTANCE_STRICT=1) is byte-for-byte identical to a pristine-HEAD baseline run on all 13 fixtures / 925 terms, and make_mulens_fixtures.py --check reports exactly the two pre-existing 1-ulp fixture staleness deltas it reports on master. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
PosteriorSummary.format special-cased both errors being zero and let
err_minus == 0 with err_plus > 0 fall through to
decimals_from_sigfigs(0) -> 0 decimal places, printing "0.0". That
reaches a published LaTeX table as "1.234^{+0.05}_{-0.0}".
REPRODUCED from real draws, not only by constructing the dataclass: a
mode slice with more than half its draws on one edge (a mode pinned at a
bound) makes the 15.865% quantile and the median coincide, so
_summarize_array returns err_minus exactly 0.0 and latex_value renders
"0.0^{+0.12}_{-0.0}". Controls fired: the both-zero case renders
"\equiv <median>" and the ordinary two-sided case renders two real
numbers, so the inconsistency is between the two zero paths.
Fix: render a zero on EITHER side as the bare "0", which is what the
both-zero path has always rendered. A rounded zero is a claim about
precision and there is none to claim. latex_value is unchanged and
still reserves "\equiv" for the both-zero (effectively fixed) case: a
one-sided zero is not a fixed element.
Table text only -- no start logp anywhere depends on it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
build_pymc's preliminary barrier steepness was `np.where(use_logit, scales, gaussian_scales)`, whose first arm cannot be selected on any element the barrier reads -- and which made no difference anywhere even so. The comment beside it implied a case that cannot occur. PROVEN both ways before deleting anything. By construction: `use_logit` is set only inside `if is_sampled[i]`, needs_barrier's sampled arm is `is_sampled & ~use_logit` and its derived arm is disjoint from is_sampled, so `needs_barrier & use_logit` is empty; and off the barrier's elements the two arms hold the same number anyway, because gaussian_scales starts as a copy of `scales` and is written only in the two loop branches a logit element never takes. By measurement: a probe recording use_logit, needs_barrier and gaussian_scales on all 31 shipped configs -- 1771 parameters, 980 logit elements, 422 barrier elements -- found zero overlap and zero difference between the old expression and gaussian_scales on the whole vector. The probe fails loudly if either set is empty, so "no overlap" is a real claim and not an instrument that never fired. Now `gaussian_scales.copy()`, with the proof recorded beside it so the selection is not reintroduced as "defensive", and a test pinning the disjointness so a future role change that really does put a barrier on a logit element fails where the scale it would read is decided. Start logp is bit-identical, as the measurement says it must be: the mulens acceptance harness (strict tier) is byte-for-byte identical to a pristine-HEAD baseline on all 13 fixtures / 925 terms. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Batch 3B of the review sweep: three behaviour-level defects in
src/exozippy/components/parameter.py. One commit per item. Start logpis bit-identical -- see the acceptance evidence at the bottom.
2.2.4 --
to_vecsilently truncated an over-long value vectorThe defect.
to_veccopied the firstn_elementsof a longer array anddropped the rest with no message, so a mis-sized
initval/lower/upper/sigma/mu/init_scalearray built a model out of whichever values happenedto land first -- indistinguishable from one that was sized correctly.
How it was reproduced. At the behaviour level, not only inside
to_vec: ashape-
(2,)Parameterhanded a length-3initvalbuilt and evaluated withelement 2 simply gone, and
_initval_presentreturned the same two-element"present" mask it returns for a correctly sized vector.
The controls fired (a length-2 array and a length-1 broadcast both came back
intact), so the silence was the code's and not the probe's -- review 3.14.19.
The fix. Raise, matching the house answer for every other spelling of this
sizing hazard (1.1.1's
_check_broadcast_covers_vector, 2.5.2's ambiguousinline mask,
manifest.normalize_selector) rather than inventing a thirdconvention. SHORTER stays legal and is load-bearing --
resolveleaves theelements nobody named as NaN,
to_vecfills the tail, and that fill is exactlywhat
_initval_presentreads as "this element was never set". LONGER has noreading at all.
_initval_presentrefuses the same array so the mask and thevector cannot disagree about which element is which.
to_vecgains awhere=argument whose only job is naming the field in the error.
Traced the live caller while verifying: the user's vectors cannot reach it
(
resolvereturns one entry per element and a per-seedinitvallist collapsesto seed 0 there) and neither can a component
"overrides"array (appliedelement by element,
src >= len(val)skipped). What reachesParameterunsizedis a manifest option --
lower/upper/sigmastraight onto the entry,which win outright -- sized from the component's config list instead of from the
parameter's own manifest
shape.2.2.5 -- a one-sided zero error rendered as
_{-0.0}The defect.
PosteriorSummary.formatspecial-cased both errors being zeroand let
err_minus == 0witherr_plus > 0fall through todecimals_from_sigfigs(0)-> 0 decimal places, printing"0.0". That reaches apublished LaTeX table as
1.234^{+0.05}_{-0.0}.How it was reproduced. From real draws, not only by constructing the
dataclass -- a mode slice with more than half its draws on one edge (a mode
pinned at a bound) makes the 15.865% quantile and the median coincide:
The fix. Render a zero on EITHER side as the bare
"0", which is what theboth-zero path has always rendered. A rounded zero is a claim about precision
and there is none to claim.
latex_valueis unchanged and still reserves\equivfor the both-zero (effectively fixed) case: a one-sided zero is not afixed element. Table text only.
5.2.3 -- unreachable
np.wherearm in the barrier preliminary scalesThe premise held, and is now proven twice.
build_pymc's preliminarybarrier steepness was
np.where(use_logit, scales, gaussian_scales), whosefirst arm cannot be selected on any element the barrier reads -- and which made
no difference anywhere even so.
By construction:
use_logitis set only insideif is_sampled[i],needs_barrier's sampled arm isis_sampled & ~use_logitand its derived armis disjoint from
is_sampled, soneeds_barrier & use_logitis empty; and offthe barrier's own elements the two arms hold the same number anyway, because
gaussian_scalesstarts as a copy ofscalesand is written only in the twoloop branches a logit element never takes.
By measurement, over every shipped config (the probe records
use_logit,needs_barrierandgaussian_scalesafter eachbuild_pymcand fails loudlyif either set is empty, so "no overlap" is a real claim):
The fix.
gaussian_scales.copy(), with the proof recorded beside it so theselection is not reintroduced as "defensive", plus a test pinning the
disjointness -- so a future role change that really does put a barrier on a
logit element fails where the scale it would read is decided. The other two
np.wherecalls in that block are untouched.Acceptance
Start logp is bit-identical. The strict tier of the mulens acceptance
harness, and a baseline run of the same harness against a pristine export of
master(git archive HEAD), produce byte-for-byte the same output:Both of those are pre-existing 1-ulp fixture staleness on this box (the first is
documented at
tests/test_mulens_acceptance.py:117-125), andscripts/make_mulens_fixtures.py --checkreports exactly the same two linesand the same four
MISSING fixturelines on the pristine-masterbaseline ason this branch -- same terms, same values, same order.
Full suite green:
3387 passed, 1 skippedwith the worktree's own sourcesfirst on the path. Three tests failed on the first run and all three were the
same pytensor link error in the shared scratch compiledir
(
/bin/ld: cannot open output file ... No such file or directory) caused by aconcurrent sibling suite on the same box; they pass on their own.
New tests (
tests/test_parameter_logic.py, 9 functions / 17 cases): every legal vectorlength still accepted, the short-vector fill preserved,
to_vecand_initval_presentboth refusing an over-long array, one parametrized case perguarded field at build time, the one-sided/two-sided/both-zero renderings, the
render reached from real draws, and the logit/barrier disjointness. All 10 of the
2.2.4 and 2.2.5 cases fail on pristine
masterand pass here; the 5.2.3 casepasses on both by design -- it pins the invariant the deletion rests on.
Out of scope, filing separately:
outputs/latex.py:245unpacksmed, ep, em = summ.format(...)whileformatreturns(median, err_minus, err_plus), so<prefix>_results.csv'sup_errcolumncarries
err_minusandlow_errcarrieserr_plus-- the two error columns areswapped for every asymmetric posterior. Not touched here.
🤖 Generated with Claude Code