Skip to content

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
jdeast merged 3 commits into
masterfrom
worktree-agent-a22b92a61a97e9097
Sep 11, 2026
Merged

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
jdeast merged 3 commits into
masterfrom
worktree-agent-a22b92a61a97e9097

Conversation

@jdeast

@jdeast jdeast commented Sep 11, 2026

Copy link
Copy Markdown
Owner

Batch 3B of the review sweep: three behaviour-level defects in
src/exozippy/components/parameter.py. One commit per item. Start logp
is bit-identical
-- see the acceptance evidence at the bottom.

2.2.4 -- to_vec silently truncated an over-long value vector

The defect. to_vec copied the first n_elements of a longer array and
dropped the rest with no message, so a mis-sized initval/lower/upper/
sigma/mu/init_scale array built a model out of whichever values happened
to land first -- indistinguishable from one that was sized correctly.

How it was reproduced. At the behaviour level, not only inside to_vec: a
shape-(2,) Parameter handed a length-3 initval built and evaluated with
element 2 simply gone, and _initval_present returned the same two-element
"present" mask it returns for a correctly sized vector.

--- raw to_vec ---
control len-2 on n=2: [1. 2.]
control len-1 on n=2: [7. 7.]
OVER-LONG len-3 on n=2: [1. 2.] warnings: []

--- through build_pymc (behaviour level) ---
control  initval len-2 -> [0.16653098 0.76393834]
DEFECT   initval len-3 -> [0.1638114  0.59364121] warnings: []
DEFECT   sigma len-3   -> [0.25 0.75] free RVs: [] warnings: []

--- _initval_present alignment ---
control len-2 present: [ True  True]
over-long present:     [ True  True]

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 ambiguous
inline mask, manifest.normalize_selector) rather than inventing a third
convention. SHORTER 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. _initval_present refuses the same array so the mask and the
vector cannot disagree about which element is which. to_vec gains a where=
argument whose only job is naming the field in the error.

Traced the live caller while verifying: 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, src >= len(val) skipped). What reaches Parameter unsized
is a manifest option -- lower/upper/sigma straight 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.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}.

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:

control both-zero    format=('0.316228', '0', '0')  latex='\equiv 0.316228'
control two-sided    format=('1.234', '0.05', '0.07')  latex='1.234^{+0.07}_{-0.05}'
DEFECT em==0         format=('1.234', '0.0', '0.05')  latex='1.234^{+0.05}_{-0.0}'
DEFECT ep==0         format=('1.234', '0.05', '0.0')  latex='1.234^{+0.0}_{-0.05}'

--- reached from real draws (a mode slice piled on one edge) ---
median/em/ep: 0.0 0.0 0.12055308336966133
latex_value: '0.0^{+0.12}_{-0.0}'

The 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.

5.2.3 -- unreachable np.where arm in the barrier preliminary scales

The premise held, and is now proven twice. 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.

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 own 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, over every shipped config (the probe records use_logit,
needs_barrier and gaussian_scales after each build_pymc and fails loudly
if either set is empty, so "no overlap" is a real claim):

31 shipped configs built, 0 skipped
observations: {'params': 1771, 'with_barrier': 278, 'logit_elems': 980,
               'barrier_elems': 422, 'overlap': 0, 'differs': 0}
CONTROL OK: both sets populated.
RESULT: overlap=0  differs=0

The fix. gaussian_scales.copy(), with the proof recorded beside it so the
selection 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.where calls 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:

$ EXOZIPPY_ACCEPTANCE_STRICT=1 python -m pytest tests/test_mulens_acceptance.py
24 passed in 206.71s

sign tally over 925 terms:
  current > recorded : 1
  current < recorded : 1
  bit-identical      : 923

largest 2 deltas:
  OGLE_0383LD  RV:mulensinstrument.model    -1.455192e-11  rel -5.423e-16
  ob09020      POT:galacticmodel.imf_prior  +2.220446e-16  rel +1.342e-16

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), and
scripts/make_mulens_fixtures.py --check reports exactly the same two lines
and the same four MISSING fixture lines on the pristine-master baseline as
on this branch -- same terms, same values, same order.

Full suite green: 3387 passed, 1 skipped with the worktree's own sources
first 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 a
concurrent sibling suite on the same box; they pass on their own.

New tests (tests/test_parameter_logic.py, 9 functions / 17 cases): every legal vector
length still accepted, the short-vector fill preserved, to_vec and
_initval_present both refusing an over-long array, one parametrized case per
guarded 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 master and pass here; the 5.2.3 case
passes on both by design -- it pins the invariant the deletion rests on.

Out of scope, filing separately: outputs/latex.py:245 unpacks
med, ep, em = summ.format(...) while format returns
(median, err_minus, err_plus), so <prefix>_results.csv's up_err column
carries err_minus and low_err carries err_plus -- the two error columns are
swapped for every asymmetric posterior. Not touched here.

🤖 Generated with Claude Code

jdeast and others added 3 commits September 11, 2026 02:49
…(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>
@jdeast
jdeast merged commit cf2c02e into master Sep 11, 2026
21 checks passed
@jdeast
jdeast deleted the worktree-agent-a22b92a61a97e9097 branch September 11, 2026 07:28
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