feat: math core v3 engine upgrade

This commit is contained in:
Celes Renata
2026-06-27 12:21:41 +00:00
parent 365bc5d4b7
commit b4bf0f2361
34 changed files with 11693 additions and 3 deletions
+417
View File
@@ -0,0 +1,417 @@
"""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,
ReliabilityComponents,
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"