parameter: refuse a bound or hard LINK on a derived element, per element (1.2.5, 7.2.2) - #256
Merged
Merged
Conversation
…ent (1.2.5) A `lower:`/`upper:` LINK is not a bound. It re-maps the element's own logit coordinate into the linked interval, so the value BECOMES the image of that coordinate -- correct and desirable on a SAMPLED element. On a DERIVED one there is no coordinate: `lq[i]` sits at its center, `q_i = 0.5`, and the `pt.set_subtensor` handed back the interval's MIDPOINT in place of the physics. A hard link (an `initval` link with `sigma: 0`) overwrote it outright. Neither warned. The whole-vector case has always been refused; the guards predate per-element roles and key on the whole-vector `expression` only, so an element derived by an `element_expressions` spec passed both loops -- the dynamic-bound loop's own guard is `if not use_logit[i] and is_sampled[i]`, and `is_sampled` is exactly what a derived element is not. Reproduced on this tree: a derived 7.77 evaluating to 6.5 (lower link 6.0, static upper 7.0), a hard link replacing 7.77 with its own 3.25, and -- end to end on a mixed V_c/V_e system, which is the live surface since fitvcve shipped -- `orbit.ecc[0]` evaluating to +inf, because ecc has no finite static upper, crashing much later in unrelated physics. The control in each probe was the same link on the SAMPLED element of the same vector, which visibly re-maps it (0.5 -> 0.625). `build_pymc` now refuses all three per element, as a pre-pass over both link loops so nothing is partially re-mapped and the element named is the first the user wrote. The message names the element by index and by name and points at the two spellings that DO work, since the two mechanisms share the params-file words and the user's instinct is a reasonable one. NOT narrowed, deliberately (JDE ruling 2026-09-11): a NUMERIC `lower:`/`upper:` on a derived element is a different mechanism -- section B's soft barrier, whose gradient acts on the parents the element is derived from -- and it stays exactly as it was. `mu` links stay legal too, per element as whole-vector. Both refusals share one message so they cannot drift. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
test_linked_params.py (24 tests) never built an ElementExpression and
test_element_parameterization.py (34) never set element_links, so the cell
where the two meet had zero coverage -- and it held 1.2.5. New file, since
the seam belongs to neither; both files now point at it.
What must raise: a `lower:` and an `upper:` LINK on a derived element of a
mixed vector, and a hard link (initval link + sigma: 0) on one, each naming
the element by index and by name; plus the whole-vector refusal, asserted
against the same advice text so the two messages cannot drift apart. Plus the
live surface end to end -- a params-file `lower:` link on `orbit.ecc` of a
mixed V_c/V_e system, through extract_links -> _wire_user_links -> build_pymc,
which is where the role and the link meet for real.
What must still work, which is the half that guards the SCOPE of the fix:
* a derived element with a static NUMERIC lower keeps its derived value, gets
its soft barrier, scores a finite logp, and -- the property that makes a
bound on a derived quantity mean anything -- puts a gradient on the PARENT
it is derived from. Asserted against a no-bound control that shows the
barrier is absent there, so the comparison is a real difference.
* a `mu` link on a derived element still applies, with the potential's value
checked against -0.5*((val - center)/sigma)^2 rather than merely existing.
* a bound link and a hard link on the SAMPLED element of the same mixed
vector still work, and the derived element beside them keeps its physics:
the refusal is per ELEMENT, not per parameter.
Values, not shapes (docs/testing.md): every case asserts the derived element
is the PHYSICS value, and the remapped sampled element is checked against its
own unlinked value. Five of the nine fail on master; the four "must still
work" ones pass before and after, which is what makes them regression guards.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
jdeast
force-pushed
the
worktree-batch3a-derived-element-links
branch
from
September 11, 2026 08:33
1328466 to
7c3e836
Compare
Owner
Author
|
Rebased onto 1cf797f (after #254 and #255, which both touched
|
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 3A: review items 1.2.5 (the defect) and 7.2.2 (its permanent test).
How the midpoint overwrite was reproduced on the current tree
Both directly in
build_pymcand end to end through a params file. Every probecarried a control -- the same link on the SAMPLED element of the same vector,
which must visibly re-map it -- so a negative result would have been
trustworthy (3.14.19).
Synthetic (a 2-element vector, element 0 sampled, element 1 derived to 7.77 by
an
element_expressionsspec, static bounds[0, 1]and[0, 7]):lowerlink 6.0mulink 6.0 + sigmalowerlink 0.25End to end, on the live surface the item names -- a two-orbit system with one
orbit in V_c/V_e mode and one not, which is what puts
orbit.eccon theper-element path (with a SINGLE vcve orbit the one selection covers the whole
vector, so
expressionis whole-vector and_wire_user_linksalready refusedthe link):
orbit.0.ecc: {lower: "0.2 + 0.1 * star.0.feh"}->eccevaluated to+inf, not a midpoint:ecchas no finite static upper, solo + span*0.5is infinite. The model built, then died much later insideunrelated physics with "eccentricity must be in the range [0, 1)".
orbit.0.ecc: {initval: "0.3 + 0.1 * star.0.feh", sigma: 0}->ecc[0]became 0.2884 instead of the derived 0.0002, and the model built and
scored (start logp -135.877 against the correct -124.933). Silent.
The two mechanisms, and why only the LINK path is refused
Two different mechanisms share the params-file words
lower:/upper::lower: 3(a NUMBER) is a soft barrier.needs_barrier = ((is_derived & ~is_reported) | (is_sampled & ~use_logit)) & ~is_fixed--is_derivedis theFIRST arm. The penalty is
potentials.soft_lower_bound/soft_upper_bound,bound_scaletunes its steepness, and the gradient acts on the PARENTS theelement is derived from, which is the only thing that can move a derived
value. This is the right answer for "keep this derived quantity above 3".
lower: <expression naming another parameter>(a LINK) is areparameterization, not a bound:
phys_val = pt.set_subtensor(phys_val[i], lo_t + span_t * q_i), so the value BECOMES the image of the element's ownlogit coordinate inside the linked interval ("the bound is a hard constraint
by construction", as the code says). Correct on a SAMPLED element; on a
derived one there is no coordinate,
lq[i]sits at its center,q_i = 0.5,and the physics is discarded.
Only the second is refused. Section B is untouched:
needs_barrier,has_lower/has_upper,safe_lowers/safe_uppers,sv_barrierandbound_scaleare not changed by one character.A numeric soft bound on a derived element still works
Explicitly, because 1.2.5's own wording ("build_pymc raises on
is_derived[i]")can be read as banning bounds on derived elements, and that reading was
rejected by JDE's ruling on 2026-09-11: it would delete a working, tested,
deliberately-built feature.
tests/test_per_element_soft_bound.pyisuntouched (
git diffon it is empty) and passes. The new test file adds itsown guard for the same property, including that the barrier's gradient reaches
the parent the element is derived from.
mulinks also stay legal on derived elements, per element exactly aswhole-vector, and the error message points at them.
The whole-vector precedent this matches
parameter.pyalready refused the same thing for a wholly-derived parameter("hard/bound links are not supported on derived (expression) parameters; only
'mu' links are"), and so does
Component._wire_user_linksat stage 6-minus-one(
if expression is not None and key != "mu"). Both key on the WHOLE-VECTORexpression, which is the entire bug: the guards predate 8.2.1's element roles.
So this is consistency with a decision already made, not new policy -- and the
two refusals now share one message (
_derived_link_error) so they cannotdrift. A test asserts the shared advice text fires on both paths.
The refusal is a pre-pass over the two link loops rather than a check inside
each, so nothing is partially re-mapped before the error and the element named
is the first one the user wrote.
Blast radius: nothing shipped changes
The refusal turns input that silently "worked" into an error, so:
in
initval/mu/sigma/lower/upper; a scan that yaml-loads all 103*.yamlunderexamples/,tests/andsrc/finds exactly two configs withstring-valued linkable fields that are real links --
examples/kelt4/kelt4_sed.params.yamlandkelt4_rv+transit+sed.params.yaml, which hard-linkstar.B/star.Cdistance/av/fehtostar.A. Every one of those targets is sampled(
is_derived = [False, False, False]), so none newly raises. Both configswere BUILT and scored: start logp 82924.87078950586 and
83894.81691822904, bit-identical to master's.
fitvcve/fitchordconfigs areexamples/hat3/hat3_transitonly.yamland two scripts under
examples/validation/, none of which sets any link.defaults.yamlsetselement_links(nothing but a user params file can).The error message
The hard-link variant differs only in its first clause ("a hard link (an
'initval' link with sigma: 0)") and its effect ("replace the derived value with
the link expression's own value").
7.2.2: what the test covers, in both directions
New file
tests/test_element_links_and_roles.py(9 tests, 14 s). A new filerather than an addition to either named file, because the seam belongs to
neither --
test_linked_params.pynever builds anElementExpressionandtest_element_parameterization.pynever setselement_links-- and both filesnow point at it from their module docstrings.
Must RAISE:
lowerLINK and anupperLINK on a derived element, each naming theelement by index AND by its
namesentry, saying it is derived, and namingboth legal spellings;
sigma: 0) on a derived element;messages cannot drift;
lower:link onorbit.eccof amixed V_c/V_e system, through
extract_links->_wire_user_links->build_pymc, with the per-element-ness of the role asserted so the testcannot silently degrade into the whole-vector case.
Must still WORK:
lowerkeeps its derived value, getsits
low_boundpotential, scores a finite logp, and puts a gradient on thePARENT it is derived from -- with a no-bound control that shows the potential
is absent there, so the comparison is a real difference;
mulink on a derived element still applies, its potential checked against-0.5*((val - center)/sigma)**2rather than merely existing;still work, and the derived element beside them keeps its physics: the
refusal is per ELEMENT, not per parameter.
Values, not shapes (
docs/testing.md): every case asserts the derived elementis the PHYSICS value, and the remapped sampled element is compared against its
own unlinked value. Scored against master (importing the main checkout's src):
5 of the 9 fail before the fix, and the 4 "must still work" cases pass
before and after, which is what makes them regression guards rather than new
behavior.
Acceptance: bit-identical
scripts/make_mulens_fixtures.py --check: output byte-for-byte identicalto the same script run against master's src -- 11
ok, the same two last-bitDIFFERS(ob09020'simf_priorat 2e-16 relative,OGLE_0383LD'smulensinstrument.modelat 6e-16) and the same fourMISSING fixture(
ob09020_{accept,diag,polish,tune}). Both predate this change; recordedhere, not fixed.
EXOZIPPY_ACCEPTANCE_STRICT=1 pytest tests/test_mulens_acceptance.py: 24passed,
max |rel| = 0.000e+00on both fixtures, 160 of 160 termsbit-identical.
tests/test_per_element_soft_bound.py: untouched, passing.Out of scope, filed separately
8.2.4 (a DYNAMIC SOFT BOUND has no spelling -- "penalize leaving an interval
whose edge is itself a parameter") was filed out of the same critique and is
NOT started here. This PR is its prerequisite: the refusal is what makes that
absence visible rather than papered over by a wrong number. The error message
points at the
mu+sigmaworkaround the item names, without citing an itemnumber.
🤖 Generated with Claude Code