Skip to content

Commit d8c1ce8

Browse files
committed
test(distributions): singleton test coverage and a new discrete CDF option
1 parent 0823726 commit d8c1ce8

2 files changed

Lines changed: 198 additions & 2 deletions

File tree

tests/unit/distributions/computations/test_discrete.py

Lines changed: 190 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@
1010
1111
Right-unbounded tests use a truncated geometric-like PMF on {0, 1, 2, ...}
1212
with P(X=k) = 0.5^(k+1) for k >= 0.
13+
14+
Right-open CDF convention (right_closed=False) tests verify:
15+
- F⁻(x) = P(ξ < x) excludes mass at x (differs from standard F(x) = P(ξ ≤ x) at support points).
16+
- The identity 1 - F⁻(x) = P(ξ ≥ x), needed for computing the CDF of -ξ.
1317
"""
1418

1519
from __future__ import annotations
@@ -32,6 +36,7 @@
3236
_fit_cdf_to_pmf_1D,
3337
_fit_cdf_to_ppf_1D,
3438
_fit_pmf_to_cdf_1D,
39+
_fit_ppf_to_cdf_1D,
3540
)
3641
from pysatl_core.distributions.support import (
3742
ExplicitTableDiscreteSupport,
@@ -209,7 +214,8 @@ def test_descriptor_metadata(self) -> None:
209214
desc = _build_ppf_to_cdf_1D()
210215
assert desc.target == CharacteristicName.CDF
211216
assert desc.sources == [CharacteristicName.PPF]
212-
assert desc.option_names() == ("n_q_grid",)
217+
# right_closed is a CharacteristicOption; n_q_grid is a ComputationOption
218+
assert desc.option_names() == ("right_closed", "n_q_grid")
213219

214220

215221
def _make_right_unbounded_pmf_distribution() -> StandaloneEuclideanUnivariateDistribution:
@@ -318,3 +324,186 @@ def test_cdf_ppf_roundtrip(self) -> None:
318324
cdf_at_xs = np.asarray(cdf_fitted.func(xs), dtype=float) # type: ignore[call-arg,arg-type,type-var]
319325
# For discrete distributions, CDF(PPF(q)) >= q
320326
assert np.all(cdf_at_xs >= qs - 1e-10) # type: ignore[operator]
327+
328+
329+
# ---------------------------------------------------------------------------
330+
# Right-open CDF convention (right_closed=False)
331+
# ---------------------------------------------------------------------------
332+
# Support {0, 1, 2}, PMF {0: 0.2, 1: 0.5, 2: 0.3}
333+
# Right-closed CDF: F(x) = P(ξ ≤ x): 0 for x<0, 0.2 at x=0, 0.7 at x=1, 1.0 at x=2
334+
# Right-open CDF: F⁻(x) = P(ξ < x): 0 for x≤0, 0.2 for 0<x≤1, 0.7 for 1<x≤2, 1.0 for x>2
335+
#
336+
# Key identity: 1 - F⁻(x) = P(ξ ≥ x), useful for computing CDF of -ξ.
337+
338+
339+
class TestPmfToCdfRightOpen(DistributionTestBase):
340+
"""Tests for _fit_pmf_to_cdf_1D with right_closed=False (right-open convention)."""
341+
342+
def test_right_open_at_support_points(self) -> None:
343+
"""F⁻(x) at support points should exclude the mass at x."""
344+
distr = self.make_discrete_point_pmf_distribution()
345+
fitted = _fit_pmf_to_cdf_1D(distr, right_closed=False)
346+
347+
# F⁻(0) = P(ξ < 0) = 0
348+
assert float(fitted.func(np.float64(0.0))[0]) == pytest.approx(0.0, abs=1e-10) # type: ignore[call-arg,arg-type]
349+
# F⁻(1) = P(ξ < 1) = P(ξ = 0) = 0.2
350+
assert float(fitted.func(np.float64(1.0))[0]) == pytest.approx(0.2, abs=1e-6) # type: ignore[call-arg,arg-type]
351+
# F⁻(2) = P(ξ < 2) = P(ξ = 0) + P(ξ = 1) = 0.7
352+
assert float(fitted.func(np.float64(2.0))[0]) == pytest.approx(0.7, abs=1e-6) # type: ignore[call-arg,arg-type]
353+
354+
def test_right_open_between_support_points(self) -> None:
355+
"""F⁻(x) between support points equals F(x) (no mass there)."""
356+
distr = self.make_discrete_point_pmf_distribution()
357+
fitted_open = _fit_pmf_to_cdf_1D(distr, right_closed=False)
358+
fitted_closed = _fit_pmf_to_cdf_1D(distr, right_closed=True)
359+
360+
# Between 0 and 1: both conventions agree
361+
x_between = np.array([0.5])
362+
np.testing.assert_allclose(
363+
np.asarray(fitted_open.func(x_between), dtype=np.float64), # type: ignore[call-arg,arg-type]
364+
np.asarray(fitted_closed.func(x_between), dtype=np.float64), # type: ignore[call-arg,arg-type]
365+
atol=1e-10,
366+
)
367+
368+
def test_right_open_before_support(self) -> None:
369+
"""F⁻(x) = 0 for x <= first support point."""
370+
distr = self.make_discrete_point_pmf_distribution()
371+
fitted = _fit_pmf_to_cdf_1D(distr, right_closed=False)
372+
373+
assert float(fitted.func(np.float64(-1.0))[0]) == pytest.approx(0.0) # type: ignore[call-arg,arg-type]
374+
assert float(fitted.func(np.float64(0.0))[0]) == pytest.approx(0.0) # type: ignore[call-arg,arg-type]
375+
376+
def test_right_open_after_last_support(self) -> None:
377+
"""F⁻(x) = 1 for x > last support point."""
378+
distr = self.make_discrete_point_pmf_distribution()
379+
fitted = _fit_pmf_to_cdf_1D(distr, right_closed=False)
380+
381+
assert float(fitted.func(np.float64(3.0))[0]) == pytest.approx(1.0) # type: ignore[call-arg,arg-type]
382+
383+
def test_right_open_negation_identity(self) -> None:
384+
"""1 - F⁻(x) = P(ξ ≥ x) for all support points."""
385+
# Support {0, 1, 2}, PMF {0: 0.2, 1: 0.5, 2: 0.3}
386+
# P(ξ ≥ 0) = 1.0, P(ξ ≥ 1) = 0.8, P(ξ ≥ 2) = 0.3
387+
distr = self.make_discrete_point_pmf_distribution()
388+
fitted = _fit_pmf_to_cdf_1D(distr, right_closed=False)
389+
390+
xs = np.array([0.0, 1.0, 2.0])
391+
f_open = np.asarray(fitted.func(xs), dtype=np.float64) # type: ignore[call-arg]
392+
p_geq = np.float64(1.0) - f_open
393+
np.testing.assert_allclose(p_geq, [1.0, 0.8, 0.3], atol=1e-6)
394+
395+
def test_right_open_monotonicity(self) -> None:
396+
"""F⁻(x) must be non-decreasing."""
397+
distr = self.make_discrete_point_pmf_distribution()
398+
fitted = _fit_pmf_to_cdf_1D(distr, right_closed=False)
399+
xs = np.linspace(-1.0, 3.0, 50)
400+
result = np.asarray(fitted.func(xs), dtype=float) # type: ignore[call-arg,type-var]
401+
assert np.all(np.diff(result) >= -1e-10)
402+
403+
def test_right_open_bounds(self) -> None:
404+
"""F⁻(x) must lie in [0, 1]."""
405+
distr = self.make_discrete_point_pmf_distribution()
406+
fitted = _fit_pmf_to_cdf_1D(distr, right_closed=False)
407+
xs = np.linspace(-2.0, 4.0, 50)
408+
result = np.asarray(fitted.func(xs), dtype=float) # type: ignore[call-arg,type-var]
409+
assert np.all(result >= 0.0) # type: ignore[operator]
410+
assert np.all(result <= 1.0) # type: ignore[operator]
411+
412+
def test_descriptor_has_right_closed_option(self) -> None:
413+
"""pmf_to_cdf_1D descriptor must declare right_closed as a CharacteristicOption."""
414+
desc = _build_pmf_to_cdf_1D()
415+
char_names = [o.name for o in desc.characteristic_options]
416+
assert "right_closed" in char_names
417+
418+
419+
class TestPmfToCdfRightOpenRightUnbounded:
420+
"""Tests for _fit_pmf_to_cdf_1D right-open convention on a right-unbounded lattice."""
421+
422+
def test_right_open_at_support_points(self) -> None:
423+
"""F⁻(k) = P(ξ < k) = 1 - 0.5^k for geometric(0.5) on {0,1,2,...}."""
424+
distr = _make_right_unbounded_pmf_distribution()
425+
fitted = _fit_pmf_to_cdf_1D(distr, right_closed=False)
426+
427+
# F⁻(0) = P(ξ < 0) = 0
428+
assert float(fitted.func(np.array([0.0]))[0]) == pytest.approx(0.0, abs=1e-6) # type: ignore[call-arg]
429+
# F⁻(1) = P(ξ < 1) = P(ξ = 0) = 0.5
430+
assert float(fitted.func(np.array([1.0]))[0]) == pytest.approx(0.5, abs=1e-6) # type: ignore[call-arg]
431+
# F⁻(2) = P(ξ < 2) = P(ξ=0) + P(ξ=1) = 0.75
432+
assert float(fitted.func(np.array([2.0]))[0]) == pytest.approx(0.75, abs=1e-6) # type: ignore[call-arg]
433+
434+
def test_right_open_negation_identity(self) -> None:
435+
"""1 - F⁻(k) = P(ξ ≥ k) = 0.5^k for geometric(0.5)."""
436+
distr = _make_right_unbounded_pmf_distribution()
437+
fitted = _fit_pmf_to_cdf_1D(distr, right_closed=False)
438+
439+
for k in range(5):
440+
f_open = float(fitted.func(np.array([float(k)]))[0]) # type: ignore[call-arg]
441+
p_geq = 1.0 - f_open
442+
expected = 0.5**k # P(ξ ≥ k) = 0.5^k for geometric(0.5)
443+
assert p_geq == pytest.approx(expected, abs=1e-5), f"P(ξ ≥ {k}) mismatch"
444+
445+
446+
class TestPpfToCdfRightOpen:
447+
"""Tests for _fit_ppf_to_cdf_1D with right_closed=False (right-open convention)."""
448+
449+
def _make_ppf_distribution(self) -> StandaloneEuclideanUnivariateDistribution:
450+
"""Discrete distribution with support {0,1,2}, PMF {0:0.2, 1:0.5, 2:0.3}."""
451+
452+
# PPF: Q(q) = 0 for q<=0.2, 1 for 0.2<q<=0.7, 2 for q>0.7
453+
def ppf(q: NumericArray, **_: Any) -> NumericArray:
454+
q_arr = np.atleast_1d(np.asarray(q, dtype=float))
455+
return np.where(q_arr <= 0.2, 0.0, np.where(q_arr <= 0.7, 1.0, 2.0))
456+
457+
return StandaloneEuclideanUnivariateDistribution(
458+
kind=Kind.DISCRETE,
459+
analytical_computations={
460+
CharacteristicName.PPF: {
461+
DEFAULT_ANALYTICAL_LABEL: AnalyticalComputation[NumericArray, NumericArray](
462+
target=CharacteristicName.PPF,
463+
func=ppf, # type: ignore[arg-type]
464+
)
465+
}
466+
},
467+
support=ExplicitTableDiscreteSupport([0, 1, 2]),
468+
)
469+
470+
def test_right_open_at_support_points(self) -> None:
471+
"""F⁻(x) at support points should exclude the mass at x."""
472+
distr = self._make_ppf_distribution()
473+
fitted = _fit_ppf_to_cdf_1D(distr, right_closed=False)
474+
475+
# F⁻(0) = P(ξ < 0) = 0
476+
assert float(fitted.func(np.float64(0.0))[0]) == pytest.approx(0.0, abs=0.01) # type: ignore[call-arg,arg-type]
477+
# F⁻(1) = P(ξ < 1) = 0.2
478+
assert float(fitted.func(np.float64(1.0))[0]) == pytest.approx(0.2, abs=0.01) # type: ignore[call-arg,arg-type]
479+
# F⁻(2) = P(ξ < 2) = 0.7
480+
assert float(fitted.func(np.float64(2.0))[0]) == pytest.approx(0.7, abs=0.01) # type: ignore[call-arg,arg-type]
481+
482+
def test_right_open_negation_identity(self) -> None:
483+
"""1 - F⁻(x) = P(ξ ≥ x) for support points."""
484+
distr = self._make_ppf_distribution()
485+
fitted = _fit_ppf_to_cdf_1D(distr, right_closed=False)
486+
487+
xs = np.array([0.0, 1.0, 2.0])
488+
f_open = np.asarray(fitted.func(xs), dtype=np.float64) # type: ignore[call-arg]
489+
p_geq = np.float64(1.0) - f_open
490+
# P(ξ ≥ 0) = 1.0, P(ξ ≥ 1) = 0.8, P(ξ ≥ 2) = 0.3
491+
np.testing.assert_allclose(p_geq, [1.0, 0.8, 0.3], atol=0.01)
492+
493+
def test_right_open_vs_closed_differ_at_support(self) -> None:
494+
"""Right-open and right-closed CDFs must differ at support points."""
495+
distr = self._make_ppf_distribution()
496+
fitted_open = _fit_ppf_to_cdf_1D(distr, right_closed=False)
497+
fitted_closed = _fit_ppf_to_cdf_1D(distr, right_closed=True)
498+
499+
xs = np.array([0.0, 1.0, 2.0])
500+
open_vals = np.asarray(fitted_open.func(xs), dtype=float) # type: ignore[call-arg,type-var]
501+
closed_vals = np.asarray(fitted_closed.func(xs), dtype=float) # type: ignore[call-arg,type-var]
502+
# At support points the two conventions differ by the PMF mass
503+
assert not np.allclose(open_vals, closed_vals, atol=0.05)
504+
505+
def test_descriptor_has_right_closed_option(self) -> None:
506+
"""ppf_to_cdf_1D descriptor must declare right_closed as a CharacteristicOption."""
507+
desc = _build_ppf_to_cdf_1D()
508+
char_names = [o.name for o in desc.characteristic_options]
509+
assert "right_closed" in char_names

tests/unit/distributions/computations/test_registry.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,10 @@
1010

1111
from typing import Any
1212

13+
import pytest
14+
1315
from pysatl_core.distributions.computations.descriptors import FitterDescriptor
14-
from pysatl_core.distributions.computations.registry import FitterRegistry
16+
from pysatl_core.distributions.computations.registry import FitterRegistry, reset_fitter_registry
1517
from pysatl_core.types import CharacteristicName
1618

1719

@@ -37,6 +39,11 @@ def _make_desc(
3739
class TestFitterRegistry:
3840
"""Tests for the FitterRegistry class."""
3941

42+
@pytest.fixture(autouse=True)
43+
def reset_registry(self) -> None:
44+
"""Reset the FitterRegistry singleton before each test."""
45+
reset_fitter_registry()
46+
4047
def test_register_and_find(self) -> None:
4148
reg = FitterRegistry()
4249
desc = _make_desc("pdf_to_cdf")

0 commit comments

Comments
 (0)