611 lines
21 KiB
Python
611 lines
21 KiB
Python
"""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 (
|
||
MarketRegime,
|
||
V3RegimeClassification,
|
||
classify_regime_v3,
|
||
)
|
||
from services.aggregation.scoring import EvidenceUnit
|
||
from services.aggregation.worker import EvidenceCluster
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 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
|
||
from datetime import 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}"
|
||
)
|