"""Tests for contradiction detection and disagreement representation. Requirements: 6.4, 6.5 """ from datetime import datetime, timezone from services.aggregation.contradiction import ( CatalystEntry, detect_contradictions, ) from services.aggregation.scoring import WeightedSignal, compute_signal_weight NOW = datetime(2026, 4, 11, 12, 0, 0, tzinfo=timezone.utc) def _sw(doc_id: str, sentiment: float, impact: float = 0.5) -> WeightedSignal: """Helper to build a WeightedSignal with default scoring.""" w = compute_signal_weight(NOW, NOW, "7d", 0.8, extraction_confidence=0.8) return WeightedSignal(doc_id, w, sentiment_value=sentiment, impact_score=impact) # --------------------------------------------------------------------------- # Overall score (backward compat with compute_contradiction_score) # --------------------------------------------------------------------------- def test_no_signals_returns_zero(): result = detect_contradictions([]) assert result.score == 0.0 assert result.details == [] def test_all_positive_no_contradiction(): signals = [_sw("d1", 1.0), _sw("d2", 1.0)] result = detect_contradictions(signals) assert result.score == 0.0 assert len(result.details) == 0 def test_equal_opposing_gives_half(): signals = [_sw("d1", 1.0, 0.5), _sw("d2", -1.0, 0.5)] result = detect_contradictions(signals) assert abs(result.score - 0.5) < 1e-4 def test_neutral_signals_ignored(): signals = [_sw("d1", 0.0), _sw("d2", 0.0)] result = detect_contradictions(signals) assert result.score == 0.0 assert result.details == [] # --------------------------------------------------------------------------- # Sentiment disagreement detail # --------------------------------------------------------------------------- def test_sentiment_disagreement_detail_present(): signals = [_sw("d1", 1.0, 0.6), _sw("d2", -1.0, 0.4)] result = detect_contradictions(signals) sentiments = [d for d in result.details if d.dimension == "sentiment"] assert len(sentiments) == 1 detail = sentiments[0] assert detail.positive_doc_ids == ["d1"] assert detail.negative_doc_ids == ["d2"] assert detail.positive_weight > 0 assert detail.negative_weight > 0 assert "positive" in detail.description.lower() or "sentiment" in detail.description.lower() def test_no_sentiment_detail_when_all_agree(): signals = [_sw("d1", 1.0), _sw("d2", 1.0)] result = detect_contradictions(signals) sentiments = [d for d in result.details if d.dimension == "sentiment"] assert len(sentiments) == 0 # --------------------------------------------------------------------------- # Catalyst disagreement detail # --------------------------------------------------------------------------- def test_catalyst_disagreement_detected(): signals = [_sw("d1", 1.0, 0.7), _sw("d2", -1.0, 0.5)] entries = [ CatalystEntry("d1", "earnings"), CatalystEntry("d2", "earnings"), ] result = detect_contradictions(signals, entries) catalyst_details = [d for d in result.details if d.dimension.startswith("catalyst:")] assert len(catalyst_details) == 1 assert catalyst_details[0].dimension == "catalyst:earnings" assert catalyst_details[0].positive_doc_ids == ["d1"] assert catalyst_details[0].negative_doc_ids == ["d2"] def test_no_catalyst_disagreement_when_same_sentiment(): signals = [_sw("d1", 1.0), _sw("d2", 1.0)] entries = [ CatalystEntry("d1", "earnings"), CatalystEntry("d2", "earnings"), ] result = detect_contradictions(signals, entries) catalyst_details = [d for d in result.details if d.dimension.startswith("catalyst:")] assert len(catalyst_details) == 0 def test_catalyst_disagreement_across_types(): """Different catalyst types with internal disagreement each get a detail.""" signals = [ _sw("d1", 1.0, 0.5), _sw("d2", -1.0, 0.5), _sw("d3", 1.0, 0.5), _sw("d4", -1.0, 0.5), ] entries = [ CatalystEntry("d1", "earnings"), CatalystEntry("d2", "earnings"), CatalystEntry("d3", "product"), CatalystEntry("d4", "product"), ] result = detect_contradictions(signals, entries) catalyst_details = [d for d in result.details if d.dimension.startswith("catalyst:")] dims = {d.dimension for d in catalyst_details} assert "catalyst:earnings" in dims assert "catalyst:product" in dims # --------------------------------------------------------------------------- # Integration with assemble_trend_summary # --------------------------------------------------------------------------- def test_trend_summary_includes_disagreement_details(): """assemble_trend_summary should populate disagreement_details.""" from datetime import timedelta from services.aggregation.worker import ( ImpactRow, assemble_trend_summary, build_weighted_signals, ) impacts = [ ImpactRow( document_id="d1", confidence=0.8, novelty_score=0.5, source_credibility=0.8, sentiment="positive", impact_score=0.7, catalyst_type="earnings", key_facts=[], risks=[], published_at=NOW - timedelta(hours=1), ), ImpactRow( document_id="d2", confidence=0.8, novelty_score=0.5, source_credibility=0.8, sentiment="negative", impact_score=0.7, catalyst_type="earnings", key_facts=[], risks=[], published_at=NOW - timedelta(hours=2), ), ] signals = build_weighted_signals(impacts, NOW, "7d") summary = assemble_trend_summary("AAPL", "7d", signals, impacts, reference_time=NOW) assert summary.contradiction_score > 0 assert len(summary.disagreement_details) > 0 dims = {d.dimension for d in summary.disagreement_details} assert "sentiment" in dims