Skip to content

fix(petab): refuse a time_error fit on export instead of writing it as an exact-time one (#738) - #739

Merged
wshlavacek merged 1 commit into
mainfrom
petab-export-reject-time-error-738
Sep 17, 2026
Merged

wshlavacek merged 1 commit into
mainfrom
petab-export-reject-time-error-738

Conversation

@wshlavacek

Copy link
Copy Markdown
Collaborator

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

What was wrong

A time_error clause on a noise_model line (ADR-0112, #587) says the reported measurement
times are not exact: the objective integrates each observation's density over a prior on
its true sampling time. ploop stores it under its own structural key,
('time_error', observable), and config.py reads that key to swap the whole per-point
objective for a MarginalizedTimeObjective.

The exporter had no awareness of the clause whatsoever — grep -n time_error pybnf/petab/export.py was empty. So the job exported with no warning and no exception, and the
emitted problem held the measurements at their nominal times as though exact. Measured end to
end on the demo job:

1. objective with the clause : MarginalizedTimeObjective
   time_error keys in config : [('time_error', None)]
2. export warnings           : []
3. time_error in re-imported : False
4. objective after round trip: LikelihoodObjective

A different statistical model, reached in silence.

Why this is an oversight rather than a decision

Its sibling in the very same noise_model field grammar was guarded all along.
cumulative is stored under the same kind of key by the same nm_field alternation, and
_reject_cumulative refuses it with reasoning that transfers verbatim:

Exporting would silently drop it and emit a problem that scores the raw cumulative columns,
a different objective. Refuse instead (the project's fail-loud-over-silently-wrong stance).

That function's twin for time_error was never written. It is now, beside it, called from
export_job between _reject_cumulative and _reject_normalization.

Walking the whole nm_field alternation, this was the last gap of its shape in the
objective/noise surface:

clause export
cumulative refused (already)
location (mean-centred) refused (already)
prediction_formula, formula, noise source export and round-trip under test (predsigma_v2, scaling_v2)
time_error / sigma_t was dropped in silence — now refused

Why a refusal, not a richer export

Like #736 and unlike #719 and #733, this is a real PEtab boundary rather than an unwritten
mapping: a measurements row carries one exact time and has no field for a distribution over
it. There is nothing to write the clause as.

The guard keys on the ('time_error', observable) config key rather than on the whole-fit
shape that happens to be reachable today — the exporter reads the raw config, while it is
_maybe_marginalize_time that defers the per-observable form. It names the observables when it
finds them and says "this fit" when the clause is whole-fit. sigma_t rides on the same key and
goes with it.

Tests

Three, of which two fail against the old code.

  • The whole-fit clause is refused.
  • A per-observable clause is refused too — pinning that the guard reads the config key, not the
    currently-reachable shape.
  • A control exports the same fixture with the time clause removed, so the refusal cannot pass
    for some other property of the conf.

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

Docs

The boundary is added to both lists that enumerate them: the export.py module docstring's
in-code list, and docs/petab.rst, which now names the two refusals a user is most likely to
meet on a job that otherwise looks perfectly exportable (this one and the U tag from #736).

No ADR amendment: the refuse-over-silently-wrong stance is already established by ADR-0025 and
by _reject_cumulative, so there is no new decision here to record.

…s an exact-time one (#738)

A `time_error` clause on a `noise_model` line (ADR-0112, #587) says the reported
measurement times are not exact: the objective integrates each observation's
density over a prior on its true sampling time. `ploop` stores it under its own
structural key, `('time_error', observable)`, and `config.py` reads that key to
swap the whole per-point objective for a `MarginalizedTimeObjective`.

The exporter had no awareness of the clause whatsoever -- `grep -n time_error
pybnf/petab/export.py` was empty -- so a job carrying it exported with no
warning and no exception, and the emitted problem held the measurements at their
nominal times as though those were exact. Measured end to end on the demo job:
`MarginalizedTimeObjective` before the round trip, `LikelihoodObjective` after,
with the re-imported conf containing no `time_error` substring at all. That is a
different statistical model, reached silently.

Its sibling in the very same `noise_model` field grammar was guarded all along.
`cumulative` is stored under the same kind of key by the same `nm_field`
alternation, and `_reject_cumulative` refuses it with reasoning that transfers
verbatim: exporting "would silently drop it and emit a problem that scores the
raw cumulative columns, a different objective. Refuse instead (the project's
fail-loud-over-silently-wrong stance)". That function's twin for `time_error` was
never written. It is now, beside it, called from `export_job` between
`_reject_cumulative` and `_reject_normalization`.

Like the `u` flag of #736 and unlike #719 and #733, this is a real PEtab boundary
rather than an unwritten mapping: a `measurements` row carries one exact `time`
and has no field for a distribution over it. So the fix is a refusal naming the
boundary, not a richer export. The refusal keys on the `('time_error',
observable)` key rather than on the whole-fit shape that is reachable today,
because the exporter reads the raw config while `_maybe_marginalize_time` is what
defers the per-observable form; it names the observables when it finds them and
says the fit when the clause is whole-fit. The `sigma_t` scale source rides on the
same key and goes with it.

Walking the whole `nm_field` alternation, `time_error`/`sigma_t` was the last gap
of this shape in the objective/noise surface: `cumulative` and a mean-centred
`location` were already refused, and `prediction_formula`, `formula` and the noise
source all export and round-trip under test (the `predsigma_v2` and `scaling_v2`
fixtures).

Tests: three, of which two fail against the old code. The whole-fit clause is
refused; a per-observable clause is refused too, which pins that the guard reads
the config key rather than the currently-reachable shape; and a control exports
the same fixture with the time clause removed, so the refusal cannot pass for
some other property of the conf.

The boundary is added to the two lists that enumerate them: the module docstring's
in-code list, and the PEtab page, which now names the two refusals a user is most
likely to meet on a job that otherwise looks perfectly exportable.

Signed-off-by: Bill Hlavacek <hlavacek@lanl.gov>
@wshlavacek
wshlavacek merged commit 766632d into main Sep 17, 2026
7 checks passed
@wshlavacek
wshlavacek deleted the petab-export-reject-time-error-738 branch September 17, 2026 20:33
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 a time_error clause, so a marginalized-time fit exports as an exact-time one

1 participant