diff --git a/src/maple/core/calibration/calibration_target_models.py b/src/maple/core/calibration/calibration_target_models.py index 48954fe..f7a88db 100644 --- a/src/maple/core/calibration/calibration_target_models.py +++ b/src/maple/core/calibration/calibration_target_models.py @@ -53,6 +53,7 @@ ) from maple.core.calibration.scenario import Scenario from maple.core.calibration.shared_models import ( + DistributionShape, EstimateInput, InputType, ModelingAssumption, @@ -339,6 +340,34 @@ def validate_observed_distribution_consistency(self) -> "CalibrationTargetEstima ) return self + @model_validator(mode="after") + def validate_bounded_observable_uses_logit_normal(self) -> "CalibrationTargetEstimates": + """A bounded observable's population spread (``moments`` form) must use + ``shape: logit_normal``, not normal/lognormal. + + Parity with the SubmodelTarget validator of the same name: for a + fraction / proportion / probability / percent observable, ``normal`` puts + mass outside the bound and ``lognormal`` is unbounded above; ``logit_normal`` + keeps expanded quartiles in (0, 1). Only applies to the ``moments`` form — + the ``quantiles`` form carries the empirical shape directly and is exempt. + """ + od = self.observed_distribution + if od is None or od.moments is None: + return self + if od.moments.shape == DistributionShape.LOGIT_NORMAL: + return self + BOUNDED_UNITS = {"percent", "%", "fraction", "proportion", "probability"} + if (self.units or "").strip().lower() not in BOUNDED_UNITS: + return self + raise ValueError( + f"Observable units='{self.units}' are a bounded fraction/percentage, but " + f"observed_distribution.moments uses shape='{od.moments.shape.value}'. " + "Bounded observables must use shape='logit_normal', which expands quartiles " + "in logit space so they never escape (0, 1); normal puts mass outside the " + "bound and lognormal is unbounded above. logit_normal requires center in " + "(0, 1) with center_type='median' — express a percent as a fraction (12% -> 0.12)." + ) + @field_validator("sample_size") @classmethod def validate_sample_size_positive(cls, v: int) -> int: diff --git a/src/maple/prompts/calibration_target_prompt.md b/src/maple/prompts/calibration_target_prompt.md index 20d0f20..1a9a11f 100644 --- a/src/maple/prompts/calibration_target_prompt.md +++ b/src/maple/prompts/calibration_target_prompt.md @@ -215,7 +215,7 @@ Use the `quantiles` form when the paper gives quartiles/percentiles/samples dire experimental_unit_type: biological ``` -Provide EXACTLY ONE of `moments` / `quantiles`. A population spread (`spread_source: across_patient`) REQUIRES `n_biological` + `experimental_unit_type: biological`. When both `observed_distribution` and `population_spread` are present they must agree that the width is (or is not) genuine spread — a validator enforces this. For a `[0,1]`-bounded observable (a fraction / proportion / response rate), use `shape: logit_normal` (with `center_type: median`) so expanded quartiles stay in-bounds. If a target ever carries several observables measured on the SAME patients/units and you want them treated as one shared spread, tag them with a common `unit_group` string (rare for full-model calibration targets, which are usually one observable each — omit it otherwise). +Provide EXACTLY ONE of `moments` / `quantiles`. A population spread (`spread_source: across_patient`) REQUIRES `n_biological` + `experimental_unit_type: biological`. When both `observed_distribution` and `population_spread` are present they must agree that the width is (or is not) genuine spread — a validator enforces this. For a `[0,1]`-bounded observable (a fraction / proportion / response rate), use `shape: logit_normal` (with `center_type: median`) so expanded quartiles stay in-bounds — **a validator rejects a `percent`/`fraction`/`proportion`/`probability` observable whose `moments` shape is `normal` or `lognormal`.** If the source reports the unit count as a LOWER BOUND ("n≥40", "at least 40 patients"), set `n_biological_is_floor: true` (with the floor value for `n_biological`) so precision-weighting consumers don't over-weight the panel. If a target ever carries several observables measured on the SAME patients/units and you want them treated as one shared spread, tag them with a common `unit_group` string (rare for full-model calibration targets, which are usually one observable each — omit it otherwise). ### Source Relevance Assessment diff --git a/tests/unit/core/test_calibration_target_validators.py b/tests/unit/core/test_calibration_target_validators.py index 32c0334..69ebf88 100644 --- a/tests/unit/core/test_calibration_target_validators.py +++ b/tests/unit/core/test_calibration_target_validators.py @@ -1671,3 +1671,59 @@ def test_degenerate_samples_rejected( ) with pytest.raises(ValidationError, match="zero variance"): CalibrationTarget.model_validate(data, context={"model_structure": model_structure}) + + +# ============================================================================ +# Cal-side parity for the submodel bounded->logit_normal validator: a bounded +# observable's moments-form observed_distribution must use shape=logit_normal. +# ============================================================================ + +from maple.core.calibration.calibration_target_models import CalibrationTargetEstimates + + +def _bounded_cal_estimates(shape: str) -> dict: + return { + "median": [0.5], + "ci95": [[0.3, 0.7]], + "units": "percent", + "sample_size": 40, + "sample_size_rationale": "n=40 patients, Table 1", + "inputs": [ + { + "name": "resp_fraction", + "value": 0.5, + "units": "percent", + "description": "objective response fraction", + "source_ref": "smith_2020", + "value_location": "Table 1", + "value_snippet": "response rate 0.5", + } + ], + "distribution_code": ( + "def derive_distribution(inputs, ureg):\n" + " v = inputs['resp_fraction']\n" + " return {'median_obs': v, 'ci95_lower': v * 0.6, 'ci95_upper': v * 1.4}" + ), + "population_spread": "center_only", + "observed_distribution": { + "moments": { + "center": 0.5, + "center_type": "median", + "scale": 0.1, + "scale_type": "sd", + "shape": shape, + }, + "spread_source": "center_only", + }, + } + + +class TestCalBoundedObservableLogitNormal: + """CalibrationTargetEstimates: bounded moments-form observable must use logit_normal.""" + + def test_percent_with_normal_shape_raises(self): + with pytest.raises(ValidationError, match="logit_normal"): + CalibrationTargetEstimates.model_validate(_bounded_cal_estimates("normal")) + + def test_percent_with_logit_normal_passes(self): + CalibrationTargetEstimates.model_validate(_bounded_cal_estimates("logit_normal"))