Skip to content

parameter: refuse a bound or hard LINK on a derived element, per element (1.2.5, 7.2.2) - #256

Merged
jdeast merged 2 commits into
masterfrom
worktree-batch3a-derived-element-links
Sep 11, 2026
Merged

parameter: refuse a bound or hard LINK on a derived element, per element (1.2.5, 7.2.2)#256
jdeast merged 2 commits into
masterfrom
worktree-batch3a-derived-element-links

Conversation

@jdeast

@jdeast jdeast commented Sep 11, 2026

Copy link
Copy Markdown
Owner

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_pymc and end to end through a params file. Every probe
carried 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_expressions spec, static bounds [0, 1] and [0, 7]):

case before after
derived element, no link 7.77 7.77
derived element, lower link 6.0 6.5 (the midpoint of [6, 7]) raises
derived element, hard link 3.25 3.25 raises
derived element, mu link 6.0 + sigma 7.77 7.77 (unchanged)
SAMPLED element 0, lower link 0.25 0.625 (control: unlinked is 0.5) 0.625

End 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.ecc on the
per-element path (with a SINGLE vcve orbit the one selection covers the whole
vector, so expression is whole-vector and _wire_user_links already refused
the link):

  • orbit.0.ecc: {lower: "0.2 + 0.1 * star.0.feh"} -> ecc evaluated to
    +inf, not a midpoint: ecc has no finite static upper, so
    lo + span*0.5 is infinite. The model built, then died much later inside
    unrelated 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_derived is the
    FIRST arm. The penalty is potentials.soft_lower_bound/soft_upper_bound,
    bound_scale tunes its steepness, and the gradient acts on the PARENTS the
    element 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 a
    reparameterization
    , 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 own
    logit 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_barrier and
bound_scale are 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.py is
untouched
(git diff on it is empty) and passes. The new test file adds its
own guard for the same property, including that the barrier's gradient reaches
the parent the element is derived from.

mu links also stay legal on derived elements, per element exactly as
whole-vector, and the error message points at them.

The whole-vector precedent this matches

parameter.py already 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_links at stage 6-minus-one
(if expression is not None and key != "mu"). Both key on the WHOLE-VECTOR
expression, 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 cannot
drift. 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:

  • Zero yaml anywhere carries a LINK in a linkable field. A link is a STRING
    in initval/mu/sigma/lower/upper; a scan that yaml-loads all 103
    *.yaml under examples/, tests/ and src/ finds exactly two configs with
    string-valued linkable fields that are real links --
    examples/kelt4/kelt4_sed.params.yaml and
    kelt4_rv+transit+sed.params.yaml, which hard-link star.B/star.C
    distance/av/feh to star.A. Every one of those targets is sampled
    (is_derived = [False, False, False]), so none newly raises. Both configs
    were BUILT and scored: start logp 82924.87078950586 and
    83894.81691822904, bit-identical to master's.
  • The only fitvcve/fitchord configs are examples/hat3/hat3_transitonly.yaml
    and two scripts under examples/validation/, none of which sets any link.
  • No defaults.yaml sets element_links (nothing but a user params file can).

The error message

Parameter 'orbit.ecc'[0] ('b'): a dynamic bound link ('lower'/'upper' naming
another parameter) is not supported here: this element's value comes from an
expression (it is DERIVED), so a link cannot constrain it -- it can only take
its place.  Applying this one would replace the derived value with the MIDPOINT
of the linked interval (a dynamic bound re-maps the element's own sampling
coordinate into that interval, and a derived element has none, so the
coordinate sits at its center).  Two things do work on a derived element: a
NUMERIC 'lower'/'upper', which becomes a soft barrier whose gradient acts on
the parameters this element is derived FROM -- the only thing that can move a
derived value -- with 'bound_scale' setting its steepness; and a 'mu' link with
a 'sigma', which is a soft Gaussian pull toward a tensor-valued center.

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 file
rather than an addition to either named file, because the seam belongs to
neither -- test_linked_params.py never builds an ElementExpression and
test_element_parameterization.py never sets element_links -- and both files
now point at it from their module docstrings.

Must RAISE:

  • a lower LINK and an upper LINK on a derived element, each naming the
    element by index AND by its names entry, saying it is derived, and naming
    both legal spellings;
  • a hard link (initval link + sigma: 0) on a derived element;
  • the whole-vector case, asserted against the same advice text, so the two
    messages cannot drift;
  • 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, with the per-element-ness of the role asserted so the test
    cannot silently degrade into the whole-vector case.

Must still WORK:

  • a derived element with a static NUMERIC lower keeps its derived value, gets
    its low_bound potential, scores a finite logp, and puts a gradient on the
    PARENT it is derived from -- with a no-bound control that shows the potential
    is absent there, so the comparison is a real difference;
  • a mu link on a derived element still applies, its potential 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 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 identical
    to the same script run against master's src -- 11 ok, the same two last-bit
    DIFFERS (ob09020's imf_prior at 2e-16 relative, OGLE_0383LD's
    mulensinstrument.model at 6e-16) and the same four MISSING fixture
    (ob09020_{accept,diag,polish,tune}). Both predate this change; recorded
    here, not fixed.
  • EXOZIPPY_ACCEPTANCE_STRICT=1 pytest tests/test_mulens_acceptance.py: 24
    passed
    , max |rel| = 0.000e+00 on both fixtures, 160 of 160 terms
    bit-identical
    .
  • The two shipped link-carrying configs: start logp identical to master (above).
  • tests/test_per_element_soft_bound.py: untouched, passing.
  • Full suite: green.

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 + sigma workaround the item names, without citing an item
number.

🤖 Generated with Claude Code

jdeast and others added 2 commits September 11, 2026 04:06
…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
jdeast force-pushed the worktree-batch3a-derived-element-links branch from 1328466 to 7c3e836 Compare September 11, 2026 08:33
@jdeast

jdeast commented Sep 11, 2026

Copy link
Copy Markdown
Owner Author

Rebased onto 1cf797f (after #254 and #255, which both touched parameter.py). Clean rebase, no conflicts -- #255's comment rewording and this refusal do not overlap. Re-verified on the rebased tree:

  • tests/test_element_links_and_roles.py + test_per_element_soft_bound.py + test_linked_params.py + test_element_parameterization.py + test_parameter_logic.py: 144 passed.
  • scripts/make_mulens_fixtures.py --check: same output as master's (11 ok, the same two pre-existing last-bit DIFFERS, the same four MISSING fixture).
  • EXOZIPPY_ACCEPTANCE_STRICT=1 pytest tests/test_mulens_acceptance.py: 24 passed, max |rel| = 0.000e+00 on both acceptance fixtures, 923 of 925 terms bit-identical -- the 2 exceptions being exactly the two pre-existing last-bit deltas above (5.4e-16 and 1.3e-16 relative).
  • Full suite green via the pre-push hook, run alone on an otherwise idle box.

@jdeast
jdeast merged commit c93bf69 into master Sep 11, 2026
21 checks passed
@jdeast
jdeast deleted the worktree-batch3a-derived-element-links branch September 11, 2026 08:50
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