"""Property-based tests for the signal math upgrade — Bayesian accumulator module. Feature: signal-math-upgrade Tests properties 1, 2, 3, 4, 8, and 13 from the design specification, covering sigmoid gate monotonicity, Beta posterior evidence accumulation, Bayesian confidence symmetry/divergence, posterior round-trip consistency, Shannon entropy range/maximum, and confidence monotonicity with agreeing signals. """ from __future__ import annotations import math from hypothesis import given, settings from hypothesis import strategies as st from services.aggregation.bayesian import ( PRIOR, compute_bayesian_posterior, compute_entropy, ) from services.aggregation.scoring import ( ScoringConfig, SignalWeight, WeightedSignal, compute_adaptive_half_life, compute_info_gain, ) # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _sigmoid(x: float) -> float: """Sigmoid function σ(x) = 1 / (1 + exp(-x)).""" return 1.0 / (1.0 + math.exp(-x)) def _sigmoid_gate(confidence: float, steepness: float = 5.0, midpoint: float = 0.5) -> float: """Sigmoid confidence gate: σ(k·(x - midpoint)).""" return _sigmoid(steepness * (confidence - midpoint)) def _make_signal_weight(combined: float) -> SignalWeight: """Create a minimal SignalWeight with the given combined value.""" return SignalWeight( recency=1.0, credibility=1.0, novelty_bonus=0.0, confidence_gate=1.0, market_ctx_multiplier=1.0, combined=combined, ) def _make_weighted_signal( sentiment_value: float, combined_weight: float = 1.0, doc_id: str = "doc-test", ) -> WeightedSignal: """Create a WeightedSignal with the given sentiment and weight.""" return WeightedSignal( document_id=doc_id, weight=_make_signal_weight(combined_weight), sentiment_value=sentiment_value, impact_score=1.0, ) # --------------------------------------------------------------------------- # Hypothesis strategies # --------------------------------------------------------------------------- def _weighted_signal_strategy() -> st.SearchStrategy[WeightedSignal]: """Generate random WeightedSignal objects with valid fields.""" return st.builds( _make_weighted_signal, sentiment_value=st.sampled_from([-1.0, 1.0]), combined_weight=st.floats(min_value=0.1, max_value=5.0, allow_nan=False, allow_infinity=False), doc_id=st.text( alphabet=st.sampled_from("abcdefghijklmnopqrstuvwxyz0123456789"), min_size=3, max_size=10, ), ) def _uniform_weight_signal_strategy() -> st.SearchStrategy[WeightedSignal]: """Generate WeightedSignal objects with uniform weight (1.0) for round-trip tests.""" return st.builds( _make_weighted_signal, sentiment_value=st.sampled_from([-1.0, 1.0]), combined_weight=st.just(1.0), doc_id=st.text( alphabet=st.sampled_from("abcdefghijklmnopqrstuvwxyz0123456789"), min_size=3, max_size=10, ), ) def _bullish_signal_strategy( combined_weight: st.SearchStrategy[float] | None = None, ) -> st.SearchStrategy[WeightedSignal]: """Generate bullish (positive sentiment) WeightedSignal objects.""" return st.builds( _make_weighted_signal, sentiment_value=st.just(1.0), combined_weight=combined_weight or st.floats( min_value=0.1, max_value=5.0, allow_nan=False, allow_infinity=False, ), doc_id=st.text( alphabet=st.sampled_from("abcdefghijklmnopqrstuvwxyz0123456789"), min_size=3, max_size=10, ), ) def _bearish_signal_strategy( combined_weight: st.SearchStrategy[float] | None = None, ) -> st.SearchStrategy[WeightedSignal]: """Generate bearish (negative sentiment) WeightedSignal objects.""" return st.builds( _make_weighted_signal, sentiment_value=st.just(-1.0), combined_weight=combined_weight or st.floats( min_value=0.1, max_value=5.0, allow_nan=False, allow_infinity=False, ), doc_id=st.text( alphabet=st.sampled_from("abcdefghijklmnopqrstuvwxyz0123456789"), min_size=3, max_size=10, ), ) # --------------------------------------------------------------------------- # Property 1: Sigmoid Gate Monotonicity # Feature: signal-math-upgrade, Property 1: Sigmoid Gate Monotonicity # **Validates: Requirements 2.6, 17.1** # --------------------------------------------------------------------------- class TestProperty1SigmoidGateMonotonicity: """Property 1: Sigmoid Gate Monotonicity. For any two extraction confidence values x₁, x₂ ∈ [0.0, 1.0] where x₁ ≤ x₂, the sigmoid gate σ(5·(x₁ - 0.5)) SHALL be ≤ σ(5·(x₂ - 0.5)). **Validates: Requirements 2.6, 17.1** """ @settings(max_examples=100) @given( x1=st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False), x2=st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False), ) def test_sigmoid_gate_monotonicity(self, x1: float, x2: float) -> None: """Higher confidence always produces equal or higher gate values.""" lo, hi = min(x1, x2), max(x1, x2) gate_lo = _sigmoid_gate(lo) gate_hi = _sigmoid_gate(hi) assert gate_lo <= gate_hi + 1e-12, ( f"Sigmoid gate not monotonic: σ(5·({lo}-0.5))={gate_lo} > σ(5·({hi}-0.5))={gate_hi}" ) # --------------------------------------------------------------------------- # Property 2: Beta Posterior Evidence Accumulation # Feature: signal-math-upgrade, Property 2: Beta Posterior Evidence Accumulation # **Validates: Requirements 1.3, 17.2** # --------------------------------------------------------------------------- class TestProperty2BetaPosteriorEvidenceAccumulation: """Property 2: Beta Posterior Evidence Accumulation. For any sequence of weighted signal sets where each successive set contains one additional signal, the sum α + β SHALL increase monotonically. **Validates: Requirements 1.3, 17.2** """ @settings(max_examples=100) @given( signals=st.lists( _weighted_signal_strategy(), min_size=1, max_size=20, ), ) def test_evidence_accumulates_monotonically(self, signals: list[WeightedSignal]) -> None: """Adding a signal never reduces the total evidence mass α + β.""" prev_evidence = PRIOR.alpha + PRIOR.beta # 2.0 for i in range(1, len(signals) + 1): posterior = compute_bayesian_posterior(signals[:i]) current_evidence = posterior.alpha + posterior.beta assert current_evidence >= prev_evidence - 1e-9, ( f"Evidence decreased at signal {i}: " f"prev={prev_evidence}, current={current_evidence}" ) prev_evidence = current_evidence # --------------------------------------------------------------------------- # Property 3: Bayesian Confidence Symmetry and Divergence # Feature: signal-math-upgrade, Property 3: Bayesian Confidence Symmetry and Divergence # **Validates: Requirements 1.4, 17.3** # --------------------------------------------------------------------------- class TestProperty3BayesianConfidenceSymmetryDivergence: """Property 3: Bayesian Confidence Symmetry and Divergence. For any Beta posterior with α, β ≥ 1.0: - C = 1 - 4αβ/(α+β)² SHALL equal 0.0 when α = β - C SHALL increase monotonically as max(α/β, β/α) increases **Validates: Requirements 1.4, 17.3** """ @settings(max_examples=100) @given( alpha=st.floats(min_value=1.0, max_value=100.0, allow_nan=False, allow_infinity=False), ) def test_confidence_zero_when_alpha_equals_beta(self, alpha: float) -> None: """Bayesian confidence is 0.0 when α = β (maximum uncertainty).""" ab_sum = alpha + alpha confidence = 1.0 - (4.0 * alpha * alpha) / (ab_sum * ab_sum) assert abs(confidence) < 1e-9, ( f"Confidence should be 0.0 when α=β={alpha}, got {confidence}" ) @settings(max_examples=100) @given( alpha=st.floats(min_value=1.0, max_value=100.0, allow_nan=False, allow_infinity=False), beta=st.floats(min_value=1.0, max_value=100.0, allow_nan=False, allow_infinity=False), delta=st.floats(min_value=0.01, max_value=10.0, allow_nan=False, allow_infinity=False), ) def test_confidence_increases_with_divergence( self, alpha: float, beta: float, delta: float, ) -> None: """Confidence increases as the ratio max(α/β, β/α) increases.""" # Compute confidence for (alpha, beta) ab_sum = alpha + beta c1 = 1.0 - (4.0 * alpha * beta) / (ab_sum * ab_sum) # Increase the divergence: push the larger parameter further away if alpha >= beta: alpha2 = alpha + delta beta2 = beta else: alpha2 = alpha beta2 = beta + delta ab_sum2 = alpha2 + beta2 c2 = 1.0 - (4.0 * alpha2 * beta2) / (ab_sum2 * ab_sum2) assert c2 >= c1 - 1e-9, ( f"Confidence did not increase with divergence: " f"C({alpha},{beta})={c1}, C({alpha2},{beta2})={c2}" ) # --------------------------------------------------------------------------- # Property 4: Bayesian Posterior Round-Trip Consistency # Feature: signal-math-upgrade, Property 4: Bayesian Posterior Round-Trip Consistency # **Validates: Requirements 1.7, 17.7** # --------------------------------------------------------------------------- class TestProperty4BayesianPosteriorRoundTrip: """Property 4: Bayesian Posterior Round-Trip Consistency. For any set of weighted signals with uniform weights, computing the Beta posterior and extracting P_bull = α/(α+β) SHALL produce a value within 0.05 of σ(L_t). **Validates: Requirements 1.7, 17.7** """ @settings(max_examples=100) @given( n_bull=st.integers(min_value=1, max_value=10), n_bear=st.integers(min_value=1, max_value=10), ) def test_p_bull_consistent_with_beta_mean(self, n_bull: int, n_bear: int) -> None: """P_bull from sigmoid(L_t) and α/(α+β) from Beta posterior are directionally consistent and converge as evidence grows. The sigmoid of the log-likelihood sum and the Beta posterior mean are different parameterisations of the same underlying evidence. They always agree on direction (both > 0.5 when bullish, both < 0.5 when bearish, both = 0.5 when balanced) and the gap shrinks with more evidence. """ signals: list[WeightedSignal] = [] for i in range(n_bull): signals.append(_make_weighted_signal( sentiment_value=1.0, combined_weight=1.0, doc_id=f"bull-{i}", )) for i in range(n_bear): signals.append(_make_weighted_signal( sentiment_value=-1.0, combined_weight=1.0, doc_id=f"bear-{i}", )) posterior = compute_bayesian_posterior(signals) # P_bull from sigmoid of log-likelihood p_bull_sigmoid = posterior.p_bull # P_bull from Beta posterior mean p_bull_beta = posterior.alpha / (posterior.alpha + posterior.beta) # Directional consistency: both representations agree on which side of 0.5 if n_bull > n_bear: assert p_bull_sigmoid > 0.5, f"σ(L_t)={p_bull_sigmoid} should be > 0.5 for bullish" assert p_bull_beta > 0.5, f"α/(α+β)={p_bull_beta} should be > 0.5 for bullish" elif n_bear > n_bull: assert p_bull_sigmoid < 0.5, f"σ(L_t)={p_bull_sigmoid} should be < 0.5 for bearish" assert p_bull_beta < 0.5, f"α/(α+β)={p_bull_beta} should be < 0.5 for bearish" else: assert abs(p_bull_sigmoid - 0.5) < 1e-9, f"σ(L_t)={p_bull_sigmoid} should be 0.5 when balanced" assert abs(p_bull_beta - 0.5) < 1e-9, f"α/(α+β)={p_bull_beta} should be 0.5 when balanced" # Both values are valid probabilities in [0, 1] assert 0.0 <= p_bull_sigmoid <= 1.0 assert 0.0 <= p_bull_beta <= 1.0 # --------------------------------------------------------------------------- # Property 8: Shannon Entropy Range and Maximum # Feature: signal-math-upgrade, Property 8: Shannon Entropy Range and Maximum # **Validates: Requirements 9.7** # --------------------------------------------------------------------------- class TestProperty8ShannonEntropyRangeMaximum: """Property 8: Shannon Entropy Range and Maximum. For any P_bull ∈ (0, 1): - Entropy H SHALL be in (0, 1] - Maximum value of 1.0 occurs at P_bull = 0.5 **Validates: Requirements 9.7** """ @settings(max_examples=100) @given( p_bull=st.floats(min_value=0.001, max_value=0.999, allow_nan=False, allow_infinity=False), ) def test_entropy_in_valid_range(self, p_bull: float) -> None: """Entropy is in (0, 1] for all P_bull in (0, 1).""" h = compute_entropy(p_bull) assert 0.0 < h <= 1.0 + 1e-12, ( f"Entropy out of range for P_bull={p_bull}: H={h}" ) @settings(max_examples=100) @given( p_bull=st.floats(min_value=0.001, max_value=0.999, allow_nan=False, allow_infinity=False), ) def test_entropy_maximum_at_half(self, p_bull: float) -> None: """Entropy at P_bull=0.5 is >= entropy at any other P_bull.""" h = compute_entropy(p_bull) h_max = compute_entropy(0.5) assert h <= h_max + 1e-12, ( f"Entropy at P_bull={p_bull} ({h}) exceeds maximum at 0.5 ({h_max})" ) def test_entropy_exactly_one_at_half(self) -> None: """Entropy is exactly 1.0 at P_bull = 0.5.""" h = compute_entropy(0.5) assert abs(h - 1.0) < 1e-12, f"Entropy at P_bull=0.5 should be 1.0, got {h}" # --------------------------------------------------------------------------- # Property 13: Bayesian Confidence Monotonic with Agreeing Signals # Feature: signal-math-upgrade, Property 13: Bayesian Confidence Monotonic with Agreeing Signals # **Validates: Requirements 8.6** # --------------------------------------------------------------------------- class TestProperty13BayesianConfidenceMonotonicAgreeingSignals: """Property 13: Bayesian Confidence Monotonic with Agreeing Signals. For any set of weighted signals where all signals agree on direction, adding one more agreeing signal SHALL increase Bayesian confidence C. **Validates: Requirements 8.6** """ @settings(max_examples=100) @given( base_signals=st.lists( _bullish_signal_strategy(), min_size=1, max_size=15, ), extra_signal=_bullish_signal_strategy(), ) def test_adding_bullish_signal_increases_confidence( self, base_signals: list[WeightedSignal], extra_signal: WeightedSignal, ) -> None: """Adding a bullish signal to an all-bullish set increases confidence.""" posterior_before = compute_bayesian_posterior(base_signals) posterior_after = compute_bayesian_posterior(base_signals + [extra_signal]) assert posterior_after.bayesian_confidence >= posterior_before.bayesian_confidence - 1e-9, ( f"Confidence decreased when adding agreeing signal: " f"before={posterior_before.bayesian_confidence:.6f}, " f"after={posterior_after.bayesian_confidence:.6f}" ) @settings(max_examples=100) @given( base_signals=st.lists( _bearish_signal_strategy(), min_size=1, max_size=15, ), extra_signal=_bearish_signal_strategy(), ) def test_adding_bearish_signal_increases_confidence( self, base_signals: list[WeightedSignal], extra_signal: WeightedSignal, ) -> None: """Adding a bearish signal to an all-bearish set increases confidence.""" posterior_before = compute_bayesian_posterior(base_signals) posterior_after = compute_bayesian_posterior(base_signals + [extra_signal]) assert posterior_after.bayesian_confidence >= posterior_before.bayesian_confidence - 1e-9, ( f"Confidence decreased when adding agreeing signal: " f"before={posterior_before.bayesian_confidence:.6f}, " f"after={posterior_after.bayesian_confidence:.6f}" ) # --------------------------------------------------------------------------- # Property 6: Information Gain Monotonicity # Feature: signal-math-upgrade, Property 6: Information Gain Monotonicity # **Validates: Requirements 3.5** # --------------------------------------------------------------------------- class TestProperty6InformationGainMonotonicity: """Property 6: Information Gain Monotonicity. For any two event type base rates p₁, p₂ ∈ (0, 1] where p₁ < p₂, the information gain factor r(p₁) SHALL be ≥ r(p₂). Rarer events always receive higher surprise weight. **Validates: Requirements 3.5** """ @settings(max_examples=100) @given( p1=st.floats(min_value=0.001, max_value=1.0, allow_nan=False, allow_infinity=False), p2=st.floats(min_value=0.001, max_value=1.0, allow_nan=False, allow_infinity=False), ) def test_info_gain_monotonically_decreasing_with_base_rate( self, p1: float, p2: float, ) -> None: """Rarer events (lower base rate) always produce higher or equal info gain.""" lo, hi = min(p1, p2), max(p1, p2) # compute_info_gain takes an event_type string and looks up the base rate. # To test with arbitrary base rates we call it with a dummy event type # and override the default_base_rate, since unknown event types use the # default_base_rate fallback. However, the function looks up the event # type in EVENT_TYPE_BASE_RATES first. Using None returns 1.0 immediately. # Instead, we use a non-existent event type so it falls through to # default_base_rate. r_lo = compute_info_gain( event_type="__test_lo__", lambda_param=0.3, max_gain=3.0, default_base_rate=lo, ) r_hi = compute_info_gain( event_type="__test_hi__", lambda_param=0.3, max_gain=3.0, default_base_rate=hi, ) assert r_lo >= r_hi - 1e-9, ( f"Info gain not monotonic: r(p={lo})={r_lo} < r(p={hi})={r_hi}" ) # --------------------------------------------------------------------------- # Property 5: Adaptive Decay Lower Bound # Feature: signal-math-upgrade, Property 5: Adaptive Decay Lower Bound # **Validates: Requirements 5.7, 17.4** # --------------------------------------------------------------------------- class TestProperty5AdaptiveDecayLowerBound: """Property 5: Adaptive Decay Lower Bound. For any valid combination of impact_score ∈ [0, 1], information gain factor r ∈ [1.0, 3.0], and market context multiplier ∈ [1.0, 1.45], the adaptive half-life τ_i SHALL be ≥ the base half-life τ_base. **Validates: Requirements 5.7, 17.4** """ @settings(max_examples=100) @given( base_half_life=st.floats(min_value=0.1, max_value=1000.0, allow_nan=False, allow_infinity=False), impact_score=st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False), info_gain_factor=st.floats(min_value=1.0, max_value=3.0, allow_nan=False, allow_infinity=False), market_multiplier=st.floats(min_value=1.0, max_value=1.45, allow_nan=False, allow_infinity=False), ) def test_adaptive_half_life_never_below_base( self, base_half_life: float, impact_score: float, info_gain_factor: float, market_multiplier: float, ) -> None: """Adaptive decay is always slower or equal to fixed decay, never faster.""" tau = compute_adaptive_half_life( base_half_life=base_half_life, impact_score=impact_score, info_gain_factor=info_gain_factor, market_multiplier=market_multiplier, config=ScoringConfig(), ) assert tau >= base_half_life - 1e-9, ( f"Adaptive half-life {tau} < base half-life {base_half_life} " f"(impact={impact_score}, info_gain={info_gain_factor}, " f"market={market_multiplier})" ) # --------------------------------------------------------------------------- # Property 9: Contradiction Entropy Monotonicity # Feature: signal-math-upgrade, Property 9: Contradiction Entropy Monotonicity # **Validates: Requirements 15.7** # --------------------------------------------------------------------------- from services.aggregation.contradiction import detect_contradictions class TestProperty9ContradictionEntropyMonotonicity: """Property 9: Contradiction Entropy Monotonicity. For any set of weighted signals containing both positive and negative sentiment signals, the contradiction entropy score SHALL increase monotonically as the weight distribution f_pos approaches 0.5 (equal split). More balanced disagreement always produces higher contradiction. **Validates: Requirements 15.7** """ @settings(max_examples=100) @given( total_weight=st.floats(min_value=1.0, max_value=20.0, allow_nan=False, allow_infinity=False), ratio_a=st.floats(min_value=0.05, max_value=0.95, allow_nan=False, allow_infinity=False), ratio_b=st.floats(min_value=0.05, max_value=0.95, allow_nan=False, allow_infinity=False), ) def test_closer_to_equal_split_produces_higher_contradiction( self, total_weight: float, ratio_a: float, ratio_b: float, ) -> None: """The ratio closer to 0.5 always produces a higher or equal contradiction score.""" # Determine which ratio is closer to 0.5 dist_a = abs(ratio_a - 0.5) dist_b = abs(ratio_b - 0.5) # Build signal sets for each ratio. # Each set has one positive and one negative signal whose combined # weights reflect the desired split. impact_score=1.0 so effective # weight equals combined weight. def _make_signals(ratio: float) -> list[WeightedSignal]: pos_w = total_weight * ratio neg_w = total_weight * (1.0 - ratio) return [ _make_weighted_signal( sentiment_value=1.0, combined_weight=pos_w, doc_id="pos-signal", ), _make_weighted_signal( sentiment_value=-1.0, combined_weight=neg_w, doc_id="neg-signal", ), ] # Use a high w_threshold so the evidence factor is the same for both # (both have the same total weight). result_a = detect_contradictions( _make_signals(ratio_a), probabilistic=True, w_threshold=5.0, ) result_b = detect_contradictions( _make_signals(ratio_b), probabilistic=True, w_threshold=5.0, ) # The ratio closer to 0.5 should have higher or equal contradiction if dist_a < dist_b: assert result_a.score >= result_b.score - 1e-9, ( f"Contradiction not monotonic toward 0.5: " f"ratio_a={ratio_a} (dist={dist_a:.4f}, score={result_a.score}) " f"< ratio_b={ratio_b} (dist={dist_b:.4f}, score={result_b.score})" ) elif dist_b < dist_a: assert result_b.score >= result_a.score - 1e-9, ( f"Contradiction not monotonic toward 0.5: " f"ratio_b={ratio_b} (dist={dist_b:.4f}, score={result_b.score}) " f"< ratio_a={ratio_a} (dist={dist_a:.4f}, score={result_a.score})" ) else: # Equal distance from 0.5 — scores should be equal assert abs(result_a.score - result_b.score) < 1e-4, ( f"Equal distance from 0.5 but different scores: " f"ratio_a={ratio_a} (score={result_a.score}), " f"ratio_b={ratio_b} (score={result_b.score})" ) # --------------------------------------------------------------------------- # Property 7: Multiplicative Macro Exposure Monotonicity # Feature: signal-math-upgrade, Property 7: Multiplicative Macro Exposure Monotonicity # **Validates: Requirements 10.7, 17.5** # --------------------------------------------------------------------------- from services.aggregation.interpolation import _compute_multiplicative_exposure class TestProperty7MultiplicativeMacroExposureMonotonicity: """Property 7: Multiplicative Macro Exposure Monotonicity. For any overlap configuration where one dimension O_k = 0, setting O_k to any positive value SHALL increase the total macro impact score. Multi-dimensional exposure always compounds — it never reduces impact. **Validates: Requirements 10.7, 17.5** """ @settings(max_examples=100) @given( geo=st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False), supply=st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False), commodity=st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False), sector=st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False), dimension=st.integers(min_value=0, max_value=3), positive_value=st.floats(min_value=0.01, max_value=1.0, allow_nan=False, allow_infinity=False), ) def test_setting_zero_dimension_to_positive_increases_impact( self, geo: float, supply: float, commodity: float, sector: float, dimension: int, positive_value: float, ) -> None: """Setting any zero-overlap dimension to a positive value increases impact.""" overlaps = [geo, supply, commodity, sector] # Force the chosen dimension to zero for the baseline overlaps[dimension] = 0.0 baseline = _compute_multiplicative_exposure(*overlaps) # Set the chosen dimension to a positive value overlaps[dimension] = positive_value increased = _compute_multiplicative_exposure(*overlaps) assert increased >= baseline - 1e-12, ( f"Multiplicative exposure not monotonic: " f"baseline={baseline} (dim {dimension}=0.0), " f"increased={increased} (dim {dimension}={positive_value})" ) # --------------------------------------------------------------------------- # Property 11: Competitive Signal Distance Attenuation # Feature: signal-math-upgrade, Property 11: Competitive Signal Distance Attenuation # **Validates: Requirements 12.7** # --------------------------------------------------------------------------- from services.aggregation.signal_propagation import compute_graph_distance_attenuation class TestProperty11CompetitiveSignalDistanceAttenuation: """Property 11: Competitive Signal Distance Attenuation. For any source-target pair with fixed S_source and ρ_historical, transfer strength SHALL decrease monotonically with increasing graph distance d_network. **Validates: Requirements 12.7** """ @settings(max_examples=100) @given( source_strength=st.floats(min_value=0.01, max_value=1.0, allow_nan=False, allow_infinity=False), correlation=st.floats(min_value=0.01, max_value=1.0, allow_nan=False, allow_infinity=False), d1=st.integers(min_value=1, max_value=3), d2=st.integers(min_value=1, max_value=3), ) def test_transfer_decreases_with_distance( self, source_strength: float, correlation: float, d1: int, d2: int, ) -> None: """Closer competitors always receive stronger signal transfer.""" lo_dist, hi_dist = min(d1, d2), max(d1, d2) s_close = compute_graph_distance_attenuation(source_strength, correlation, lo_dist) s_far = compute_graph_distance_attenuation(source_strength, correlation, hi_dist) assert s_close >= s_far - 1e-12, ( f"Transfer not monotonically decreasing with distance: " f"S(d={lo_dist})={s_close} < S(d={hi_dist})={s_far}" ) # --------------------------------------------------------------------------- # Property 10: Exponentially Weighted Momentum Direction # Feature: signal-math-upgrade, Property 10: Exponentially Weighted Momentum Direction # **Validates: Requirements 13.6, 17.6** # --------------------------------------------------------------------------- from services.aggregation.projection import compute_ew_momentum class TestProperty10ExponentiallyWeightedMomentumDirection: """Property 10: Exponentially Weighted Momentum Direction. For any sequence of monotonically increasing signed trend strengths (each ΔS > 0), the EW momentum SHALL be positive. **Validates: Requirements 13.6, 17.6** """ @settings(max_examples=100) @given( deltas=st.lists( st.floats(min_value=0.001, max_value=1.0, allow_nan=False, allow_infinity=False), min_size=2, max_size=10, ), ) def test_monotonically_increasing_strengths_produce_positive_momentum( self, deltas: list[float], ) -> None: """Consistently strengthening bullish trends always produce positive momentum.""" # All deltas are positive (monotonically increasing signed strengths) momentum = compute_ew_momentum(deltas) assert momentum > 0.0, ( f"EW momentum should be positive for all-positive deltas: " f"deltas={deltas}, momentum={momentum}" ) # --------------------------------------------------------------------------- # Property 12: Expected Value Directional Consistency # Feature: signal-math-upgrade, Property 12: Expected Value Directional Consistency # **Validates: Requirements 17.8** # --------------------------------------------------------------------------- from services.recommendation.eligibility import compute_expected_value class TestProperty12ExpectedValueDirectionalConsistency: """Property 12: Expected Value Directional Consistency. For any P_bull > 0.5 and estimated returns where R_up > R_down, EV SHALL be positive. **Validates: Requirements 17.8** """ @settings(max_examples=100) @given( p_bull=st.floats(min_value=0.501, max_value=1.0, allow_nan=False, allow_infinity=False), strength=st.floats(min_value=0.501, max_value=1.0, allow_nan=False, allow_infinity=False), sigma_20=st.floats(min_value=0.001, max_value=1.0, allow_nan=False, allow_infinity=False), horizon_days=st.floats(min_value=1.0, max_value=90.0, allow_nan=False, allow_infinity=False), ) def test_ev_positive_when_bullish_and_upside_exceeds_downside( self, p_bull: float, strength: float, sigma_20: float, horizon_days: float, ) -> None: """When P_bull > 0.5 and strength > 0.5 (R_up > R_down), EV is positive.""" # strength > 0.5 ensures R_up = strength * σ * √h > (1-strength) * σ * √h = R_down ev = compute_expected_value(p_bull, strength, sigma_20, horizon_days) assert ev > -1e-12, ( f"EV should be positive when P_bull={p_bull} > 0.5 and " f"strength={strength} > 0.5: EV={ev}" ) # --------------------------------------------------------------------------- # Property 14: Numerical Stability Across All Formulas # Feature: signal-math-upgrade, Property 14: Numerical Stability Across All Formulas # **Validates: Requirements 17.9, 6.4** # --------------------------------------------------------------------------- from services.aggregation.interpolation import _compute_multiplicative_exposure from services.aggregation.projection import compute_volatility_scaled_momentum from services.aggregation.scoring import compute_regime_multiplier, sigmoid_gate class TestProperty14NumericalStabilityAcrossAllFormulas: """Property 14: Numerical Stability Across All Formulas. For any valid input combination to any formula in the probabilistic pipeline, the output SHALL be a finite float (not NaN, not infinity) within the documented range. Formulas tested: - Sigmoid gate: output in (0, 1) - Beta posterior (P_bull, alpha, beta, bayesian_confidence, entropy) - Bayesian confidence: output in [0, 1] - Adaptive decay: output >= base_half_life - Regime multiplier: output in [1.0, 2.5] - Shannon entropy: output in [0, 1] - Multiplicative exposure: output in [0, ~0.724] - EW momentum: output in [-1, 1] - Volatility-scaled momentum: output in [-2.0, 2.0] - Expected value: output is finite **Validates: Requirements 17.9, 6.4** """ @settings(max_examples=100) @given( x=st.floats(min_value=-10.0, max_value=10.0, allow_nan=False, allow_infinity=False), steepness=st.floats(min_value=0.1, max_value=50.0, allow_nan=False, allow_infinity=False), midpoint=st.floats(min_value=-5.0, max_value=5.0, allow_nan=False, allow_infinity=False), ) def test_sigmoid_gate_finite_and_in_range( self, x: float, steepness: float, midpoint: float, ) -> None: """Sigmoid gate always produces a finite float in (0, 1).""" result = sigmoid_gate(x, steepness, midpoint) assert math.isfinite(result), f"Sigmoid gate produced non-finite: {result}" assert 0.0 <= result <= 1.0, f"Sigmoid gate out of range: {result}" @settings(max_examples=100) @given( signals=st.lists( _weighted_signal_strategy(), min_size=0, max_size=30, ), ) def test_bayesian_posterior_finite_and_in_range( self, signals: list[WeightedSignal], ) -> None: """All Bayesian posterior outputs are finite and within documented ranges.""" posterior = compute_bayesian_posterior(signals) # P_bull in [0, 1] assert math.isfinite(posterior.p_bull), f"P_bull non-finite: {posterior.p_bull}" assert 0.0 <= posterior.p_bull <= 1.0, f"P_bull out of range: {posterior.p_bull}" # Alpha >= 1.0 assert math.isfinite(posterior.alpha), f"Alpha non-finite: {posterior.alpha}" assert posterior.alpha >= 1.0, f"Alpha below 1.0: {posterior.alpha}" # Beta >= 1.0 assert math.isfinite(posterior.beta), f"Beta non-finite: {posterior.beta}" assert posterior.beta >= 1.0, f"Beta below 1.0: {posterior.beta}" # Bayesian confidence in [0, 1] assert math.isfinite(posterior.bayesian_confidence), ( f"Bayesian confidence non-finite: {posterior.bayesian_confidence}" ) assert 0.0 <= posterior.bayesian_confidence <= 1.0 + 1e-9, ( f"Bayesian confidence out of range: {posterior.bayesian_confidence}" ) # Entropy in [0, 1] assert math.isfinite(posterior.entropy), f"Entropy non-finite: {posterior.entropy}" assert 0.0 <= posterior.entropy <= 1.0 + 1e-9, ( f"Entropy out of range: {posterior.entropy}" ) # Log-likelihood is finite assert math.isfinite(posterior.log_likelihood), ( f"Log-likelihood non-finite: {posterior.log_likelihood}" ) @settings(max_examples=100) @given( base_half_life=st.floats(min_value=0.01, max_value=2000.0, allow_nan=False, allow_infinity=False), impact_score=st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False), info_gain_factor=st.floats(min_value=1.0, max_value=3.0, allow_nan=False, allow_infinity=False), market_multiplier=st.floats(min_value=1.0, max_value=2.5, allow_nan=False, allow_infinity=False), ) def test_adaptive_decay_finite_and_above_base( self, base_half_life: float, impact_score: float, info_gain_factor: float, market_multiplier: float, ) -> None: """Adaptive decay always produces a finite half-life >= base.""" tau = compute_adaptive_half_life( base_half_life=base_half_life, impact_score=impact_score, info_gain_factor=info_gain_factor, market_multiplier=market_multiplier, config=ScoringConfig(), ) assert math.isfinite(tau), f"Adaptive half-life non-finite: {tau}" assert tau >= base_half_life - 1e-9, ( f"Adaptive half-life {tau} < base {base_half_life}" ) @settings(max_examples=100) @given( returns=st.lists( st.floats(min_value=-0.5, max_value=0.5, allow_nan=False, allow_infinity=False), min_size=2, max_size=30, ), volumes=st.lists( st.floats(min_value=0.0, max_value=1e9, allow_nan=False, allow_infinity=False), min_size=2, max_size=30, ), ) def test_regime_multiplier_finite_and_in_range( self, returns: list[float], volumes: list[float], ) -> None: """Regime multiplier always produces a finite float in [1.0, 2.5].""" result = compute_regime_multiplier(returns, volumes) assert math.isfinite(result), f"Regime multiplier non-finite: {result}" assert 1.0 <= result <= 2.5 + 1e-9, ( f"Regime multiplier out of range: {result}" ) @settings(max_examples=100) @given( p_bull=st.floats(min_value=-1.0, max_value=2.0, allow_nan=False, allow_infinity=False), ) def test_entropy_finite_and_in_range(self, p_bull: float) -> None: """Shannon entropy always produces a finite float in [0, 1].""" result = compute_entropy(p_bull) assert math.isfinite(result), f"Entropy non-finite: {result}" assert 0.0 <= result <= 1.0 + 1e-9, f"Entropy out of range: {result}" @settings(max_examples=100) @given( geo=st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False), supply=st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False), commodity=st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False), sector=st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False), ) def test_multiplicative_exposure_finite_and_in_range( self, geo: float, supply: float, commodity: float, sector: float, ) -> None: """Multiplicative exposure always produces a finite float in [0, 1].""" result = _compute_multiplicative_exposure(geo, supply, commodity, sector) assert math.isfinite(result), f"Multiplicative exposure non-finite: {result}" assert -1e-9 <= result <= 1.0 + 1e-9, ( f"Multiplicative exposure out of range: {result}" ) @settings(max_examples=100) @given( deltas=st.lists( st.floats(min_value=-2.0, max_value=2.0, allow_nan=False, allow_infinity=False), min_size=0, max_size=15, ), lambda_decay=st.floats(min_value=0.01, max_value=0.99, allow_nan=False, allow_infinity=False), ) def test_ew_momentum_finite_and_in_range( self, deltas: list[float], lambda_decay: float, ) -> None: """EW momentum always produces a finite float in [-1, 1].""" result = compute_ew_momentum(deltas, lambda_decay) assert math.isfinite(result), f"EW momentum non-finite: {result}" assert -1.0 - 1e-9 <= result <= 1.0 + 1e-9, ( f"EW momentum out of range: {result}" ) @settings(max_examples=100) @given( momentum=st.floats(min_value=-5.0, max_value=5.0, allow_nan=False, allow_infinity=False), sigma_20=st.floats(min_value=-0.1, max_value=2.0, allow_nan=False, allow_infinity=False), ) def test_volatility_scaled_momentum_finite_and_in_range( self, momentum: float, sigma_20: float, ) -> None: """Volatility-scaled momentum always produces a finite float in [-2.0, 2.0].""" result = compute_volatility_scaled_momentum(momentum, sigma_20) assert math.isfinite(result), ( f"Volatility-scaled momentum non-finite: {result}" ) assert -2.0 - 1e-9 <= result <= 2.0 + 1e-9, ( f"Volatility-scaled momentum out of range: {result}" ) @settings(max_examples=100) @given( p_bull=st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False), strength=st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False), sigma_20=st.floats(min_value=0.0, max_value=2.0, allow_nan=False, allow_infinity=False), horizon_days=st.floats(min_value=0.0, max_value=365.0, allow_nan=False, allow_infinity=False), ) def test_expected_value_finite( self, p_bull: float, strength: float, sigma_20: float, horizon_days: float, ) -> None: """Expected value always produces a finite float.""" result = compute_expected_value(p_bull, strength, sigma_20, horizon_days) assert math.isfinite(result), f"Expected value non-finite: {result}"