|
| 1 | +"""Regression tests for the width-modifier safety pair (PyAutoFit #1346, |
| 2 | +Phase 2 of decision hub #1331 — Decisions 5 + 2). Numpy-only. |
| 3 | +""" |
| 4 | +import pytest |
| 5 | + |
| 6 | +import autofit as af |
| 7 | +from autofit import exc |
| 8 | +from autofit.mapper.prior.width_modifier import ( |
| 9 | + RelativeWidthModifier, |
| 10 | + WidthModifier, |
| 11 | +) |
| 12 | +from autofit.messages.normal import NormalMessage |
| 13 | +from autofit.messages.truncated_normal import TruncatedNormalMessage |
| 14 | + |
| 15 | + |
| 16 | +# --- Decision 5: RelativeWidthModifier uses abs(mean), optional absolute_floor --- |
| 17 | + |
| 18 | +def test__relative_width_modifier_abs_mean(): |
| 19 | + mod = RelativeWidthModifier(0.5) |
| 20 | + # A negative posterior median previously produced a negative sigma that |
| 21 | + # flowed silently into the passed prior and flipped its scale. |
| 22 | + assert mod(-2.0) == 1.0 |
| 23 | + assert mod(2.0) == 1.0 |
| 24 | + # The bare modifier still returns 0.0 at mean=0; prior passing rejects it |
| 25 | + # loudly downstream (see the chained tests below). |
| 26 | + assert mod(0.0) == 0.0 |
| 27 | + |
| 28 | + |
| 29 | +def test__relative_width_modifier_floor(): |
| 30 | + mod = RelativeWidthModifier(0.5, absolute_floor=0.1) |
| 31 | + assert mod(0.0) == 0.1 # floor engages at zero median |
| 32 | + assert mod(0.1) == 0.1 # 0.5 * 0.1 = 0.05 < floor |
| 33 | + assert mod(10.0) == 5.0 # a floor, not a cap |
| 34 | + |
| 35 | + |
| 36 | +def test__relative_width_modifier_dict_round_trip(): |
| 37 | + mod = RelativeWidthModifier(0.5, absolute_floor=0.1) |
| 38 | + assert mod.dict == {"type": "Relative", "value": 0.5, "absolute_floor": 0.1} |
| 39 | + assert WidthModifier.from_dict(mod.dict) == mod |
| 40 | + |
| 41 | + bare = RelativeWidthModifier(0.5) |
| 42 | + assert bare.dict == {"type": "Relative", "value": 0.5} |
| 43 | + assert WidthModifier.from_dict(bare.dict) == bare |
| 44 | + assert bare != mod |
| 45 | + |
| 46 | + |
| 47 | +# --- Decision 2 (evidence-adjusted): both message classes now agree — sigma < 0 |
| 48 | +# rejected, sigma == 0 permitted as the established point-mass idiom (latent |
| 49 | +# variables' simple_model_for_kwargs, from_mode(covariance=0), |
| 50 | +# model_centred_relative at mean=0 all depend on it) --- |
| 51 | + |
| 52 | +def test__normal_message_rejects_negative_sigma(): |
| 53 | + with pytest.raises(exc.MessageException): |
| 54 | + NormalMessage(mean=0.0, sigma=-1.0) |
| 55 | + |
| 56 | + |
| 57 | +def test__truncated_normal_message_rejects_negative_sigma(): |
| 58 | + with pytest.raises(exc.MessageException): |
| 59 | + TruncatedNormalMessage( |
| 60 | + mean=0.0, sigma=-1.0, lower_limit=-1.0, upper_limit=1.0 |
| 61 | + ) |
| 62 | + |
| 63 | + |
| 64 | +def test__sigma_zero_point_mass_still_constructs(): |
| 65 | + # The point-mass carrier used by the latent-variables machinery |
| 66 | + # (non_linear/samples/util.py) and from_mode(covariance=0) must keep working. |
| 67 | + m = NormalMessage(mean=3.0, sigma=0.0) |
| 68 | + assert m.sigma == 0.0 |
| 69 | + p = af.GaussianPrior(mean=3.0, sigma=0.0) |
| 70 | + assert p.sigma == 0.0 |
| 71 | + |
| 72 | + |
| 73 | +def test__gaussian_prior_rejects_negative_sigma(): |
| 74 | + # Previously constructed silently with deceptive variance = sigma**2 > 0 |
| 75 | + # and a sign-flipped value_for. |
| 76 | + with pytest.raises(exc.MessageException): |
| 77 | + af.GaussianPrior(mean=0.0, sigma=-0.5) |
| 78 | + |
| 79 | + |
| 80 | +# --- The mean=0 chained-parameter regression (the Phase-2 sequencing gate: |
| 81 | +# yesterday's silent delta-freeze must become a clear, parameter-named error, |
| 82 | +# and the floor must be the working remedy) --- |
| 83 | + |
| 84 | +def _mapper_with_relative_widths(absolute_floor=None): |
| 85 | + mapper = af.ModelMapper(mock_class=af.m.MockClassx2) |
| 86 | + for prior in mapper.priors: |
| 87 | + prior.width_modifier = RelativeWidthModifier( |
| 88 | + 0.5, absolute_floor=absolute_floor |
| 89 | + ) |
| 90 | + return mapper |
| 91 | + |
| 92 | + |
| 93 | +def test__prior_passing_mean_zero_raises_with_guidance(): |
| 94 | + mapper = _mapper_with_relative_widths() |
| 95 | + with pytest.raises(exc.PriorException) as err: |
| 96 | + mapper.mapper_from_prior_means([0.0, 5.0]) |
| 97 | + # The error must name the parameter and point at the remedy. |
| 98 | + assert "mock_class" in str(err.value) |
| 99 | + assert "absolute_floor" in str(err.value) |
| 100 | + |
| 101 | + |
| 102 | +def test__prior_passing_mean_zero_with_floor_passes(): |
| 103 | + mapper = _mapper_with_relative_widths(absolute_floor=0.1) |
| 104 | + result = mapper.mapper_from_prior_means([0.0, 5.0]) |
| 105 | + assert result.mock_class.one.mean == 0.0 |
| 106 | + assert result.mock_class.one.sigma == 0.1 # floor engaged |
| 107 | + assert result.mock_class.two.sigma == 2.5 # 0.5 * 5.0, floor irrelevant |
| 108 | + |
| 109 | + |
| 110 | +def test__prior_passing_negative_mean_gets_positive_width(): |
| 111 | + mapper = _mapper_with_relative_widths() |
| 112 | + result = mapper.mapper_from_prior_means([-2.0, 5.0]) |
| 113 | + assert result.mock_class.one.mean == -2.0 |
| 114 | + assert result.mock_class.one.sigma == 1.0 # 0.5 * abs(-2.0) |
0 commit comments