You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

175 lines
6.7 KiB
Python

"""Unit tests for canonical merger."""
from __future__ import annotations
import copy
import pytest
from libs.parser.merger import merge, should_queue_for_review
_BASE_RULE = {
"schema_version": "1.0.0",
"document_id": "DOC::test",
"parser_kind": "rule",
"event_type": "earnings_release",
"event_direction": "bullish",
"event_date": "2026-01-01",
"filing_time_bucket": "post_market",
"headline": "",
"summary": "Q1 results beat estimates.",
"guidance": {"status": "raised", "scope": "annual", "notes": ""},
"signals": {
"demand_strength": "strong",
"pricing_power": "present",
"backlog_or_bookings": "unknown",
"customer_expansion": "unknown",
"margin_quality": "improving",
},
"risk_flags": {
"oneoff_item": False,
"tax_benefit": False,
"valuation_gain": False,
"non_gaap_heavy": True,
"financing_related": False,
"legal_or_regulatory_overhang": False,
},
"evidence": [],
"confidence": {
"overall": 0.80,
"event_type": 0.90,
"event_direction": 0.75,
"guidance": 0.80,
"risk_flags": 0.85,
},
"warnings": [],
}
_BASE_LLM = {
**copy.deepcopy(_BASE_RULE),
"parser_kind": "llm",
"headline": "Apple beats Q1 expectations",
"summary": "Apple Inc. reported strong Q1 results driven by iPhone sales.",
"signals": {
"demand_strength": "strong",
"pricing_power": "present",
"backlog_or_bookings": "present",
"customer_expansion": "present",
"margin_quality": "improving",
},
"confidence": {
"overall": 0.85,
"event_type": 0.90,
"event_direction": 0.80,
"guidance": 0.75,
"risk_flags": 0.85,
},
}
@pytest.mark.unit
class TestMerge:
def test_rule_wins_when_both_confident(self) -> None:
"""Rule wins on event_type/direction when rule confidence >= 0.70."""
result = merge(_BASE_RULE, _BASE_LLM)
assert result["event_type"] == "earnings_release"
assert result["event_direction"] == "bullish"
assert result["provenance"]["event_type"] == "rule"
def test_llm_fills_unknown_signals(self) -> None:
"""LLM fills in unknown signals that rule left blank."""
result = merge(_BASE_RULE, _BASE_LLM)
# backlog_or_bookings was unknown in rule but present in LLM
assert result["signals"]["backlog_or_bookings"] == "present"
assert result["provenance"]["signals.backlog_or_bookings"] == "llm"
# customer_expansion was unknown in rule but present in LLM
assert result["signals"]["customer_expansion"] == "present"
def test_llm_wins_when_rule_not_confident(self) -> None:
"""LLM wins on a field when rule confidence is below threshold."""
llm_bearish = copy.deepcopy(_BASE_LLM)
llm_bearish["event_direction"] = "bearish"
llm_bearish["confidence"]["event_direction"] = 0.80 # LLM confident
rule_low = copy.deepcopy(_BASE_RULE)
rule_low["confidence"]["event_direction"] = 0.40 # below threshold → not confident
result = merge(rule_low, llm_bearish)
# Rule not confident → LLM fills in "bearish", no conflict
assert result["event_direction"] == "bearish"
assert result["provenance"]["event_direction"] == "llm"
assert result["rule_llm_conflict"] is False
def test_conflict_flags_rule_llm_conflict(self) -> None:
"""rule_llm_conflict flag is set when both parsers disagree with high confidence."""
llm_conflict = copy.deepcopy(_BASE_LLM)
llm_conflict["event_type"] = "guidance_update" # conflicts with "earnings_release"
# Both rule and LLM are confident
rule_high = copy.deepcopy(_BASE_RULE)
rule_high["confidence"]["event_type"] = 0.90
llm_conflict["confidence"]["event_type"] = 0.90
result = merge(rule_high, llm_conflict)
# Both confident → rule wins, but conflict flagged
assert result["event_type"] == "earnings_release"
assert result["rule_llm_conflict"] is True
def test_llm_none_all_provenance_is_rule(self) -> None:
"""With no LLM output, all provenance should be 'rule'."""
result = merge(_BASE_RULE, None)
assert result["parser_kind"] == "merged"
assert result["rule_llm_conflict"] is False
for field, source in result["provenance"].items():
assert source == "rule", f"Expected 'rule' for {field}, got {source}"
def test_risk_flags_are_ored(self) -> None:
"""Risk flags combine with OR: if either parser flags it, it's flagged."""
rule_no_oneoff = copy.deepcopy(_BASE_RULE)
rule_no_oneoff["risk_flags"]["oneoff_item"] = False
llm_with_oneoff = copy.deepcopy(_BASE_LLM)
llm_with_oneoff["risk_flags"]["oneoff_item"] = True
result = merge(rule_no_oneoff, llm_with_oneoff)
assert result["risk_flags"]["oneoff_item"] is True
assert result["provenance"]["risk_flags.oneoff_item"] == "llm"
@pytest.mark.unit
class TestShouldQueueForReview:
def test_no_queue_for_clean_record(self) -> None:
"""High-confidence, no conflicts, no oneoff flags → no review needed."""
clean = copy.deepcopy(_BASE_RULE)
# Remove any flags that would trigger review
clean["risk_flags"]["oneoff_item"] = False
clean["risk_flags"]["non_gaap_heavy"] = False
clean["rule_llm_conflict"] = False
clean["provenance"] = {} # type: ignore[typeddict-unknown-key]
clean["parser_kind"] = "merged"
# event_direction is bullish (not unknown/mixed), confidence is 0.80
should_queue, reasons = should_queue_for_review(clean)
assert should_queue is False
assert reasons == []
def test_low_confidence_triggers_review(self) -> None:
low_conf = copy.deepcopy(_BASE_RULE)
low_conf["confidence"]["overall"] = 0.45
low_conf["rule_llm_conflict"] = False
should_queue, reasons = should_queue_for_review(low_conf)
assert should_queue is True
assert "low_confidence" in reasons
def test_conflict_triggers_review(self) -> None:
conflict = copy.deepcopy(_BASE_RULE)
conflict["rule_llm_conflict"] = True
should_queue, reasons = should_queue_for_review(conflict)
assert should_queue is True
assert "rule_llm_conflict" in reasons
def test_oneoff_triggers_review(self) -> None:
oneoff = copy.deepcopy(_BASE_RULE)
oneoff["risk_flags"]["oneoff_item"] = True
oneoff["rule_llm_conflict"] = False
should_queue, reasons = should_queue_for_review(oneoff)
assert should_queue is True
assert "oneoff_likely" in reasons