Files
stonks-oracle/tests/test_pbt_v3_reliability.py
T

417 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Property-based tests for EvidenceUnit normalization, calibrated reliability, and LLR.
Feature: math-core-v3-engine
Uses Hypothesis to validate correctness properties of the v3 calibrated
evidence engine foundation: EvidenceUnit normalization preserves field ranges,
reliability q_i is bounded, p_correct is bounded, LLR sign matches direction,
and neutral signals produce zero LLR.
Validates: Requirements 1.11.8, 2.12.9, 3.13.6, 21.121.3
"""
from __future__ import annotations
import math
from datetime import datetime, timedelta, timezone
from hypothesis import given, settings
from hypothesis import strategies as st
from services.aggregation.scoring import (
EvidenceUnit,
SourceStats,
_clamp,
compute_llr,
compute_v3_reliability,
normalize_company_signal,
normalize_competitive_signal,
normalize_macro_signal,
)
# ---------------------------------------------------------------------------
# Hypothesis strategies
# ---------------------------------------------------------------------------
unit_floats = st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False)
directions = st.sampled_from([-1, 0, 1])
horizons = st.sampled_from(["intraday", "1d", "7d", "30d", "90d"])
positive_floats = st.floats(min_value=0.0, max_value=10000.0, allow_nan=False, allow_infinity=False)
non_negative_ints = st.integers(min_value=0, max_value=100)
def _evidence_unit_strategy() -> st.SearchStrategy[EvidenceUnit]:
"""Generate valid EvidenceUnit instances with fields in valid ranges."""
return st.builds(
EvidenceUnit,
symbol=st.just("AAPL"),
layer=st.sampled_from(["company", "macro", "competitive"]),
event_type=st.sampled_from(["earnings", "product_launch", "regulatory", "unknown"]),
source_id=st.just("src-001"),
source_group=st.sampled_from(["company", "macro", "competitive"]),
timestamp=st.just(datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)),
horizon=horizons,
direction=directions,
sentiment_strength=unit_floats,
impact=unit_floats,
extraction_conf=unit_floats,
source_cred=unit_floats,
novelty=unit_floats,
event_base_rate=st.floats(min_value=0.01, max_value=1.0, allow_nan=False, allow_infinity=False),
cluster_id=st.just("cluster-001"),
)
def _source_stats_strategy() -> st.SearchStrategy[SourceStats]:
"""Generate SourceStats with valid counts."""
return st.builds(
SourceStats,
source_id=st.just("src-001"),
hits=non_negative_ints,
misses=non_negative_ints,
alpha_0=st.floats(min_value=1.0, max_value=10.0, allow_nan=False, allow_infinity=False),
beta_0=st.floats(min_value=1.0, max_value=10.0, allow_nan=False, allow_infinity=False),
)
# ---------------------------------------------------------------------------
# Property 1: Reliability q_i is bounded in [0, 1]
# Feature: math-core-v3-engine, Property 1: Reliability q_i is bounded in [0, 1]
# Validates: Requirements 2.12.9
# ---------------------------------------------------------------------------
@settings(max_examples=100)
@given(
extraction_conf=unit_floats,
source_cred=unit_floats,
novelty=unit_floats,
impact=unit_floats,
sentiment_strength=unit_floats,
event_base_rate=st.floats(min_value=0.01, max_value=1.0, allow_nan=False, allow_infinity=False),
age_hours=st.floats(min_value=0.0, max_value=5000.0, allow_nan=False, allow_infinity=False),
duplicate_count_before=non_negative_ints,
hits=non_negative_ints,
misses=non_negative_ints,
)
def test_property_1_reliability_qi_bounded(
extraction_conf: float,
source_cred: float,
novelty: float,
impact: float,
sentiment_strength: float,
event_base_rate: float,
age_hours: float,
duplicate_count_before: int,
hits: int,
misses: int,
) -> None:
"""Property 1: Reliability q_i is bounded in [0, 1].
For any valid EvidenceUnit with extraction_conf in [0,1], source_cred in [0,1],
novelty in [0,1], any non-negative age_hours, and any non-negative
duplicate_count_before, the computed q_i SHALL be in [0.0, 1.0].
**Validates: Requirements 2.12.9**
"""
reference_time = datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)
unit_time = reference_time - timedelta(hours=age_hours)
unit = EvidenceUnit(
symbol="AAPL",
layer="company",
event_type="earnings",
source_id="src-001",
source_group="company",
timestamp=unit_time,
horizon="7d",
direction=1,
sentiment_strength=sentiment_strength,
impact=impact,
extraction_conf=extraction_conf,
source_cred=source_cred,
novelty=novelty,
event_base_rate=event_base_rate,
cluster_id="cluster-001",
)
stats = SourceStats(source_id="src-001", hits=hits, misses=misses)
result = compute_v3_reliability(unit, stats, duplicate_count_before, reference_time)
assert 0.0 <= result.q_i <= 1.0, f"q_i={result.q_i} out of [0, 1]"
assert 0.0 <= result.q_ext <= 1.0, f"q_ext={result.q_ext} out of [0, 1]"
assert 0.0 <= result.q_source <= 1.0, f"q_source={result.q_source} out of [0, 1]"
assert 0.0 <= result.q_recency <= 1.0, f"q_recency={result.q_recency} out of [0, 1]"
assert 0.0 <= result.q_uniqueness <= 1.0, f"q_uniqueness={result.q_uniqueness} out of [0, 1]"
# ---------------------------------------------------------------------------
# Property 2: p_correct is bounded in [0.501, 0.85]
# Feature: math-core-v3-engine, Property 2: p_correct is bounded in [0.501, 0.85]
# Validates: Requirements 3.13.2
# ---------------------------------------------------------------------------
@settings(max_examples=100)
@given(
q_i=unit_floats,
impact=unit_floats,
sentiment_strength=unit_floats,
)
def test_property_2_p_correct_bounded(
q_i: float,
impact: float,
sentiment_strength: float,
) -> None:
"""Property 2: p_correct is bounded in [0.501, 0.85].
For any valid q_i in [0,1], impact in [0,1], and sentiment_strength in [0,1],
the computed p_correct SHALL be in [0.501, 0.85].
**Validates: Requirements 3.13.2**
"""
# Replicate the p_correct computation from compute_llr
p_correct = _clamp(
0.50 + 0.35 * q_i * impact * sentiment_strength,
0.501,
0.85,
)
assert 0.501 <= p_correct <= 0.85, f"p_correct={p_correct} out of [0.501, 0.85]"
# ---------------------------------------------------------------------------
# Property 3: LLR sign matches direction and magnitude is bounded
# Feature: math-core-v3-engine, Property 3: LLR sign matches direction and magnitude is bounded
# Validates: Requirements 3.33.4, 3.6
# ---------------------------------------------------------------------------
@settings(max_examples=100)
@given(
direction=st.sampled_from([-1, 1]),
q_i=unit_floats,
impact=unit_floats,
sentiment_strength=unit_floats,
)
def test_property_3_llr_sign_matches_direction(
direction: int,
q_i: float,
impact: float,
sentiment_strength: float,
) -> None:
"""Property 3: LLR sign matches direction and magnitude is bounded.
For any valid signal with direction in {-1, +1}, the LLR SHALL have the same
sign as direction, with abs magnitude in [~0.004, ~1.735].
**Validates: Requirements 3.33.4, 3.6**
"""
unit = EvidenceUnit(
symbol="AAPL",
layer="company",
event_type="earnings",
source_id="src-001",
source_group="company",
timestamp=datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc),
horizon="7d",
direction=direction,
sentiment_strength=sentiment_strength,
impact=impact,
extraction_conf=0.8,
source_cred=0.8,
novelty=0.8,
event_base_rate=0.25,
cluster_id="cluster-001",
)
llr = compute_llr(unit, q_i)
# LLR sign must match direction
if direction == 1:
assert llr > 0.0, f"LLR={llr} should be positive for direction=+1"
else:
assert llr < 0.0, f"LLR={llr} should be negative for direction=-1"
# Magnitude bounds: ln(0.501/0.499) ≈ 0.004, ln(0.85/0.15) ≈ 1.735
min_magnitude = math.log(0.501 / 0.499) # ~0.004
max_magnitude = math.log(0.85 / 0.15) # ~1.735
assert abs(llr) >= min_magnitude - 1e-9, f"|LLR|={abs(llr)} below min ~0.004"
assert abs(llr) <= max_magnitude + 1e-9, f"|LLR|={abs(llr)} above max ~1.735"
# ---------------------------------------------------------------------------
# Property 4: Neutral signals produce zero LLR
# Feature: math-core-v3-engine, Property 4: Neutral signals produce zero LLR
# Validates: Requirements 3.5
# ---------------------------------------------------------------------------
@settings(max_examples=100)
@given(
q_i=unit_floats,
impact=unit_floats,
sentiment_strength=unit_floats,
extraction_conf=unit_floats,
source_cred=unit_floats,
novelty=unit_floats,
)
def test_property_4_neutral_produces_zero_llr(
q_i: float,
impact: float,
sentiment_strength: float,
extraction_conf: float,
source_cred: float,
novelty: float,
) -> None:
"""Property 4: Neutral signals produce zero LLR.
For any EvidenceUnit with direction=0, LLR SHALL be exactly 0.0.
**Validates: Requirements 3.5**
"""
unit = EvidenceUnit(
symbol="AAPL",
layer="company",
event_type="earnings",
source_id="src-001",
source_group="company",
timestamp=datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc),
horizon="7d",
direction=0,
sentiment_strength=sentiment_strength,
impact=impact,
extraction_conf=extraction_conf,
source_cred=source_cred,
novelty=novelty,
event_base_rate=0.25,
cluster_id="cluster-001",
)
llr = compute_llr(unit, q_i)
assert llr == 0.0, f"LLR={llr} should be exactly 0.0 for neutral direction"
# ---------------------------------------------------------------------------
# Property 21: EvidenceUnit normalization preserves field ranges
# Feature: math-core-v3-engine, Property 21: EvidenceUnit normalization preserves field ranges
# Validates: Requirements 1.11.8, 21.121.3
# ---------------------------------------------------------------------------
def _company_signal_strategy() -> st.SearchStrategy[dict]:
"""Generate raw company signal dicts with arbitrary values."""
return st.fixed_dictionaries({
"symbol": st.just("TSLA"),
"timestamp": st.just(datetime(2024, 6, 15, 10, 0, 0, tzinfo=timezone.utc)),
"source_id": st.just("doc-123"),
"event_type": st.sampled_from(["earnings", "product_launch", "regulatory", "unknown", None]),
"source_group": st.sampled_from(["company", "reuters", None]),
"horizon": st.sampled_from(["intraday", "1d", "7d", "30d", "90d", "invalid", None]),
"sentiment": st.sampled_from(["positive", "negative", "neutral", "mixed", "bullish", "bearish", None]),
"sentiment_strength": st.one_of(unit_floats, st.just(None)),
"impact": st.one_of(unit_floats, st.just(None)),
"extraction_conf": st.one_of(unit_floats, st.just(None)),
"source_cred": st.one_of(unit_floats, st.just(None)),
"novelty": st.one_of(unit_floats, st.just(None)),
})
def _macro_signal_strategy() -> st.SearchStrategy[dict]:
"""Generate raw macro signal dicts with arbitrary values."""
return st.fixed_dictionaries({
"symbol": st.just("AAPL"),
"timestamp": st.just(datetime(2024, 6, 15, 10, 0, 0, tzinfo=timezone.utc)),
"source_id": st.just("event-456"),
"event_type": st.sampled_from(["earnings", "regulatory", "market_data", None]),
"estimated_duration": st.sampled_from(["short_term", "medium_term", "long_term", "unknown", None]),
"impact_direction": st.sampled_from(["positive", "negative", "neutral", "bullish", "bearish", None]),
"macro_impact_score": st.one_of(unit_floats, st.just(None)),
"event_confidence": st.one_of(unit_floats, st.just(None)),
"novelty": st.one_of(unit_floats, st.just(None)),
"sentiment_strength": st.one_of(unit_floats, st.just(None)),
})
def _competitive_signal_strategy() -> st.SearchStrategy[dict]:
"""Generate raw competitive signal dicts with arbitrary values."""
return st.fixed_dictionaries({
"symbol": st.just("MSFT"),
"timestamp": st.just(datetime(2024, 6, 15, 10, 0, 0, tzinfo=timezone.utc)),
"source_id": st.just("comp-789"),
"event_type": st.sampled_from(["earnings", "product_launch", None]),
"signal_direction": st.sampled_from(["bullish", "bearish", "neutral", None]),
"signal_strength": st.one_of(unit_floats, st.just(None)),
"relationship_strength": st.one_of(unit_floats, st.just(None)),
"pattern_confidence": st.one_of(unit_floats, st.just(None)),
"time_horizon": st.sampled_from(["intraday", "1d", "7d", "30d", "90d", "short_term", None]),
"novelty": st.one_of(unit_floats, st.just(None)),
"sentiment_strength": st.one_of(unit_floats, st.just(None)),
})
@settings(max_examples=100)
@given(signal=_company_signal_strategy())
def test_property_21_company_normalization_preserves_ranges(signal: dict) -> None:
"""Property 21: EvidenceUnit normalization preserves field ranges (company).
For any valid company signal input, normalized EvidenceUnit SHALL have
direction in {-1,0,+1}, all float fields in [0,1], event_base_rate in (0,1].
**Validates: Requirements 1.11.8, 21.121.3**
"""
unit = normalize_company_signal(signal)
assert unit is not None, "Expected valid EvidenceUnit from company signal"
_assert_evidence_unit_ranges(unit)
@settings(max_examples=100)
@given(signal=_macro_signal_strategy())
def test_property_21_macro_normalization_preserves_ranges(signal: dict) -> None:
"""Property 21: EvidenceUnit normalization preserves field ranges (macro).
For any valid macro signal input, normalized EvidenceUnit SHALL have
direction in {-1,0,+1}, all float fields in [0,1], event_base_rate in (0,1].
**Validates: Requirements 1.11.8, 21.121.3**
"""
unit = normalize_macro_signal(signal)
assert unit is not None, "Expected valid EvidenceUnit from macro signal"
_assert_evidence_unit_ranges(unit)
@settings(max_examples=100)
@given(signal=_competitive_signal_strategy())
def test_property_21_competitive_normalization_preserves_ranges(signal: dict) -> None:
"""Property 21: EvidenceUnit normalization preserves field ranges (competitive).
For any valid competitive signal input, normalized EvidenceUnit SHALL have
direction in {-1,0,+1}, all float fields in [0,1], event_base_rate in (0,1].
**Validates: Requirements 1.11.8, 21.121.3**
"""
unit = normalize_competitive_signal(signal)
assert unit is not None, "Expected valid EvidenceUnit from competitive signal"
_assert_evidence_unit_ranges(unit)
# ---------------------------------------------------------------------------
# Shared assertion helper
# ---------------------------------------------------------------------------
def _assert_evidence_unit_ranges(unit: EvidenceUnit) -> None:
"""Assert all EvidenceUnit fields are within their valid ranges."""
assert unit.direction in (-1, 0, 1), f"direction={unit.direction} not in {{-1, 0, +1}}"
assert 0.0 <= unit.sentiment_strength <= 1.0, f"sentiment_strength={unit.sentiment_strength} out of [0, 1]"
assert 0.0 <= unit.impact <= 1.0, f"impact={unit.impact} out of [0, 1]"
assert 0.0 <= unit.extraction_conf <= 1.0, f"extraction_conf={unit.extraction_conf} out of [0, 1]"
assert 0.0 <= unit.source_cred <= 1.0, f"source_cred={unit.source_cred} out of [0, 1]"
assert 0.0 <= unit.novelty <= 1.0, f"novelty={unit.novelty} out of [0, 1]"
assert 0.0 < unit.event_base_rate <= 1.0, f"event_base_rate={unit.event_base_rate} out of (0, 1]"
assert unit.horizon in ("intraday", "1d", "7d", "30d", "90d"), f"horizon={unit.horizon} invalid"
assert unit.layer in ("company", "macro", "competitive"), f"layer={unit.layer} invalid"