diff --git a/CHANGELOG.md b/CHANGELOG.md index 37e96553..3d3b8ee7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -252,6 +252,32 @@ All notable changes to PyBNF are documented below. This project adheres to by default. Both surfaces are documented under gradient-based fitting. ### Fixed +- **The PEtab export reads edition-2 `parameter:` records, so a free parameter written the + new-era way no longer vanishes from the exported problem (#733).** The exporter picked its + free parameters out of the config by matching key names against `(_var$|^var$|^logvar$)`. + A `parameter:` record is stored under a `('parameter', id)` key, which matches none of the + three, so it was skipped rather than refused: no row was written, nothing downstream + noticed the id was missing, and the export finished cleanly having dropped the parameter. + A conf whose parameters were *all* records reported "No exportable free parameters found", + naming only the `*_var` keywords — pointing the user away from the syntax the edition-2 + documentation teaches. Both contradict the exporter's own contract, that everything it + cannot write raises `NotImplementedError` with the boundary named in code. + The damage was widest on truncated priors. A record is the only grammar carrying + `lower`/`upper`, so it is what the importer emits for a prior truncated to a box + (ADR-0020/0047) — meaning a PEtab problem with bounded priors imported fine and then + exported to a table missing exactly those parameters. On the tutorial's own PEtab priors + problem (lesson 15) that was three of four: a log-normal, a gamma and a normal all + disappeared, leaving one plain uniform, and the re-import produced a fit over one + parameter instead of four. + The exporter now reads both declaration spellings in one pass, in declaration order, and + builds each record through the same mapping the fitter loads a job with, which moved to + `pybnf/parameter_record.py` so the two cannot drift. A record therefore meets the same + boundaries the positional line does, reached by a different spelling: a no-prior point + start, a natural-log sampling scale, a three-parameter family such as student_t, and the + log forms PEtab defines for no family are each refused with the keyword the record built. + `initial_value:` is honoured as the start point it is, alongside a `start_point` line when + the two agree and refused when they disagree, and an out-of-box one is a `PybnfError` + rather than the bare `OutOfBoundsException` that reaches users as "an unknown error". - **The PEtab export writes the fit's start point, so a round trip no longer moves the fit back to a sampled draw (#719).** `nominalValue` is where a PEtab problem states the point a fit starts from, and since #583 PyBNF reads it: an imported problem's nominal becomes a diff --git a/docs/adr/0043-new-era-free-parameter-is-a-labeled-parameter-record-prior-bounds-initial-value.md b/docs/adr/0043-new-era-free-parameter-is-a-labeled-parameter-record-prior-bounds-initial-value.md index dfd72d3d..340e8d4f 100644 --- a/docs/adr/0043-new-era-free-parameter-is-a-labeled-parameter-record-prior-bounds-initial-value.md +++ b/docs/adr/0043-new-era-free-parameter-is-a-labeled-parameter-record-prior-bounds-initial-value.md @@ -209,3 +209,23 @@ prior — the default start when `initial_value` is absent), 0003 (prior in the 0031 (edition select-and-freeze), 0038 (which filed the native truncation grammar on #417, and the blank-bounds export limitation this lifts). Issue: **#417** (reframed from "truncation grammar" to "the new-era `parameter:` record"; truncation is its `lower`/`upper` fields). + +## Amendment (2026-09-17, #733) + +The loader is no longer config-private. It reads a declaration grammar, and the PEtab v2 +exporter reads the same declarations when it serializes a job, so the mapping moved to +`pybnf/parameter_record.py` (`free_parameter_from_record`) with two callers: +`Configuration._load_variables` and `petab.export._free_parameters_from_conf`. The +`Configuration._free_parameter_from_record` method above remains, delegating, so the +references in this ADR and in ADR-0047 still name something real. + +The move is what fixes #733. Until it, the exporter matched free parameters by config KEY +name (`_VAR_DECL`, which a `('parameter', id)` key never matches), so every `parameter:` +record was *skipped* rather than refused: the whole free parameter vanished from the +exported problem with no diagnostic, and with it every truncated prior — the shape this +ADR's `lower`/`upper` fields exist to author, and the one the importer emits as a record. +The failure is the same one #603 found on the coherence gate, in the other direction: +keying on the config key name instead of on what the declaration builds makes the record +syntax invisible to a rule the positional line goes through. An exporter that re-derived +the record grammar for itself would drift from the one the fitter runs, which is why the +fix is one shared builder rather than a second reading. diff --git a/docs/modules/index.rst b/docs/modules/index.rst index 03c0afa0..5b9ce436 100644 --- a/docs/modules/index.rst +++ b/docs/modules/index.rst @@ -19,6 +19,7 @@ PyBNF Module References measurement noise objective + parameter_record parse petab printing diff --git a/docs/modules/parameter_record.rst b/docs/modules/parameter_record.rst new file mode 100644 index 00000000..3d0f0b52 --- /dev/null +++ b/docs/modules/parameter_record.rst @@ -0,0 +1,6 @@ +========================================================= +Free-parameter records (:py:mod:`pybnf.parameter_record`) +========================================================= + +.. automodule:: pybnf.parameter_record + :members: diff --git a/docs/petab.rst b/docs/petab.rst index 6f116e75..e534e5a0 100644 --- a/docs/petab.rst +++ b/docs/petab.rst @@ -64,7 +64,11 @@ model) in ``out_dir``:: export_job('fit.conf', 'exported/') The export is fit-preserving: re-importing the emitted problem reproduces the same -free parameters, priors, noise models, and data. +free parameters, priors, noise models, and data. Both free-parameter spellings are +read — the positional ``_var`` line and the edition-2 ``parameter:`` record — +so a truncated prior, which only the record can state, exports and round-trips like any +other. Anything PEtab v2 cannot express raises ``NotImplementedError`` naming the +boundary; nothing is dropped quietly. .. _petab_bngl_loader: diff --git a/pybnf/config.py b/pybnf/config.py index 0eb1b121..1912e3f0 100644 --- a/pybnf/config.py +++ b/pybnf/config.py @@ -5,7 +5,7 @@ from . import objective # noqa: F401 -- imported for its side effect: running the module fires the @register_objfunc decorators, populating OBJFUNC_REGISTRY before _load_obj_func dispatches. from . import algorithms # noqa: F401 -- imported for its side effect: running the leaves fires the @register_fit_type decorators, populating FIT_TYPE_REGISTRY (incl. each method's config schema) before _build_config dispatches. No cycle: nothing in algorithms/ imports config. from .registry import OBJFUNC_REGISTRY, FIT_TYPE_REGISTRY -from .priors import PRIOR_KEYWORD_MAP +from .parameter_record import free_parameter_from_record from . import config_schema from . import edition @@ -3680,173 +3680,15 @@ def _warn_unbounded_start_points(self): f"start point and the search bounded.") def _free_parameter_from_record(self, pid, raw_fields, initialization_distribution): - """Build a :class:`FreeParameter` from a new-era ``parameter:`` record (ADR-0043). - - ``raw_fields`` is the parsed ``{field: str}`` map -- every part of the line is named: - ``prior`` (the family), ``space`` (``linear``/``log10``, the sampling-space transform), - the family's own distribution fields (``mean``/``sd``, ``location``/``scale``, ...), - ``lower``/``upper`` (the bounds that truncate the prior -- #417/ADR-0020), and - ``initial_value`` (the start point). No positional numbers; the family names its fields - via ``Prior.field_names``. The truncation/box capability is unchanged -- this only maps - named fields onto the existing ``FreeParameter`` constructor. - """ - fields = dict(raw_fields) - - def _num(name): - v = fields.pop(name) - try: - return float(v) - except (TypeError, ValueError): - raise PybnfError(f"Parameter '{pid}': field '{name}' must be a number, got {v!r}.") - - prior_name = fields.pop('prior', None) - # The sampling-space transform. PyBNF samples in linear, log10, or natural log; each - # base is named explicitly so it is never ambiguous (ADR-0022/0043). ``lin`` is - # accepted as PEtab's spelling of ``linear``; ``log`` is rejected as ambiguous (PEtab - # means natural by it, PyBNF historically means log10) -- write ``ln`` or ``log10``. - # The base prefixes the family keyword (``log{f}_var`` / ``ln{f}_var`` / ``var``). - pscale = str(fields.pop('parameter_scale', 'linear')).lower() - scale_prefix = {'lin': '', 'linear': '', 'log10': 'log', 'ln': 'ln'} - if pscale == 'log': - raise PybnfError( - f"Parameter '{pid}': parameter_scale 'log' is ambiguous -- write 'log10' " - f"(base 10) or 'ln' (natural log) explicitly (ADR-0022).") - if pscale not in scale_prefix: - raise PybnfError(f"Parameter '{pid}': parameter_scale must be 'linear', 'log10', or " - f"'ln', got '{pscale}'.") - prefix = scale_prefix[pscale] - - lower = _num('lower') if 'lower' in fields else None - upper = _num('upper') if 'upper' in fields else None - # Bounds come as a pair: an open side is an explicit +-inf, never a blank - # (ADR-0047 -- no specification by absence). Omitting *both* is the untruncated - # shorthand. One-sided truncation IS supported now -- spell the open side with - # an infinity. The graded floor rule (positivity, support floor) is applied per - # path below: finite for a uniform box, the family floor for a truncated prior. - if (lower is None) != (upper is None): - present, absent = ('lower', 'upper') if upper is None else ('upper', 'lower') - raise PybnfError( - f"Parameter '{pid}': bounds come as a pair -- '{present}' is set but " - f"'{absent}' is missing. For an open {absent} side write an explicit " - f"infinity ('{absent}: inf' or '{absent}: -inf'), not a blank (ADR-0047).") - initial_value = _num('initial_value') if 'initial_value' in fields else None - is_log_scale = prefix in ('log', 'ln') - - if prior_name is None: - if lower is not None: - # No prior but bounds -> uniform over the bounds (PEtab's default for an - # estimated parameter without an explicit prior; the importer does the same). - self._require_finite_box(pid, lower, upper, is_log_scale, "a uniform box") - keyword = f'{prefix}uniform_var' - self._reject_extra_fields(pid, fields, keyword) - return FreeParameter(pid, keyword, lower, upper, value=initial_value, bounded=True, - initialization_distribution=initialization_distribution) - # No prior and no bounds -> the no-prior start point (legacy var/logvar/lnvar). Its - # start value is carried in the FreeParameter's first slot *in sampling space* - # (Simplex reads it via from_sampling_space(p1)), so map the theta-space - # initial_value through the scale -- making initial_value the real value (theta) for - # a log start point too, consistent with the prior-param case. - if initial_value is None: - raise PybnfError(f"Parameter '{pid}': declares no prior, no bounds, and no " - f"initial_value -- nothing to fit. Give it a 'prior:', a " - f"'lower:'/'upper:' box, or an 'initial_value:'.") - if is_log_scale and initial_value <= 0.0: - raise PybnfError(f"Parameter '{pid}': a {pscale} start point needs " - f"initial_value > 0, got {initial_value}.") - self._reject_extra_fields(pid, fields, 'a no-prior start point') - _, start_scale = PRIOR_KEYWORD_MAP[f'{prefix}var'] - return FreeParameter(pid, f'{prefix}var', float(start_scale.forward(initial_value)), None, - initialization_distribution=initialization_distribution) - - prior_name = str(prior_name).lower() - if prior_name == 'uniform': - # Uniform: lower/upper ARE the support (and the bounds); no separate family fields. - if lower is None: - raise PybnfError(f"Parameter '{pid}': a uniform prior needs 'lower' and 'upper'.") - self._require_finite_box(pid, lower, upper, is_log_scale, "a uniform prior") - keyword = f'{prefix}uniform_var' - self._reject_extra_fields(pid, fields, keyword) - return FreeParameter(pid, keyword, lower, upper, value=initial_value, bounded=True, - initialization_distribution=initialization_distribution) - - keyword = f'{prefix}{prior_name}_var' - if keyword not in PRIOR_KEYWORD_MAP: - raise PybnfError(f"Parameter '{pid}': unknown prior family '{prior_name}'.") - fam, _scale = PRIOR_KEYWORD_MAP[keyword] - params = [] - for fname in fam.field_names: - if fname not in fields: - raise PybnfError(f"Parameter '{pid}': prior '{prior_name}' needs field '{fname}'.") - params.append(_num(fname)) - self._reject_extra_fields(pid, fields, f"prior '{prior_name}'") - p1 = params[0] - p2 = params[1] if len(fam.field_names) >= 2 else None - # A three-parameter family (student_t, ADR-0057) carries its third value in p3; - # field_names ordered it last (df/location/scale -> p1/p2/p3). The carrier and - # build_prior pass it through; it is None for the one- and two-parameter families. - p3 = params[2] if len(fam.field_names) >= 3 else None - # lower/upper truncate an unbounded-support family to a reflecting box: two finite - # walls (two-sided, ADR-0020) or one finite wall + an infinity (half-bounded, - # ADR-0047). The graded floor rule warns/errors on a sub-floor lower bound first. - lower, upper = self._graded_truncation_bounds(pid, lower, upper, fam, _scale) - return FreeParameter(pid, keyword, p1, p2, lb=lower, ub=upper, value=initial_value, - initialization_distribution=initialization_distribution, p3=p3) - - @staticmethod - def _require_finite_box(pid, lower, upper, is_log, where): - """A Uniform family's bounds ARE its support, so they must be finite -- an - infinite bound describes an unbounded prior's open tail, not a box. A log - scale additionally needs a strictly positive lower bound (ADR-0047).""" - for label, v in (('lower', lower), ('upper', upper)): - if v is None or not np.isfinite(v): - raise PybnfError( - f"Parameter '{pid}': {where} needs a finite '{label}' bound " - f"(got {v}); an infinite bound describes an unbounded prior's open " - f"tail, not a uniform box.") - if is_log and lower <= 0.0: - raise PybnfError( - f"Parameter '{pid}': {where} on a log scale needs 'lower' > 0 " - f"(log of <= 0 is -inf), got lower={lower}.") - - @staticmethod - def _graded_truncation_bounds(pid, lower, upper, fam, scale): - """Apply the ADR-0047 graded sentinel/floor rule to a truncated family's bounds. - - ``lower``/``upper`` are in theta, already validated to be both-set or both-None - (the pairing rule). Omit-both passes through as the untruncated shorthand. On a - positive-support family -- whose theta floor, derived from the family's natural - support and the scale, is finite (0 for the linear half-bounded families; - 0 for any log form; the doubly-unbounded families floor at -inf and are exempt) -- - a sloppy-but-lossless ``lower: -inf`` is warned and canonicalized to the floor, - and a *finite* ``lower`` below the floor (a wall in the zero-density region, a - likely wrong family/scale) is an error. These families are all unbounded above, - so the upper side needs no floor. Returns the (possibly canonicalized) bounds.""" - if lower is None: - return lower, upper - floor = scale.inverse(fam.support_lo_u) # theta-space support floor - if np.isfinite(floor): - if lower == -np.inf: - logger.warning( - f"Parameter '{pid}': 'lower: -inf' on a prior whose support floor " - f"is {floor:g} -- interpreting as open below at the floor. Write " - f"'lower: {floor:g}' to silence this (ADR-0047).") - lower = floor - elif lower < floor: - raise PybnfError( - f"Parameter '{pid}': 'lower: {lower:g}' is below the prior's support " - f"floor {floor:g} -- a finite wall in the zero-density region (likely " - f"a wrong family or scale). Use 'lower: {floor:g}' for an open lower " - f"side, or a value >= {floor:g} (ADR-0047).") - return lower, upper + """Build a :class:`~pybnf.pset.FreeParameter` from a new-era ``parameter:`` record. - @staticmethod - def _reject_extra_fields(pid, leftover, where): - """Raise a clear error if a ``parameter:`` record carries fields unknown to ``where`` - (a typo or a field from a different family) -- naming every part means an unrecognised - name is an error, not a silently-ignored token.""" - if leftover: - unknown = ', '.join(sorted(leftover)) - raise PybnfError(f"Parameter '{pid}': unknown field(s) for {where}: {unknown}.") + The mapping itself lives in :mod:`pybnf.parameter_record`, because the PEtab + exporter reads the same records when it serializes a job and must land on the + same object this does -- it used to skip them entirely for want of a builder it + could reach (#733). Kept as a method so the loader below, and the ADR-0043 tests + that exercise the record grammar directly, still call one name. + """ + return free_parameter_from_record(pid, raw_fields, initialization_distribution) @staticmethod def _declaration_kind(v): diff --git a/pybnf/parameter_record.py b/pybnf/parameter_record.py new file mode 100644 index 00000000..42bf5c00 --- /dev/null +++ b/pybnf/parameter_record.py @@ -0,0 +1,198 @@ +"""The new-era ``parameter:`` record -> :class:`~pybnf.pset.FreeParameter` (ADR-0043). + +The edition-2 free-parameter declaration is a *fully labeled* record -- +``parameter: , prior: , : , ..., lower: , upper: , +initial_value: `` -- where the legacy positional ``_var = p1 p2`` line +names nothing. It is also the **only** grammar that carries ``lower``/``upper``, so a +truncated prior (two-sided, ADR-0020; half-bounded, ADR-0047) can be written no other way. + +This module is the one mapping from that record onto a ``FreeParameter``, with two +consumers. :class:`pybnf.config.Configuration` reads it when it loads a job to fit, and +:mod:`pybnf.petab.export` reads it when it serializes a job to a PEtab v2 problem. They +must agree: a PEtab row and a native declaration landing on the same object is the +two-adapter proof (ADR-0004), and an exporter that re-derived the record grammar for +itself would drift from the one the fitter actually runs. It lived as a private +``Configuration`` method until the exporter needed it, and was silently skipping every +``parameter:`` record for want of it (#733). + +Light by design (``printing`` / ``priors`` / ``pset`` / numpy): the exporter reads a job +through the stdlib ``ploop`` parser without building a ``Configuration``, and pulling the +whole configuration layer in to reach one builder would defeat that. +""" + +import logging + +import numpy as np + +from .printing import PybnfError +from .priors import PRIOR_KEYWORD_MAP +from .pset import FreeParameter + +logger = logging.getLogger(__name__) + + +def free_parameter_from_record(pid, raw_fields, initialization_distribution): + """Build a :class:`FreeParameter` from a new-era ``parameter:`` record (ADR-0043). + + ``raw_fields`` is the parsed ``{field: str}`` map -- every part of the line is named: + ``prior`` (the family), ``space`` (``linear``/``log10``, the sampling-space transform), + the family's own distribution fields (``mean``/``sd``, ``location``/``scale``, ...), + ``lower``/``upper`` (the bounds that truncate the prior -- #417/ADR-0020), and + ``initial_value`` (the start point). No positional numbers; the family names its fields + via ``Prior.field_names``. The truncation/box capability is unchanged -- this only maps + named fields onto the existing ``FreeParameter`` constructor. + """ + fields = dict(raw_fields) + + def _num(name): + v = fields.pop(name) + try: + return float(v) + except (TypeError, ValueError): + raise PybnfError(f"Parameter '{pid}': field '{name}' must be a number, got {v!r}.") + + prior_name = fields.pop('prior', None) + # The sampling-space transform. PyBNF samples in linear, log10, or natural log; each + # base is named explicitly so it is never ambiguous (ADR-0022/0043). ``lin`` is + # accepted as PEtab's spelling of ``linear``; ``log`` is rejected as ambiguous (PEtab + # means natural by it, PyBNF historically means log10) -- write ``ln`` or ``log10``. + # The base prefixes the family keyword (``log{f}_var`` / ``ln{f}_var`` / ``var``). + pscale = str(fields.pop('parameter_scale', 'linear')).lower() + scale_prefix = {'lin': '', 'linear': '', 'log10': 'log', 'ln': 'ln'} + if pscale == 'log': + raise PybnfError( + f"Parameter '{pid}': parameter_scale 'log' is ambiguous -- write 'log10' " + f"(base 10) or 'ln' (natural log) explicitly (ADR-0022).") + if pscale not in scale_prefix: + raise PybnfError(f"Parameter '{pid}': parameter_scale must be 'linear', 'log10', or " + f"'ln', got '{pscale}'.") + prefix = scale_prefix[pscale] + + lower = _num('lower') if 'lower' in fields else None + upper = _num('upper') if 'upper' in fields else None + # Bounds come as a pair: an open side is an explicit +-inf, never a blank + # (ADR-0047 -- no specification by absence). Omitting *both* is the untruncated + # shorthand. One-sided truncation IS supported now -- spell the open side with + # an infinity. The graded floor rule (positivity, support floor) is applied per + # path below: finite for a uniform box, the family floor for a truncated prior. + if (lower is None) != (upper is None): + present, absent = ('lower', 'upper') if upper is None else ('upper', 'lower') + raise PybnfError( + f"Parameter '{pid}': bounds come as a pair -- '{present}' is set but " + f"'{absent}' is missing. For an open {absent} side write an explicit " + f"infinity ('{absent}: inf' or '{absent}: -inf'), not a blank (ADR-0047).") + initial_value = _num('initial_value') if 'initial_value' in fields else None + is_log_scale = prefix in ('log', 'ln') + + if prior_name is None: + if lower is not None: + # No prior but bounds -> uniform over the bounds (PEtab's default for an + # estimated parameter without an explicit prior; the importer does the same). + _require_finite_box(pid, lower, upper, is_log_scale, "a uniform box") + keyword = f'{prefix}uniform_var' + _reject_extra_fields(pid, fields, keyword) + return FreeParameter(pid, keyword, lower, upper, value=initial_value, bounded=True, + initialization_distribution=initialization_distribution) + # No prior and no bounds -> the no-prior start point (legacy var/logvar/lnvar). Its + # start value is carried in the FreeParameter's first slot *in sampling space* + # (Simplex reads it via from_sampling_space(p1)), so map the theta-space + # initial_value through the scale -- making initial_value the real value (theta) for + # a log start point too, consistent with the prior-param case. + if initial_value is None: + raise PybnfError(f"Parameter '{pid}': declares no prior, no bounds, and no " + f"initial_value -- nothing to fit. Give it a 'prior:', a " + f"'lower:'/'upper:' box, or an 'initial_value:'.") + if is_log_scale and initial_value <= 0.0: + raise PybnfError(f"Parameter '{pid}': a {pscale} start point needs " + f"initial_value > 0, got {initial_value}.") + _reject_extra_fields(pid, fields, 'a no-prior start point') + _, start_scale = PRIOR_KEYWORD_MAP[f'{prefix}var'] + return FreeParameter(pid, f'{prefix}var', float(start_scale.forward(initial_value)), None, + initialization_distribution=initialization_distribution) + + prior_name = str(prior_name).lower() + if prior_name == 'uniform': + # Uniform: lower/upper ARE the support (and the bounds); no separate family fields. + if lower is None: + raise PybnfError(f"Parameter '{pid}': a uniform prior needs 'lower' and 'upper'.") + _require_finite_box(pid, lower, upper, is_log_scale, "a uniform prior") + keyword = f'{prefix}uniform_var' + _reject_extra_fields(pid, fields, keyword) + return FreeParameter(pid, keyword, lower, upper, value=initial_value, bounded=True, + initialization_distribution=initialization_distribution) + + keyword = f'{prefix}{prior_name}_var' + if keyword not in PRIOR_KEYWORD_MAP: + raise PybnfError(f"Parameter '{pid}': unknown prior family '{prior_name}'.") + fam, _scale = PRIOR_KEYWORD_MAP[keyword] + params = [] + for fname in fam.field_names: + if fname not in fields: + raise PybnfError(f"Parameter '{pid}': prior '{prior_name}' needs field '{fname}'.") + params.append(_num(fname)) + _reject_extra_fields(pid, fields, f"prior '{prior_name}'") + p1 = params[0] + p2 = params[1] if len(fam.field_names) >= 2 else None + # A three-parameter family (student_t, ADR-0057) carries its third value in p3; + # field_names ordered it last (df/location/scale -> p1/p2/p3). The carrier and + # build_prior pass it through; it is None for the one- and two-parameter families. + p3 = params[2] if len(fam.field_names) >= 3 else None + # lower/upper truncate an unbounded-support family to a reflecting box: two finite + # walls (two-sided, ADR-0020) or one finite wall + an infinity (half-bounded, + # ADR-0047). The graded floor rule warns/errors on a sub-floor lower bound first. + lower, upper = _graded_truncation_bounds(pid, lower, upper, fam, _scale) + return FreeParameter(pid, keyword, p1, p2, lb=lower, ub=upper, value=initial_value, + initialization_distribution=initialization_distribution, p3=p3) + +def _require_finite_box(pid, lower, upper, is_log, where): + """A Uniform family's bounds ARE its support, so they must be finite -- an + infinite bound describes an unbounded prior's open tail, not a box. A log + scale additionally needs a strictly positive lower bound (ADR-0047).""" + for label, v in (('lower', lower), ('upper', upper)): + if v is None or not np.isfinite(v): + raise PybnfError( + f"Parameter '{pid}': {where} needs a finite '{label}' bound " + f"(got {v}); an infinite bound describes an unbounded prior's open " + f"tail, not a uniform box.") + if is_log and lower <= 0.0: + raise PybnfError( + f"Parameter '{pid}': {where} on a log scale needs 'lower' > 0 " + f"(log of <= 0 is -inf), got lower={lower}.") + +def _graded_truncation_bounds(pid, lower, upper, fam, scale): + """Apply the ADR-0047 graded sentinel/floor rule to a truncated family's bounds. + + ``lower``/``upper`` are in theta, already validated to be both-set or both-None + (the pairing rule). Omit-both passes through as the untruncated shorthand. On a + positive-support family -- whose theta floor, derived from the family's natural + support and the scale, is finite (0 for the linear half-bounded families; + 0 for any log form; the doubly-unbounded families floor at -inf and are exempt) -- + a sloppy-but-lossless ``lower: -inf`` is warned and canonicalized to the floor, + and a *finite* ``lower`` below the floor (a wall in the zero-density region, a + likely wrong family/scale) is an error. These families are all unbounded above, + so the upper side needs no floor. Returns the (possibly canonicalized) bounds.""" + if lower is None: + return lower, upper + floor = scale.inverse(fam.support_lo_u) # theta-space support floor + if np.isfinite(floor): + if lower == -np.inf: + logger.warning( + f"Parameter '{pid}': 'lower: -inf' on a prior whose support floor " + f"is {floor:g} -- interpreting as open below at the floor. Write " + f"'lower: {floor:g}' to silence this (ADR-0047).") + lower = floor + elif lower < floor: + raise PybnfError( + f"Parameter '{pid}': 'lower: {lower:g}' is below the prior's support " + f"floor {floor:g} -- a finite wall in the zero-density region (likely " + f"a wrong family or scale). Use 'lower: {floor:g}' for an open lower " + f"side, or a value >= {floor:g} (ADR-0047).") + return lower, upper + +def _reject_extra_fields(pid, leftover, where): + """Raise a clear error if a ``parameter:`` record carries fields unknown to ``where`` + (a typo or a field from a different family) -- naming every part means an unrecognised + name is an error, not a silently-ignored token.""" + if leftover: + unknown = ', '.join(sorted(leftover)) + raise PybnfError(f"Parameter '{pid}': unknown field(s) for {where}: {unknown}.") diff --git a/pybnf/petab/export.py b/pybnf/petab/export.py index d100a9b0..7d60379d 100644 --- a/pybnf/petab/export.py +++ b/pybnf/petab/export.py @@ -71,9 +71,11 @@ from .. import edition from ..data import Data, observed_mean from ..objective import _OBJECTIVE_DESUGAR +from ..parameter_record import free_parameter_from_record from ..parse import ploop from ..printing import PybnfError -from ..pset import FreeParameter, OutOfBoundsException +from ..priors import PRIOR_KEYWORD_MAP +from ..pset import FreeParameter, INITIALIZATION_PRIOR, OutOfBoundsException from ._bngl import parse_model as parse_bngl_model from ._sbml import parse_model as parse_sbml_model from .conditions import ( @@ -116,9 +118,19 @@ 'laplace': 'laplace'} # Free-parameter declaration keywords (the ``(keyword, name)`` tuple keys ``ploop`` -# emits). Only ``uniform_var`` exports in chunk 1; the rest raise. +# emits). Only ``uniform_var`` exports in chunk 1; the rest raise. The new-era +# ``parameter:`` record is the other declaration spelling and keys on 'parameter' instead +# (ADR-0043); it resolves to one of these same keywords once built (#733). _VAR_DECL = re.compile(r'(_var$|^var$|^logvar$)') +# The keywords carrying no prior at all (``var``/``logvar``/``lnvar``) -- a flat improper +# prior, which is not a PEtab probability family. Derived from the registry rather than +# listed, so a family added there cannot quietly land in the wrong arm of the refusal +# message; ``has_prior`` is a class attribute, so the family class answers it directly. +_NO_PRIOR_KEYWORDS = frozenset( + keyword for keyword, (family, _scale) in PRIOR_KEYWORD_MAP.items() + if not family.has_prior) + # A legacy ``__FREE`` bind-by-id marker. New-era BNGL binds free parameters by id # (ADR-0034), so a model carrying this token was not modernized; the exporter refuses it # rather than ship a ``v1__FREE`` symbol into PEtab (where it would dangle). @@ -1393,11 +1405,20 @@ def _read_conf_dict(conf_path): def _free_parameters_from_conf(conf): - """Build ``FreeParameter`` objects from the config's ``(keyword, name)`` entries. - - Each one carries the fit's declared start point for that parameter, if it has one, on - ``.value`` -- the source :func:`~pybnf.petab.parameters.petab_parameter_row` writes as - the row's ``nominalValue``, closing the export half of the #583 round trip (#719). + """Build ``FreeParameter`` objects from the config's free-parameter declarations. + + Reads **both** spellings, in declaration order (ADR-0043): the legacy positional + ``_var = p1 [p2]`` line and the new-era ``parameter:`` record. Only the + first was read until #733, so an edition-2 record was skipped rather than refused and + the whole free parameter vanished from the exported problem with no diagnostic -- + including the truncated priors the *importer* emits as records, the one grammar + carrying ``lower``/``upper``. + + Each parameter carries the fit's declared start point, if it has one, on ``.value`` -- + the source :func:`~pybnf.petab.parameters.petab_parameter_row` writes as the row's + ``nominalValue`` (#719). Both spellings of a start point are honoured here, the way + ``Configuration._load_start_point`` merges them: a record's ``initial_value:`` field + and a ``start_point =`` line beside it. """ start_points = _start_points_from_conf(conf) free_params = [] @@ -1406,40 +1427,17 @@ def _free_parameters_from_conf(conf): and isinstance(key[0], str) and isinstance(key[1], str)): continue keyword, name = key - if not _VAR_DECL.search(keyword): + if keyword == 'parameter': + free_param = _free_parameter_from_conf_record(name, value) + elif _VAR_DECL.search(keyword): + free_param = _free_parameter_from_var_line(name, keyword, value) + else: continue - if keyword not in EXPORTABLE_PRIOR_KEYWORDS: - raise NotImplementedError( - f"Free parameter '{name}' is a '{keyword}'; the exporter writes the " - f"PEtab prior families {sorted(EXPORTABLE_PRIOR_KEYWORDS)}. The " - f"no-prior 'var'/'logvar' point-start keywords have no PEtab prior " - f"representation (a flat improper prior is not a PEtab probability " - f"family; ADR-0025, #423).") - # p1/p2 are the family's governing values (bounds for the Uniform families, - # loc/scale or shape/scale for the two-parameter location families); a 3rd token - # is the native ``bounded`` flag, inert for the location families. A one-parameter - # unbounded family (exponential/chisquare/rayleigh, #417) carries only p1. - p2 = float(value[1]) if len(value) >= 2 else None - start = start_points.pop(name, None) - try: - free_params.append( - FreeParameter(name, keyword, float(value[0]), p2, value=start)) - except OutOfBoundsException: - # A start point outside the parameter's own box. PEtab cannot express one - # either (a nominalValue outside lowerBound/upperBound is what the importer - # refuses on the way back in), and the bare OutOfBoundsException subclasses - # Exception, so pybnf.main would report it as "an unknown error ... please - # report this bug" on a config the user wrote (#583). - raise PybnfError( - f"start point out of bounds for '{name}'", - f"The config starts '{name}' at {start}, which is outside the box " - f"[{value[0]}, {p2}] its own declaration gives it. A start point is " - f"refused rather than moved.", - "Correct the start point, or widen the parameter's bounds.") + free_params.append(_with_start_point(free_param, start_points)) if not free_params: raise PybnfError( - "No exportable free parameters found in the config (expected one of " - f"{sorted(EXPORTABLE_PRIOR_KEYWORDS)}).") + "No exportable free parameters found in the config (expected a 'parameter:' " + f"record or one of {sorted(EXPORTABLE_PRIOR_KEYWORDS)}).") if start_points: # A start point for a name no exportable free parameter claims. Silently dropping # it is the failure this whole path exists to remove, and config.py refuses the @@ -1454,13 +1452,113 @@ def _free_parameters_from_conf(conf): return free_params +def _free_parameter_from_var_line(name, keyword, value): + """The legacy positional ``_var = p1 [p2] [b|u]`` declaration.""" + _require_exportable_prior(name, keyword) + # p1/p2 are the family's governing values (bounds for the Uniform families, + # loc/scale or shape/scale for the two-parameter location families); a 3rd token + # is the native ``bounded`` flag, inert for the location families. A one-parameter + # unbounded family (exponential/chisquare/rayleigh, #417) carries only p1. + p2 = float(value[1]) if len(value) >= 2 else None + return FreeParameter(name, keyword, float(value[0]), p2) + + +def _free_parameter_from_conf_record(name, fields): + """A new-era ``parameter:`` record (ADR-0043) -> its ``FreeParameter`` (#733). + + Built through :func:`~pybnf.parameter_record.free_parameter_from_record`, the same + mapping the fitter loads a job with, so the exported row describes the parameter the + fit would actually search rather than a second reading of the grammar. The record + resolves to one of the ordinary ``*_var`` keywords, so the exportability gate below is + the one the positional line goes through -- a record's boundaries are the same + boundaries, reached by a different spelling. + + ``initialization_distribution`` is fixed at ``prior`` rather than read from the config: + it selects where an algorithm draws its start points, which is run recipe rather than + problem, has no home in a PEtab table, and is what the positional line above defaults + to. + """ + try: + free_param = free_parameter_from_record(name, fields, INITIALIZATION_PRIOR) + except OutOfBoundsException: + # An out-of-box initial_value, as at the Configuration loader's own call site: the + # bare OutOfBoundsException subclasses Exception, so pybnf.main reports it as "an + # unknown error ... please report this bug" on a config the user wrote (#583). + raise PybnfError( + f"start point out of bounds for '{name}'", + f"Parameter '{name}' declares an initial_value outside its own lower/upper " + f"bounds. A start point is refused rather than moved into the box.", + "Correct the initial_value, or widen the parameter's bounds.") + _require_exportable_prior(name, free_param.type) + return free_param + + +def _require_exportable_prior(name, keyword): + """Refuse a free parameter whose prior family PEtab v2 cannot state (ADR-0025, #423). + + Keyed on the ``*_var`` keyword, which both declaration spellings resolve to, so a + ``parameter:`` record hits the identical boundary as the positional line that builds + the same parameter. + """ + if keyword in EXPORTABLE_PRIOR_KEYWORDS: + return + if keyword in _NO_PRIOR_KEYWORDS: + raise NotImplementedError( + f"Free parameter '{name}' is a no-prior point start (a '{keyword}' line, or a " + f"'parameter:' record with an 'initial_value:' but no 'prior:' and no " + f"'lower:'/'upper:' box). A flat improper prior is not a PEtab probability " + f"family, and a PEtab estimated parameter needs bounds or a prior. Give it a " + f"prior or a box; the exporter writes {sorted(EXPORTABLE_PRIOR_KEYWORDS)} " + f"(ADR-0025, #423).") + raise NotImplementedError( + f"Free parameter '{name}' is a '{keyword}'; the exporter writes the PEtab prior " + f"families {sorted(EXPORTABLE_PRIOR_KEYWORDS)}, and PEtab v2 has no " + f"priorDistribution spelling for this one -- it defines no log- form for " + f"cauchy/gamma/exponential/chisquare/rayleigh, no natural-log ('ln') sampling " + f"scale, and no three-parameter family such as student_t (ADR-0025, #423).") + + +def _with_start_point(free_param, start_points): + """Attach this parameter's ``start_point =`` line, if the config declares one (#719). + + The merge rule is ``Configuration._load_start_point``'s (ADR-0117): a ``parameter:`` + record's ``initial_value:`` and a ``start_point`` line are two spellings of one fact, + so they may both be present when they agree and are refused when they disagree -- + silently preferring one would reintroduce the class of failure the start-point work + exists to remove. + """ + if free_param.name not in start_points: + return free_param + start = start_points.pop(free_param.name) + if free_param.value is not None: + if free_param.value != start: + raise PybnfError( + f"contradictory start point for '{free_param.name}'", + f"Parameter '{free_param.name}' is given two different start points: " + f"initial_value: {free_param.value} on its 'parameter:' record, and " + f"'start_point = {free_param.name} {start}'. Delete one of them.") + return free_param + try: + return free_param.set_value(start, reflect=False) + except OutOfBoundsException: + # A start point outside the parameter's own box. PEtab cannot express one either + # (a nominalValue outside lowerBound/upperBound is what the importer refuses on + # the way back in), and the bare OutOfBoundsException would reach the user as "an + # unknown error ... please report this bug" (#583). + raise PybnfError( + f"start point out of bounds for '{free_param.name}'", + f"The config starts '{free_param.name}' at {start}, which is outside the box " + f"[{free_param.lower_bound}, {free_param.upper_bound}] its own declaration " + f"gives it. A start point is refused rather than moved.", + "Correct the start point, or widen the parameter's bounds.") + + def _start_points_from_conf(conf): """``{name: theta}`` for every ``start_point = `` line (#583). - The ``start_point`` half of the two spellings ``Configuration._load_start_point`` - merges. The other, a ``parameter:`` record's ``initial_value:`` field, is not read - here because the exporter does not build free parameters from ``parameter:`` records - at all -- a separate gap (#733), not a second place to populate. + One of the two spellings ``Configuration._load_start_point`` merges; the other, a + ``parameter:`` record's ``initial_value:`` field, arrives on the built + ``FreeParameter.value`` and is reconciled with this one in :func:`_with_start_point`. """ return {key[1]: float(value) for key, value in conf.items() if isinstance(key, tuple) and len(key) == 2 and key[0] == 'start_point'} diff --git a/tests/test_petab_export.py b/tests/test_petab_export.py index da3ab2fd..5c12861a 100644 --- a/tests/test_petab_export.py +++ b/tests/test_petab_export.py @@ -75,6 +75,11 @@ MULTISIGMA_DIR = FIXTURE_DIR / 'multisigma_v2' PREDSIGMA_DIR = FIXTURE_DIR / 'predsigma_v2' +# The tutorial's own PEtab priors problem (lesson 15): three parameters whose finite bounds +# TRUNCATE an unbounded family, which the importer can only write as `parameter:` records -- +# the shape the exporter used to drop on the floor (#733). +PRIORS_DIR = Path(__file__).resolve().parents[1] / 'examples' / 'tutorial' / '15_petab_priors' + def _tsv_rows(path): """Read a TSV into a list of dict rows (a tiny stdlib reader for assertions).""" @@ -772,13 +777,22 @@ def test_prediction_dependent_noise_exports_verbatim_formula(self, tmp_path): # nominalValue column -- so a round trip silently moved the fit back to a sampled draw. # --------------------------------------------------------------------------- -def _demo_job(tmp_path, extra=''): - """A copy of the demo job whose conf has ``extra`` appended (a start_point line).""" +def _demo_job(tmp_path, extra='', replace=None): + """A copy of the demo job whose conf has ``extra`` appended (a start_point line). + + ``replace`` is an optional ``(old, new)`` pair applied to the conf text first, used to + swap a positional ``uniform_var`` line for the ``parameter:`` record spelling of it. + """ import shutil job = tmp_path / 'job' shutil.copytree(DEMO_DIR, job) conf = job / DEMO_CONF.name - conf.write_text(conf.read_text() + extra) + text = conf.read_text() + if replace is not None: + old_line, new_line = replace + assert old_line in text # a silent no-op would hollow out the test + text = text.replace(old_line, new_line) + conf.write_text(text + extra) return conf @@ -909,6 +923,174 @@ def test_reimported_conf_starts_the_fit_at_the_published_point(self, tmp_path, m assert cfg.start_point == {'v1': 0.5, 'v2': 1.0, 'v3': 3.0} + +# --------------------------------------------------------------------------- +# The edition-2 `parameter:` record is a free-parameter declaration the exporter reads +# (#733). It used to match no branch of the `(keyword, name)` filter, so it was skipped +# rather than refused and the whole parameter vanished from the exported problem -- taking +# every TRUNCATED prior with it, since a record is the only grammar carrying lower/upper +# and is exactly what the importer emits for one (ADR-0020/0043/0047). +# --------------------------------------------------------------------------- + +_V1_VAR = 'uniform_var = v1 0 10' + + +class TestExportParameterRecord: + + def test_record_exports_the_same_row_as_the_positional_line(self, tmp_path): + # The two-adapter proof on the declaration grammars: one parameter written both + # ways must reach PEtab as the same row. Before #733 the record's row was simply + # absent from the table, with no warning and no exception. + export_job(DEMO_CONF, tmp_path / 'positional') + export_job(_demo_job(tmp_path, replace=( + _V1_VAR, 'parameter: v1, prior: uniform, lower: 0, upper: 10')), + tmp_path / 'record') + assert (tmp_path / 'record' / 'parameters.tsv').read_text() == \ + (tmp_path / 'positional' / 'parameters.tsv').read_text() + + def test_record_only_conf_exports_every_parameter(self, tmp_path): + # A conf written entirely on the edition-2 surface used to export nothing at all: + # every record was skipped, so `free_params` came back empty and the user got + # "No exportable free parameters found", naming only the *_var keywords -- pointing + # away from the syntax the edition-2 docs teach. + conf = _demo_job(tmp_path, replace=( + 'uniform_var = v1 0 10\nuniform_var = v2 0 10\nuniform_var = v3 0 10', + 'parameter: v1, prior: uniform, lower: 0, upper: 10\n' + 'parameter: v2, prior: uniform, lower: 0, upper: 10\n' + 'parameter: v3, prior: uniform, lower: 0, upper: 10')) + export_job(conf, tmp_path / 'out') + rows = _tsv_rows(tmp_path / 'out' / 'parameters.tsv') + assert {r['parameterId'] for r in rows} == {'v1', 'v2', 'v3'} + assert all((r['lowerBound'], r['upperBound']) == ('0', '10') for r in rows) + + def test_records_and_var_lines_keep_declaration_order(self, tmp_path): + # The two spellings may be mixed, and the table follows the conf's own order -- + # the record is read in the same pass, not appended after. + conf = _demo_job(tmp_path, replace=( + 'uniform_var = v2 0 10', + 'parameter: v2, prior: uniform, lower: 0, upper: 10')) + export_job(conf, tmp_path / 'out') + assert [r['parameterId'] for r in _tsv_rows(tmp_path / 'out' / 'parameters.tsv')] == \ + ['v1', 'v2', 'v3'] + + # -- truncated priors, the shape only a record can carry ------------------------- + + def test_truncated_priors_survive_import_export_reimport(self, tmp_path): + # The tutorial's own PEtab priors problem: kon (log-normal), koff (gamma) and R0 + # (normal) all carry finite bounds that TRUNCATE an unbounded family, so the + # importer writes each as a `parameter:` record. Before #733 the export kept only + # L0 -- the one plain uniform_var -- and silently dropped the other three, which is + # three quarters of a published problem. + from pybnf.petab.import_ import import_job + imp1, pet2, imp2 = tmp_path / 'imp1', tmp_path / 'pet2', tmp_path / 'imp2' + import_job(PRIORS_DIR / 'problem.yaml', imp1) + export_job(imp1 / 'imported.conf', pet2) + import_job(pet2 / 'problem.yaml', imp2) + + rows = {r['parameterId']: r for r in _tsv_rows(pet2 / 'parameters.tsv')} + assert set(rows) == {'kon', 'koff', 'R0', 'L0'} + assert rows['R0']['priorDistribution'] == 'normal' + assert (rows['R0']['lowerBound'], rows['R0']['upperBound']) == ('1', '100') + assert rows['koff']['priorDistribution'] == 'gamma' + assert rows['L0']['priorDistribution'] == '' # a plain box needs no prior + + # and the declarations come back identical, record grammar included + def declarations(conf): + return [line.strip() for line in conf.read_text().splitlines() + if line.startswith(('parameter:', 'uniform_var', 'loguniform_var'))] + assert declarations(imp2 / 'imported.conf') == declarations(imp1 / 'imported.conf') + assert sum(d.startswith('parameter:') for d in declarations(imp1 / 'imported.conf')) == 3 + + def test_reexported_truncated_prior_problem_is_petab_valid(self, tmp_path): + # The external oracle on a shape the exporter could not previously emit at all. + pytest.importorskip('petab.v2') + from pybnf.petab.import_ import import_job + import_job(PRIORS_DIR / 'problem.yaml', tmp_path / 'imp1') + export_job(tmp_path / 'imp1' / 'imported.conf', tmp_path / 'pet2') + # the count first: a table that dropped three of the four parameters is still + # perfectly valid PEtab, so validity alone is not the guard here + assert len(_tsv_rows(tmp_path / 'pet2' / 'parameters.tsv')) == 4 + assert _petab_validation_errors(tmp_path / 'pet2' / 'problem.yaml') == [] + + def test_half_bounded_truncation_writes_an_explicit_infinity(self, tmp_path): + # ADR-0047: one finite wall and an open side, the ub->inf limit of the two-sided + # fold. It reaches PEtab as a finite lowerBound and an infinite upperBound. + export_job(_demo_job(tmp_path, replace=( + _V1_VAR, 'parameter: v1, prior: normal, mean: 1, sd: 2, lower: 0, upper: inf')), + tmp_path / 'out') + row = {r['parameterId']: r for r in _tsv_rows(tmp_path / 'out' / 'parameters.tsv')}['v1'] + assert (row['lowerBound'], row['upperBound']) == ('0', 'inf') + assert (row['priorDistribution'], row['priorParameters']) == ('normal', '1;2') + + # -- the start point, through the record spelling (#719) -------------------------- + + def test_record_initial_value_becomes_a_nominal_value(self, tmp_path): + # initial_value: is the record's spelling of the start point, so it lands in the + # same nominalValue cell a `start_point` line does. + export_job(_demo_job(tmp_path, replace=( + _V1_VAR, + 'parameter: v1, prior: uniform, lower: 0, upper: 10, initial_value: 0.7')), + tmp_path / 'out') + nominal = {r['parameterId']: r['nominalValue'] + for r in _tsv_rows(tmp_path / 'out' / 'parameters.tsv')} + assert nominal == {'v1': '0.7', 'v2': '', 'v3': ''} + + def test_agreeing_start_point_spellings_are_accepted(self, tmp_path): + # Both spellings for one parameter is fine when they say the same thing -- a record + # naming its own start plus a start_point line restating it (ADR-0117). + export_job(_demo_job( + tmp_path, extra='\nstart_point = v1 0.7\n', replace=( + _V1_VAR, + 'parameter: v1, prior: uniform, lower: 0, upper: 10, initial_value: 0.7')), + tmp_path / 'out') + row = {r['parameterId']: r for r in _tsv_rows(tmp_path / 'out' / 'parameters.tsv')} + assert row['v1']['nominalValue'] == '0.7' + + def test_contradictory_start_point_spellings_are_refused(self, tmp_path): + # Disagreement has no defensible winner, so it is refused rather than resolved -- + # the rule Configuration._load_start_point applies when the job is run. + conf = _demo_job(tmp_path, extra='\nstart_point = v1 0.9\n', replace=( + _V1_VAR, + 'parameter: v1, prior: uniform, lower: 0, upper: 10, initial_value: 0.7')) + with pytest.raises(PybnfError, match='contradictory start point'): + export_job(conf, tmp_path / 'out') + + def test_out_of_box_initial_value_is_refused(self, tmp_path): + # As a PybnfError, not the bare OutOfBoundsException the FreeParameter constructor + # raises -- which pybnf.main reports as "an unknown error ... please report this + # bug" on a config the user wrote (#583). + conf = _demo_job(tmp_path, replace=( + _V1_VAR, + 'parameter: v1, prior: uniform, lower: 0, upper: 10, initial_value: 42')) + with pytest.raises(PybnfError, match='out of bounds'): + export_job(conf, tmp_path / 'out') + + # -- the boundaries a record reaches by a different spelling ---------------------- + + def test_no_prior_record_is_refused(self, tmp_path): + # A record with no prior and no box is the edition-2 spelling of `var` -- a flat + # improper prior, which is not a PEtab probability family. Refused in code, which + # is the exporter's contract for everything it cannot write. + conf = _demo_job(tmp_path, replace=(_V1_VAR, 'parameter: v1, initial_value: 3')) + with pytest.raises(NotImplementedError, match='no-prior point start'): + export_job(conf, tmp_path / 'out') + + @pytest.mark.parametrize('record,built', [ + ('parameter: v1, prior: normal, parameter_scale: ln, mean: 0, sd: 1', + 'lnnormal_var'), # PEtab has no natural-log sampling scale + ('parameter: v1, prior: student_t, df: 3, location: 1, scale: 2', + 'student_t_var'), # no three-parameter PEtab family + ('parameter: v1, prior: cauchy, parameter_scale: log10, location: 0, scale: 1', + 'logcauchy_var'), # PEtab defines no log- form for cauchy + ]) + def test_petab_inexpressible_record_prior_is_refused(self, tmp_path, record, built): + # A record resolves to an ordinary *_var keyword, so it meets the same boundary the + # positional line does -- and the message names the keyword it actually built. + conf = _demo_job(tmp_path, replace=(_V1_VAR, record)) + with pytest.raises(NotImplementedError, match=built): + export_job(conf, tmp_path / 'out') + + # --------------------------------------------------------------------------- # Chunk 5a: the exporter reads the new-era data surface (model: / experiment: / data: / # observable:) as transcription, not the legacy linkage (ADR-0028). The demo twin