feat: math core v3 engine upgrade
This commit is contained in:
@@ -0,0 +1,609 @@
|
||||
"""Property-based tests for v3 posterior assembly and regime classification.
|
||||
|
||||
Feature: math-core-v3-engine
|
||||
|
||||
Uses Hypothesis to validate correctness properties of the v3 posterior
|
||||
and regime detection layer.
|
||||
|
||||
Validates: Requirements 5.3, 6.2–6.5, 21.5
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
from hypothesis import given, settings
|
||||
from hypothesis import strategies as st
|
||||
|
||||
from services.aggregation.bayesian import V3Posterior, compute_v3_posterior
|
||||
from services.aggregation.regime import (
|
||||
V3RegimeClassification,
|
||||
MarketRegime,
|
||||
classify_regime_v3,
|
||||
)
|
||||
from services.aggregation.worker import EvidenceCluster
|
||||
from services.aggregation.scoring import EvidenceUnit
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Hypothesis strategies
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Cluster LLR values in [-2.5, 2.5] as per spec
|
||||
cluster_llrs = st.floats(
|
||||
min_value=-2.5, max_value=2.5, allow_nan=False, allow_infinity=False
|
||||
)
|
||||
|
||||
# Lists of cluster LLR values (0 to 10 clusters)
|
||||
cluster_llr_lists = st.lists(cluster_llrs, min_size=0, max_size=10)
|
||||
|
||||
# Regime evidence multiplier gamma from the defined set
|
||||
gammas = st.sampled_from([0.70, 0.80, 0.90, 1.10])
|
||||
|
||||
# Prior probability in [0.40, 0.60]
|
||||
priors = st.floats(
|
||||
min_value=0.40, max_value=0.60, allow_nan=False, allow_infinity=False
|
||||
)
|
||||
|
||||
# Random walk closing prices for regime classification
|
||||
# Generate 120+ prices as a random walk starting from a base price
|
||||
_base_price = st.floats(min_value=10.0, max_value=500.0, allow_nan=False, allow_infinity=False)
|
||||
_price_step = st.floats(min_value=-2.0, max_value=2.0, allow_nan=False, allow_infinity=False)
|
||||
|
||||
|
||||
@st.composite
|
||||
def closing_prices_strategy(draw: st.DrawFn) -> list[float]:
|
||||
"""Generate 120+ closing prices as a random walk."""
|
||||
base = draw(_base_price)
|
||||
n = draw(st.integers(min_value=120, max_value=200))
|
||||
steps = draw(st.lists(_price_step, min_size=n, max_size=n))
|
||||
prices = [base]
|
||||
for step in steps:
|
||||
prices.append(max(0.01, prices[-1] + step)) # keep prices positive
|
||||
return prices
|
||||
|
||||
|
||||
@st.composite
|
||||
def daily_returns_strategy(draw: st.DrawFn) -> list[float]:
|
||||
"""Generate 120+ daily returns (small floats)."""
|
||||
n = draw(st.integers(min_value=120, max_value=200))
|
||||
returns = draw(
|
||||
st.lists(
|
||||
st.floats(min_value=-0.10, max_value=0.10, allow_nan=False, allow_infinity=False),
|
||||
min_size=n,
|
||||
max_size=n,
|
||||
)
|
||||
)
|
||||
return returns
|
||||
|
||||
|
||||
# ATR_20 > 0
|
||||
atr_20_strategy = st.floats(
|
||||
min_value=0.01, max_value=50.0, allow_nan=False, allow_infinity=False
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helper: build mock EvidenceCluster from a cluster_llr value
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_cluster(cluster_llr: float) -> EvidenceCluster:
|
||||
"""Create a minimal EvidenceCluster with the given cluster_llr."""
|
||||
return EvidenceCluster(
|
||||
cluster_id=f"test_cluster_{id(cluster_llr)}",
|
||||
units=[],
|
||||
llrs=[cluster_llr],
|
||||
n_eff=1.0,
|
||||
cluster_llr=cluster_llr,
|
||||
)
|
||||
|
||||
|
||||
def _make_regime(gamma: float) -> V3RegimeClassification:
|
||||
"""Create a minimal V3RegimeClassification with the given gamma."""
|
||||
return V3RegimeClassification(
|
||||
regime=MarketRegime.UNCERTAINTY,
|
||||
trend_z=0.0,
|
||||
vol_ratio=1.0,
|
||||
evidence_multiplier=gamma,
|
||||
confidence_multiplier=0.85,
|
||||
phi_decay=0.50,
|
||||
atr_multiplier=2.0,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 7: Posterior P_up is in open interval (0, 1)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given(
|
||||
llr_values=cluster_llr_lists,
|
||||
gamma=gammas,
|
||||
p_prior=priors,
|
||||
)
|
||||
@settings(max_examples=100)
|
||||
def test_property_7_posterior_p_up_in_open_interval(
|
||||
llr_values: list[float],
|
||||
gamma: float,
|
||||
p_prior: float,
|
||||
) -> None:
|
||||
"""**Validates: Requirements 5.3**
|
||||
|
||||
Property 7: For any set of cluster LLRs (each in [-2.5, 2.5]), any regime
|
||||
evidence multiplier gamma in {0.70, 0.80, 0.90, 1.10}, and any prior
|
||||
P_prior in [0.40, 0.60], the computed P_up SHALL be in (1e-10, 1 - 1e-10).
|
||||
"""
|
||||
# Build mock clusters from the LLR values
|
||||
clusters = [_make_cluster(llr) for llr in llr_values]
|
||||
|
||||
# Build mock regime with the given gamma
|
||||
regime = _make_regime(gamma)
|
||||
|
||||
# Compute posterior
|
||||
result = compute_v3_posterior(clusters, regime, p_prior=p_prior)
|
||||
|
||||
# P_up must be in the open interval (1e-10, 1 - 1e-10)
|
||||
assert result.p_up >= 1e-10, (
|
||||
f"P_up {result.p_up} is below lower bound 1e-10"
|
||||
)
|
||||
assert result.p_up <= 1 - 1e-10, (
|
||||
f"P_up {result.p_up} is above upper bound 1 - 1e-10"
|
||||
)
|
||||
|
||||
# p_down should be 1 - p_up
|
||||
assert math.isclose(result.p_up + result.p_down, 1.0, abs_tol=1e-12), (
|
||||
f"p_up + p_down = {result.p_up + result.p_down}, expected 1.0"
|
||||
)
|
||||
|
||||
# Strength should be abs(2 * P_up - 1)
|
||||
expected_strength = abs(2.0 * result.p_up - 1.0)
|
||||
assert math.isclose(result.strength, expected_strength, abs_tol=1e-12), (
|
||||
f"strength {result.strength} != expected {expected_strength}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 20: Regime classification is exhaustive and deterministic
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_VALID_REGIMES = {"panic", "trend_following", "mean_reversion", "uncertainty"}
|
||||
|
||||
|
||||
@given(
|
||||
closing_prices=closing_prices_strategy(),
|
||||
daily_returns=daily_returns_strategy(),
|
||||
atr_20=atr_20_strategy,
|
||||
)
|
||||
@settings(max_examples=100)
|
||||
def test_property_20_regime_classification_exhaustive_and_deterministic(
|
||||
closing_prices: list[float],
|
||||
daily_returns: list[float],
|
||||
atr_20: float,
|
||||
) -> None:
|
||||
"""**Validates: Requirements 6.2–6.5, 21.5**
|
||||
|
||||
Property 20: For any valid market data inputs (closing_prices of sufficient
|
||||
length, daily_returns, ATR_20 > 0), the regime classification SHALL produce
|
||||
exactly one of {panic, trend_following, mean_reversion, uncertainty} and the
|
||||
same inputs SHALL always produce the same classification.
|
||||
"""
|
||||
# First classification
|
||||
result_1 = classify_regime_v3(closing_prices, daily_returns, atr_20)
|
||||
|
||||
# Result must be exactly one of the four valid regimes
|
||||
assert result_1.regime.value in _VALID_REGIMES, (
|
||||
f"Regime '{result_1.regime.value}' not in valid set {_VALID_REGIMES}"
|
||||
)
|
||||
|
||||
# Determinism: second call with same inputs must produce same result
|
||||
result_2 = classify_regime_v3(closing_prices, daily_returns, atr_20)
|
||||
|
||||
assert result_1.regime == result_2.regime, (
|
||||
f"Non-deterministic: first call gave {result_1.regime.value}, "
|
||||
f"second gave {result_2.regime.value}"
|
||||
)
|
||||
assert result_1.trend_z == result_2.trend_z, (
|
||||
f"Non-deterministic trend_z: {result_1.trend_z} vs {result_2.trend_z}"
|
||||
)
|
||||
assert result_1.vol_ratio == result_2.vol_ratio, (
|
||||
f"Non-deterministic vol_ratio: {result_1.vol_ratio} vs {result_2.vol_ratio}"
|
||||
)
|
||||
assert result_1.evidence_multiplier == result_2.evidence_multiplier, (
|
||||
f"Non-deterministic evidence_multiplier: "
|
||||
f"{result_1.evidence_multiplier} vs {result_2.evidence_multiplier}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 8: Contradiction is zero when evidence is unidirectional
|
||||
# Feature: math-core-v3-engine, Property 8: Contradiction is zero when evidence is unidirectional
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
from services.aggregation.contradiction import compute_v3_contradiction
|
||||
|
||||
# Strategies for unidirectional cluster LLRs
|
||||
all_positive_llrs = st.lists(
|
||||
st.floats(min_value=0.001, max_value=2.5, allow_nan=False, allow_infinity=False),
|
||||
min_size=1, max_size=10,
|
||||
)
|
||||
all_negative_llrs = st.lists(
|
||||
st.floats(min_value=-2.5, max_value=-0.001, allow_nan=False, allow_infinity=False),
|
||||
min_size=1, max_size=10,
|
||||
)
|
||||
|
||||
|
||||
@given(llr_values=all_positive_llrs)
|
||||
@settings(max_examples=100)
|
||||
def test_property_8_contradiction_zero_all_positive(
|
||||
llr_values: list[float],
|
||||
) -> None:
|
||||
"""**Validates: Requirements 7.6, 7.7**
|
||||
|
||||
Property 8: For any set of cluster LLRs where all clusters have the same
|
||||
sign (all positive), the computed contradiction score SHALL be 0.0.
|
||||
"""
|
||||
clusters = [_make_cluster(llr) for llr in llr_values]
|
||||
score = compute_v3_contradiction(clusters)
|
||||
assert score == 0.0, (
|
||||
f"Expected contradiction 0.0 for all-positive LLRs {llr_values}, got {score}"
|
||||
)
|
||||
|
||||
|
||||
@given(llr_values=all_negative_llrs)
|
||||
@settings(max_examples=100)
|
||||
def test_property_8_contradiction_zero_all_negative(
|
||||
llr_values: list[float],
|
||||
) -> None:
|
||||
"""**Validates: Requirements 7.6, 7.7**
|
||||
|
||||
Property 8: For any set of cluster LLRs where all clusters have the same
|
||||
sign (all negative), the computed contradiction score SHALL be 0.0.
|
||||
"""
|
||||
clusters = [_make_cluster(llr) for llr in llr_values]
|
||||
score = compute_v3_contradiction(clusters)
|
||||
assert score == 0.0, (
|
||||
f"Expected contradiction 0.0 for all-negative LLRs {llr_values}, got {score}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 9: Contradiction score is bounded in [0, 1]
|
||||
# Feature: math-core-v3-engine, Property 9: Contradiction score is bounded in [0, 1]
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Mix of positive and negative cluster LLRs
|
||||
mixed_llrs = st.lists(
|
||||
st.floats(min_value=-2.5, max_value=2.5, allow_nan=False, allow_infinity=False),
|
||||
min_size=0, max_size=15,
|
||||
)
|
||||
|
||||
|
||||
@given(llr_values=mixed_llrs)
|
||||
@settings(max_examples=100)
|
||||
def test_property_9_contradiction_bounded_zero_one(
|
||||
llr_values: list[float],
|
||||
) -> None:
|
||||
"""**Validates: Requirements 7.6, 7.7, 21.7**
|
||||
|
||||
Property 9: For any set of cluster LLRs (including mixed positive and
|
||||
negative), the computed contradiction score SHALL be in [0.0, 1.0].
|
||||
"""
|
||||
clusters = [_make_cluster(llr) for llr in llr_values]
|
||||
score = compute_v3_contradiction(clusters)
|
||||
assert 0.0 <= score <= 1.0, (
|
||||
f"Contradiction score {score} out of bounds [0, 1] for LLRs {llr_values}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 10: Multiplicative confidence is bounded in [0, 1] and suppressed
|
||||
# by weak dimensions
|
||||
# Feature: math-core-v3-engine
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
from services.aggregation.worker import compute_v3_confidence, compute_v3_data_quality
|
||||
|
||||
# Strategies for Property 10
|
||||
n_eff_totals = st.floats(min_value=0.0, max_value=20.0, allow_nan=False, allow_infinity=False)
|
||||
unit_floats = st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False)
|
||||
q_value_lists = st.lists(unit_floats, min_size=1, max_size=15)
|
||||
llr_lists_conf = st.lists(
|
||||
st.floats(min_value=-2.5, max_value=2.5, allow_nan=False, allow_infinity=False),
|
||||
min_size=1, max_size=15,
|
||||
)
|
||||
regime_conf_mults = st.floats(min_value=0.01, max_value=1.0, allow_nan=False, allow_infinity=False)
|
||||
|
||||
|
||||
@given(
|
||||
n_eff_total=n_eff_totals,
|
||||
q_values=q_value_lists,
|
||||
llrs=llr_lists_conf,
|
||||
strength=unit_floats,
|
||||
regime_confidence_mult=regime_conf_mults,
|
||||
contradiction=unit_floats,
|
||||
data_quality=unit_floats,
|
||||
)
|
||||
@settings(max_examples=100)
|
||||
def test_property_10_confidence_bounded_and_suppressed(
|
||||
n_eff_total: float,
|
||||
q_values: list[float],
|
||||
llrs: list[float],
|
||||
strength: float,
|
||||
regime_confidence_mult: float,
|
||||
contradiction: float,
|
||||
data_quality: float,
|
||||
) -> None:
|
||||
"""**Validates: Requirements 8.3, 8.5**
|
||||
|
||||
Property 10: For any valid inputs (n_eff_total >= 0, q_values in [0,1],
|
||||
strength in [0,1], regime_confidence_mult in (0,1], contradiction in [0,1],
|
||||
data_quality in [0,1]), the computed confidence SHALL be in [0, 1].
|
||||
Furthermore, if any single dimension (data_quality, 1-contradiction, or
|
||||
C_quality) is below 0.01, the resulting confidence SHALL be below 0.10.
|
||||
"""
|
||||
# Align list lengths: q_values and llrs must be parallel
|
||||
min_len = min(len(q_values), len(llrs))
|
||||
q_values = q_values[:min_len]
|
||||
llrs = llrs[:min_len]
|
||||
|
||||
confidence = compute_v3_confidence(
|
||||
n_eff_total=n_eff_total,
|
||||
q_values=q_values,
|
||||
llrs=llrs,
|
||||
strength=strength,
|
||||
regime_confidence_mult=regime_confidence_mult,
|
||||
contradiction=contradiction,
|
||||
data_quality=data_quality,
|
||||
)
|
||||
|
||||
# Confidence must be in [0, 1]
|
||||
assert 0.0 <= confidence <= 1.0, (
|
||||
f"Confidence {confidence} out of bounds [0, 1]"
|
||||
)
|
||||
|
||||
# Suppression check: if data_quality < 0.01, confidence < 0.10
|
||||
if data_quality < 0.01:
|
||||
assert confidence < 0.10, (
|
||||
f"Confidence {confidence} not suppressed by data_quality={data_quality}"
|
||||
)
|
||||
|
||||
# Suppression check: if (1 - contradiction) < 0.01, confidence < 0.10
|
||||
if (1.0 - contradiction) < 0.01:
|
||||
assert confidence < 0.10, (
|
||||
f"Confidence {confidence} not suppressed by contradiction={contradiction}"
|
||||
)
|
||||
|
||||
# Suppression check: if C_quality < 0.01, confidence < 0.10
|
||||
# C_quality = weighted_mean(q_i, |LLR_i|) = sum(q_i * |LLR_i|) / sum(|LLR_i|)
|
||||
abs_llrs = [abs(llr) for llr in llrs]
|
||||
sum_abs_llrs = sum(abs_llrs)
|
||||
if sum_abs_llrs == 0.0:
|
||||
c_quality = 0.0
|
||||
else:
|
||||
c_quality = sum(q * w for q, w in zip(q_values, abs_llrs)) / sum_abs_llrs
|
||||
if c_quality < 0.01:
|
||||
assert confidence < 0.10, (
|
||||
f"Confidence {confidence} not suppressed by C_quality={c_quality}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 10 suppression sub-test: force one dimension below 0.01
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@given(
|
||||
n_eff_total=st.floats(min_value=1.0, max_value=20.0, allow_nan=False, allow_infinity=False),
|
||||
q_values=st.lists(
|
||||
st.floats(min_value=0.5, max_value=1.0, allow_nan=False, allow_infinity=False),
|
||||
min_size=1, max_size=10,
|
||||
),
|
||||
llrs=st.lists(
|
||||
st.floats(min_value=0.5, max_value=2.5, allow_nan=False, allow_infinity=False),
|
||||
min_size=1, max_size=10,
|
||||
),
|
||||
strength=st.floats(min_value=0.3, max_value=1.0, allow_nan=False, allow_infinity=False),
|
||||
regime_confidence_mult=regime_conf_mults,
|
||||
dimension=st.sampled_from(["data_quality", "contradiction", "c_quality"]),
|
||||
)
|
||||
@settings(max_examples=100)
|
||||
def test_property_10_suppression_by_weak_dimension(
|
||||
n_eff_total: float,
|
||||
q_values: list[float],
|
||||
llrs: list[float],
|
||||
strength: float,
|
||||
regime_confidence_mult: float,
|
||||
dimension: str,
|
||||
) -> None:
|
||||
"""**Validates: Requirements 8.3, 8.5**
|
||||
|
||||
Property 10 (suppression): If any single dimension (data_quality,
|
||||
1-contradiction, or C_quality) is below 0.01, the resulting confidence
|
||||
SHALL be below 0.10.
|
||||
"""
|
||||
# Align list lengths
|
||||
min_len = min(len(q_values), len(llrs))
|
||||
q_values = q_values[:min_len]
|
||||
llrs = llrs[:min_len]
|
||||
|
||||
# Force one dimension to be weak
|
||||
if dimension == "data_quality":
|
||||
data_quality = 0.005 # below 0.01
|
||||
contradiction = 0.0
|
||||
elif dimension == "contradiction":
|
||||
data_quality = 0.9
|
||||
contradiction = 0.995 # (1 - contradiction) = 0.005 < 0.01
|
||||
else: # c_quality
|
||||
# Force all q_values near zero so C_quality < 0.01
|
||||
q_values = [0.001] * min_len
|
||||
data_quality = 0.9
|
||||
contradiction = 0.0
|
||||
|
||||
confidence = compute_v3_confidence(
|
||||
n_eff_total=n_eff_total,
|
||||
q_values=q_values,
|
||||
llrs=llrs,
|
||||
strength=strength,
|
||||
regime_confidence_mult=regime_confidence_mult,
|
||||
contradiction=contradiction,
|
||||
data_quality=data_quality,
|
||||
)
|
||||
|
||||
assert 0.0 <= confidence <= 1.0, (
|
||||
f"Confidence {confidence} out of bounds [0, 1]"
|
||||
)
|
||||
assert confidence < 0.10, (
|
||||
f"Confidence {confidence} not suppressed when {dimension} is weak"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 17: Data quality score is bounded in [0, 1]
|
||||
# Feature: math-core-v3-engine
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
from datetime import datetime, timezone as _tz
|
||||
|
||||
# Strategies for Property 17
|
||||
extraction_failure_rates = st.floats(
|
||||
min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False
|
||||
)
|
||||
extraction_confs = st.floats(
|
||||
min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False
|
||||
)
|
||||
impacts = st.floats(
|
||||
min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False
|
||||
)
|
||||
age_newest_hours_strat = st.floats(
|
||||
min_value=0.0, max_value=10000.0, allow_nan=False, allow_infinity=False
|
||||
)
|
||||
n_source_types_strat = st.integers(min_value=0, max_value=20)
|
||||
|
||||
|
||||
@st.composite
|
||||
def evidence_unit_list_strategy(draw: st.DrawFn) -> list[EvidenceUnit]:
|
||||
"""Generate a list of EvidenceUnit-like objects for data quality testing."""
|
||||
n = draw(st.integers(min_value=0, max_value=15))
|
||||
units = []
|
||||
_ts = datetime(2024, 1, 1, tzinfo=_tz.utc)
|
||||
for i in range(n):
|
||||
ext_conf = draw(extraction_confs)
|
||||
impact = draw(impacts)
|
||||
unit = EvidenceUnit(
|
||||
symbol="TEST",
|
||||
layer="company",
|
||||
event_type="earnings",
|
||||
source_id=f"src_{i}",
|
||||
source_group="news",
|
||||
timestamp=_ts,
|
||||
horizon="7d",
|
||||
direction=1,
|
||||
sentiment_strength=0.5,
|
||||
impact=impact,
|
||||
extraction_conf=ext_conf,
|
||||
source_cred=0.7,
|
||||
novelty=0.5,
|
||||
event_base_rate=0.25,
|
||||
cluster_id=f"cluster_{i}",
|
||||
)
|
||||
units.append(unit)
|
||||
return units
|
||||
|
||||
|
||||
@given(
|
||||
units=evidence_unit_list_strategy(),
|
||||
extraction_failure_rate=extraction_failure_rates,
|
||||
age_newest_hours=age_newest_hours_strat,
|
||||
n_source_types=n_source_types_strat,
|
||||
)
|
||||
@settings(max_examples=100)
|
||||
def test_property_17_data_quality_bounded_zero_one(
|
||||
units: list[EvidenceUnit],
|
||||
extraction_failure_rate: float,
|
||||
age_newest_hours: float,
|
||||
n_source_types: int,
|
||||
) -> None:
|
||||
"""**Validates: Requirements 17.6, 21.6**
|
||||
|
||||
Property 17: For any valid inputs (extraction_failure_rate in [0,1],
|
||||
extraction_conf_i in [0,1], impact_i in [0,1], age_newest_hours >= 0,
|
||||
N_valid >= 0, N_source_types >= 0), the computed data_quality_score
|
||||
SHALL be in [0, 1].
|
||||
"""
|
||||
data_quality = compute_v3_data_quality(
|
||||
units=units,
|
||||
extraction_failure_rate=extraction_failure_rate,
|
||||
age_newest_hours=age_newest_hours,
|
||||
n_source_types=n_source_types,
|
||||
)
|
||||
|
||||
assert 0.0 <= data_quality <= 1.0, (
|
||||
f"Data quality score {data_quality} out of bounds [0, 1] "
|
||||
f"for extraction_failure_rate={extraction_failure_rate}, "
|
||||
f"age_newest_hours={age_newest_hours}, n_source_types={n_source_types}, "
|
||||
f"n_units={len(units)}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 12: Posterior state JSON round-trip
|
||||
# Feature: math-core-v3-engine
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
import json
|
||||
from dataclasses import asdict
|
||||
|
||||
|
||||
@given(
|
||||
llr_values=st.lists(
|
||||
st.floats(min_value=-2.5, max_value=2.5, allow_nan=False, allow_infinity=False),
|
||||
min_size=1, max_size=10,
|
||||
),
|
||||
gamma=gammas,
|
||||
p_prior=priors,
|
||||
)
|
||||
@settings(max_examples=100)
|
||||
def test_property_12_posterior_json_round_trip(
|
||||
llr_values: list[float],
|
||||
gamma: float,
|
||||
p_prior: float,
|
||||
) -> None:
|
||||
"""**Validates: Requirements 20.1, 21.10**
|
||||
|
||||
Property 12: Serialize V3Posterior to JSON via dataclasses.asdict() and
|
||||
json.dumps(), then deserialize via json.loads() and V3Posterior(**data).
|
||||
All numeric fields SHALL be equivalent within 1e-10, and string fields
|
||||
SHALL be exactly equal.
|
||||
"""
|
||||
# Build clusters and regime, compute posterior
|
||||
clusters = [_make_cluster(llr) for llr in llr_values]
|
||||
regime = _make_regime(gamma)
|
||||
posterior = compute_v3_posterior(clusters, regime, p_prior=p_prior)
|
||||
|
||||
# Serialize to JSON
|
||||
serialized = json.dumps(asdict(posterior))
|
||||
|
||||
# Deserialize back to V3Posterior
|
||||
restored = V3Posterior(**json.loads(serialized))
|
||||
|
||||
# Verify numeric fields within 1e-10
|
||||
assert math.isclose(posterior.p_up, restored.p_up, abs_tol=1e-10), (
|
||||
f"p_up mismatch: {posterior.p_up} vs {restored.p_up}"
|
||||
)
|
||||
assert math.isclose(posterior.p_down, restored.p_down, abs_tol=1e-10), (
|
||||
f"p_down mismatch: {posterior.p_down} vs {restored.p_down}"
|
||||
)
|
||||
assert math.isclose(posterior.log_odds, restored.log_odds, abs_tol=1e-10), (
|
||||
f"log_odds mismatch: {posterior.log_odds} vs {restored.log_odds}"
|
||||
)
|
||||
assert math.isclose(posterior.strength, restored.strength, abs_tol=1e-10), (
|
||||
f"strength mismatch: {posterior.strength} vs {restored.strength}"
|
||||
)
|
||||
assert math.isclose(posterior.n_eff_total, restored.n_eff_total, abs_tol=1e-10), (
|
||||
f"n_eff_total mismatch: {posterior.n_eff_total} vs {restored.n_eff_total}"
|
||||
)
|
||||
|
||||
# Verify string fields are exactly equal
|
||||
assert posterior.direction == restored.direction, (
|
||||
f"direction mismatch: {posterior.direction!r} vs {restored.direction!r}"
|
||||
)
|
||||
assert posterior.regime == restored.regime, (
|
||||
f"regime mismatch: {posterior.regime!r} vs {restored.regime!r}"
|
||||
)
|
||||
Reference in New Issue
Block a user