# Feature: dual-pipeline-signal-engine, Properties: Bayesian log-odds, entropy gate, EV_R """Property-based tests for the probabilistic pipeline math. Feature: dual-pipeline-signal-engine Tests three properties from the design specification: 1. Bayesian log-odds round-trip (Requirement 17.2) 2. Shannon entropy gate properties (Requirement 17.3) 3. EV_R monotonicity with P_up (Requirement 17.8) Requirements: 6.3, 6.4, 6.5, 17.2, 17.3, 17.8 """ from __future__ import annotations import math from hypothesis import given, settings from hypothesis import strategies as st from services.signal_engine.probabilistic import ( _logit, _shannon_entropy, _sigmoid, ) # --------------------------------------------------------------------------- # Hypothesis strategies # --------------------------------------------------------------------------- # Prior probability in (0.01, 0.99) — avoids extreme clamping at boundaries _prior_prob = st.floats( min_value=0.01, max_value=0.99, allow_nan=False, allow_infinity=False, ) # Log-likelihood ratio values — bounded to avoid overflow in sigmoid _log_lr = st.floats( min_value=-10.0, max_value=10.0, allow_nan=False, allow_infinity=False, ) # List of log-LR values (1 to 10 signals) _log_lr_list = st.lists(_log_lr, min_size=1, max_size=10) # Probability in open interval (0, 1) for entropy tests _open_prob = st.floats( min_value=1e-6, max_value=1.0 - 1e-6, allow_nan=False, allow_infinity=False, ) # P_up values for EV_R monotonicity — two ordered values _p_up_pair = st.tuples( st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False), st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False), ).filter(lambda pair: pair[0] < pair[1]) # Positive expected win in R-units _e_win_r = st.floats( min_value=0.01, max_value=100.0, allow_nan=False, allow_infinity=False, ) # --------------------------------------------------------------------------- # Property 1: Bayesian log-odds round-trip # Validates: Requirements 6.3, 17.2 # --------------------------------------------------------------------------- @given(p_prior=_prior_prob, log_lrs=_log_lr_list) @settings(max_examples=100) def test_bayesian_log_odds_round_trip( p_prior: float, log_lrs: list[float], ) -> None: """**Validates: Requirements 6.3, 17.2** Converting P_prior to logit, adding Σ log(LR_i), and converting back via sigmoid SHALL produce a valid probability in [0, 1]. logit(P_post) = logit(P_prior) + Σ log(LR_i) P_post = sigmoid(logit(P_post)) The sigmoid implementation clamps extreme values to 0.0 / 1.0, so the result is in the closed interval [0, 1]. """ logit_prior = _logit(p_prior) sum_log_lr = sum(log_lrs) logit_posterior = logit_prior + sum_log_lr p_posterior = _sigmoid(logit_posterior) # Posterior must be a valid probability in [0, 1] assert 0.0 <= p_posterior <= 1.0, ( f"Posterior {p_posterior} not in [0, 1]. " f"P_prior={p_prior}, logit_prior={logit_prior}, " f"Σ log_lr={sum_log_lr}, logit_posterior={logit_posterior}" ) # For non-saturated posteriors, round-trip should hold: # sigmoid(logit(p)) ≈ p. At saturation (0.0 or 1.0) the logit # clamps, so we only check the interior. if 1e-9 < p_posterior < 1.0 - 1e-9: round_trip = _sigmoid(_logit(p_posterior)) assert math.isclose(round_trip, p_posterior, rel_tol=1e-6), ( f"Round-trip failed: sigmoid(logit({p_posterior})) = {round_trip}" ) # --------------------------------------------------------------------------- # Property 2: Shannon entropy gate properties # Validates: Requirements 6.4, 17.3 # --------------------------------------------------------------------------- @given(p=_open_prob) @settings(max_examples=100) def test_entropy_maximized_at_half(p: float) -> None: """**Validates: Requirements 6.4, 17.3** Shannon entropy H(p) SHALL be maximized at p = 0.5. For all p in (0, 1): H(0.5) >= H(p). """ h_p = _shannon_entropy(p) h_half = _shannon_entropy(0.5) assert h_half >= h_p - 1e-12, ( f"Entropy at 0.5 ({h_half}) should be >= entropy at {p} ({h_p})" ) def test_entropy_zero_at_boundaries() -> None: """**Validates: Requirements 6.4, 17.3** Shannon entropy SHALL equal 0.0 at p = 0.0 and p = 1.0. """ assert _shannon_entropy(0.0) == 0.0, "H(0.0) should be 0.0" assert _shannon_entropy(1.0) == 0.0, "H(1.0) should be 0.0" @given(p=_open_prob) @settings(max_examples=100) def test_entropy_symmetric_around_half(p: float) -> None: """**Validates: Requirements 6.4, 17.3** Shannon entropy SHALL be symmetric around 0.5: H(p) == H(1 - p). """ h_p = _shannon_entropy(p) h_complement = _shannon_entropy(1.0 - p) assert math.isclose(h_p, h_complement, rel_tol=1e-9), ( f"Entropy not symmetric: H({p}) = {h_p}, H({1.0 - p}) = {h_complement}" ) # --------------------------------------------------------------------------- # Property 3: EV_R monotonicity with P_up # Validates: Requirements 6.5, 17.8 # --------------------------------------------------------------------------- @given(p_pair=_p_up_pair, e_win=_e_win_r) @settings(max_examples=100) def test_ev_r_monotonically_increasing_with_p_up( p_pair: tuple[float, float], e_win: float, ) -> None: """**Validates: Requirements 6.5, 17.8** EV_R = P_up · E[win_R] - (1 - P_up) · 1.0 SHALL be monotonically increasing with P_up for fixed E[win_R] > 0. For p1 < p2 and fixed E[win_R] > 0: EV_R(p2) >= EV_R(p1). """ p1, p2 = p_pair # Compute EV_R directly using the formula (not _compute_ev_r which # derives E[win_R] from confluence signals) ev_r_1 = p1 * e_win - (1.0 - p1) * 1.0 ev_r_2 = p2 * e_win - (1.0 - p2) * 1.0 assert ev_r_2 >= ev_r_1 - 1e-12, ( f"EV_R not monotonic: EV_R(p2={p2}) = {ev_r_2} < " f"EV_R(p1={p1}) = {ev_r_1} with E[win_R]={e_win}" )