Skip to content

fix(petab): refuse a U-tagged free parameter on export instead of writing it as a hard-bounded one (#736) - #737

Merged
wshlavacek merged 1 commit into
mainfrom
petab-export-unbounded-flag-736
Sep 17, 2026
Merged

wshlavacek merged 1 commit into
mainfrom
petab-export-unbounded-flag-736

Conversation

@wshlavacek

Copy link
Copy Markdown
Collaborator

Closes #736. Third and last of the export-drops-part-of-a-declaration family, after #719
(PR #734) and #733 (PR #735).

What was wrong

uniform_var = v 0 10 U (and its loguniform_var twin) means the box is enforced only
during initialization
— it seeds the first population and the search is then free to leave
it. ploop reads the tag and the config loader honours it, so the fitter builds a parameter
whose box is (-inf, inf).

The exporter read value[0] and value[1] and stopped. The comment above the construction
called the third token "the native bounded flag, inert for the location families" — true
there, and false where the token is actually legal: parse.b_var_def_keys is exactly
['loguniform_var', 'uniform_var'], the two families whose p1/p2 become the PEtab
bounds. So bounded defaulted to True and _petab_uniform_row wrote [p1, p2] into
lowerBound/upperBound, which in PEtab are hard box constraints.

Measured end to end:

1. fitter:       v1.bounded = False   box = (-inf, inf)
2. exported row: v1	true	0	10              # byte-identical to the bounded spelling
3. re-imported:  v1.bounded = True    box = (0.0, 10.0)
   conf line: ['uniform_var = v1 0 10']         # the U is gone

A search the config declared unconstrained comes back constrained, silently — against
docs/petab.rst's "the export is fit-preserving" and against the module's own contract that
everything it cannot write raises NotImplementedError.

Why a refusal and not a better mapping

Unlike #719 and #733, there is no mapping waiting to be written. PEtab's nearest shape — blank
(infinite) bounds plus an explicit priorDistribution = uniform over the box — says something
else, because PEtab bounds truncate a prior rather than seed a draw. It does not survive the
trip either:

row = PetabParameterRow('v1', True, None, None,
                        prior_distribution='uniform', prior_parameters=(0.0, 10.0))
free_parameter_from_row(row).bounded      # True

_resolve_prior's uniform arm returns bounded=True unconditionally. That is the right
reading for a PEtab row, whose bounds are hard, so nothing changes on the import side.

Refuse vs. warn-and-export. I considered writing the box as bounds with a warning and
rejected it: the exporter emits no warnings anywhere, so it would be a new channel for one
case, and a warning is precisely the signal the #583/#719 work found people do not act on.
The cost of refusing is nothing measurable — there is no U-tagged declaration in the repo's
examples, tests or benchmarks, or in the BNGL-Models/pybnf-jobs corpus. ADR-0025 records the
decision and names warn-and-export as the fallback if it ever becomes a real obstacle.

What changed

  • _free_parameter_from_var_line passes the flag through, so the parameter the exporter
    builds matches the one the fitter builds.
  • _petab_uniform_row raises NotImplementedError when the box is off, naming the boundary
    and saying to drop the tag to export the box as real bounds.

The untagged and B-tagged spellings export exactly as before.

Tests

Five, of which four fail against the old code.

  • Two at the row mapper, parametrized over both flag-bearing families, each also asserting the
    bounded spelling of the same declaration is untouched.
  • Two end to end on a real job, one per family.
  • One holding the other half of the flag: an explicit B tag exports byte-identically to
    leaving it off — the guard that threading bounded through the constructor perturbs nothing.

Full suite green: 5162 passed, 25 skipped. ruff@0.15.14 clean; the -W --keep-going
Sphinx build succeeds.

Docs

ADR-0025 carries the amendment. docs/petab.rst names the boundary in the export section, and
the uniform_var config-key entry says the tag cannot be exported — where a reader of that tag
will actually look.

…ting it as a hard-bounded one (#736)

`uniform_var = v 0 10 U` and its `loguniform_var` twin mean the box is enforced
only during initialization: it seeds the first population and the search is then
free to leave it. `ploop` reads the tag into the value list and the config loader
honours it, so the fitter builds a parameter whose box is `(-inf, inf)`.

The exporter read `value[0]` and `value[1]` and stopped. The comment above the
construction called the third token "the native `bounded` flag, inert for the
location families" -- true there, and false where the token is actually legal,
since `parse.b_var_def_keys` is exactly `['loguniform_var', 'uniform_var']`, the
two families whose p1/p2 become the PEtab bounds. `bounded` therefore defaulted
to True and `_petab_uniform_row` wrote `[p1, p2]` into `lowerBound`/`upperBound`,
which in PEtab are hard box constraints.

Measured: a conf declaring a search free to leave [0, 10] exported to a table
byte-identical to the bounded spelling, and the re-imported conf built a
parameter with `bounded=True` and box `(0, 10)` -- the `U` gone from the line.
No warning, no exception. This is the third defect of the same shape as #719 and
#733: the export reads part of a declaration and discards the rest without
saying so, against docs/petab.rst's "the export is fit-preserving" and against
the module's own contract that everything it cannot write raises
NotImplementedError.

Unlike the first two this one has no mapping waiting to be written. PEtab's
nearest shape -- blank (infinite) bounds plus an explicit
`priorDistribution = uniform` over the box -- says something else, because PEtab
bounds truncate a prior rather than seed a draw, and it does not survive the trip
either: `_resolve_prior`'s uniform arm returns `bounded=True` unconditionally, so
such a row re-imports as a bounded parameter. That is the right reading for a
PEtab row, whose bounds are hard, so nothing changes on the import side.

So the exporter refuses. `_free_parameter_from_var_line` passes the flag through
(the parameter it builds now matches the one the fitter builds), and
`_petab_uniform_row` raises NotImplementedError when it is off, naming the
boundary and saying to drop the tag to export the box as real bounds. Exporting
the box with a warning was considered and rejected: the exporter emits no
warnings anywhere, so it would be a new channel for one case, and a warning is
the signal the #583/#719 work found people do not act on. The cost of refusing is
nothing measurable -- no `U`-tagged declaration exists in the repo's examples,
tests or benchmarks, or in the BNGL-Models/pybnf-jobs corpus.

Tests: five, of which four fail against the old code. Two at the row mapper,
parametrized over both flag-bearing families, which also assert the bounded
spelling of the same declaration is untouched; two end to end on a real job, one
per family; and one holding the other half of the flag, that an explicit `B` tag
exports byte-identically to leaving it off -- the guard that threading `bounded`
through the constructor perturbs nothing.

ADR-0025 carries an amendment recording the refusal, the rejected
warn-and-export alternative, and the evidence that the PEtab shape is genuinely
absent rather than merely unmapped. The `uniform_var` config-key documentation
now says the tag cannot be exported, where a reader of that tag will look.

Signed-off-by: Bill Hlavacek <hlavacek@lanl.gov>
@wshlavacek
wshlavacek merged commit f7eb376 into main Sep 17, 2026
7 checks passed
@wshlavacek
wshlavacek deleted the petab-export-unbounded-flag-736 branch September 17, 2026 19:57
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.

PEtab export silently drops the 'u' unbounded flag, so an unconstrained search is exported as a hard-bounded one

1 participant