"""Tests for symbol resolution: alias index, resolver, and ambiguity handling. Validates Requirements 5.2, 5.3, 5.4, 5.5, 5.6 """ from __future__ import annotations import pytest from services.intelligence_pipeline_v3.resolution.alias_index import ( AliasIndex, IndexEntry, build_alias_index, ) from services.intelligence_pipeline_v3.resolution.explicit_vs_inferred import ( ClassifiedMentionType, classify_mention, to_mention_type, ) from services.intelligence_pipeline_v3.resolution.models import ( MatchType, MentionType, ResolutionCandidate, UnresolvedMention, UnresolvedReason, ) from services.intelligence_pipeline_v3.resolution.symbol_resolver import SymbolResolver # --- Fixtures --- def _sample_companies() -> list[dict]: """Standard set of test companies matching the seed structure.""" return [ { "id": "11111111-1111-1111-1111-111111111111", "ticker": "AAPL", "legal_name": "Apple Inc.", "aliases": [ {"alias": "Apple", "alias_type": "brand"}, {"alias": "iPhone", "alias_type": "product"}, ], }, { "id": "22222222-2222-2222-2222-222222222222", "ticker": "GOOGL", "legal_name": "Alphabet Inc.", "aliases": [ {"alias": "Google", "alias_type": "brand"}, {"alias": "Alphabet", "alias_type": "legal_name"}, {"alias": "YouTube", "alias_type": "product"}, ], }, { "id": "33333333-3333-3333-3333-333333333333", "ticker": "MSFT", "legal_name": "Microsoft Corporation", "aliases": [ {"alias": "Microsoft", "alias_type": "brand"}, {"alias": "Azure", "alias_type": "product"}, {"alias": "Windows", "alias_type": "product"}, ], }, { "id": "44444444-4444-4444-4444-444444444444", "ticker": "META", "legal_name": "Meta Platforms Inc.", "aliases": [ {"alias": "Facebook", "alias_type": "brand"}, {"alias": "Instagram", "alias_type": "product"}, {"alias": "WhatsApp", "alias_type": "product"}, ], }, { "id": "55555555-5555-5555-5555-555555555555", "ticker": "JPM", "legal_name": "JPMorgan Chase & Co.", "aliases": [ {"alias": "JPMorgan", "alias_type": "brand"}, {"alias": "Chase", "alias_type": "brand"}, ], }, { "id": "66666666-6666-6666-6666-666666666666", "ticker": "V", "legal_name": "Visa Inc.", "aliases": [ {"alias": "Visa", "alias_type": "brand"}, ], }, ] def _companies_with_shared_alias() -> list[dict]: """Companies that share an alias, creating ambiguity.""" return [ { "id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", "ticker": "CARR", "legal_name": "Carrier Global Corporation", "aliases": [ {"alias": "Carrier", "alias_type": "brand"}, ], }, { "id": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", "ticker": "CSX", "legal_name": "CSX Corporation", "aliases": [ {"alias": "Carrier", "alias_type": "brand"}, # shared alias! ], }, ] def _companies_with_multiple_shared_aliases() -> list[dict]: """Three companies sharing the 'Mercury' alias plus additional overlaps.""" return [ { "id": "aaa11111-1111-1111-1111-111111111111", "ticker": "MCY", "legal_name": "Mercury General Corporation", "aliases": [ {"alias": "Mercury", "alias_type": "brand"}, {"alias": "Mercury Insurance", "alias_type": "brand"}, ], }, { "id": "bbb22222-2222-2222-2222-222222222222", "ticker": "MRCY", "legal_name": "Mercury Systems Inc.", "aliases": [ {"alias": "Mercury", "alias_type": "brand"}, {"alias": "Mercury Systems", "alias_type": "brand"}, ], }, { "id": "ccc33333-3333-3333-3333-333333333333", "ticker": "MERC", "legal_name": "Mercer International Inc.", "aliases": [ {"alias": "Mercury", "alias_type": "brand"}, # 3-way shared {"alias": "Mercer", "alias_type": "brand"}, ], }, ] @pytest.fixture def resolver() -> SymbolResolver: """Create a resolver loaded with sample companies.""" r = SymbolResolver() r.load_registry(_sample_companies()) return r @pytest.fixture def ambiguous_resolver() -> SymbolResolver: """Create a resolver with companies sharing aliases.""" r = SymbolResolver() r.load_registry(_companies_with_shared_alias()) return r @pytest.fixture def multi_ambiguous_resolver() -> SymbolResolver: """Create a resolver with three companies sharing one alias.""" r = SymbolResolver() r.load_registry(_companies_with_multiple_shared_aliases()) return r # --- Tests: build_alias_index (Task 24.1) --- class TestBuildAliasIndex: """Test the build_alias_index factory function.""" def test_builds_from_company_list(self) -> None: companies = _sample_companies() index = build_alias_index(companies) assert len(index) > 0 def test_indexes_tickers(self) -> None: companies = _sample_companies() index = build_alias_index(companies) entries = index.lookup("AAPL") assert len(entries) == 1 assert entries[0].ticker == "AAPL" assert entries[0].match_type == "exact_ticker" def test_indexes_legal_names(self) -> None: companies = _sample_companies() index = build_alias_index(companies) entries = index.lookup("Microsoft Corporation") assert len(entries) >= 1 assert any(e.ticker == "MSFT" for e in entries) def test_indexes_aliases(self) -> None: companies = _sample_companies() index = build_alias_index(companies) entries = index.lookup("Google") assert len(entries) == 1 assert entries[0].ticker == "GOOGL" assert entries[0].match_type == "alias" def test_handles_tuple_aliases(self) -> None: companies = [ { "id": "99999999-9999-9999-9999-999999999999", "ticker": "TEST", "legal_name": "Test Corp.", "aliases": [("TestAlias", "brand"), ("AnotherAlias", "product")], } ] index = build_alias_index(companies) entries = index.lookup("TestAlias") assert len(entries) == 1 assert entries[0].ticker == "TEST" def test_handles_empty_company_list(self) -> None: index = build_alias_index([]) assert len(index) == 0 def test_handles_company_without_aliases(self) -> None: companies = [ { "id": "88888888-8888-8888-8888-888888888888", "ticker": "BARE", "legal_name": "Bare Corp.", } ] index = build_alias_index(companies) entries = index.lookup("BARE") # "BARE" matches both as ticker and normalized legal name "Bare Corp." → "bare" assert len(entries) >= 1 assert all(e.company_id == "88888888-8888-8888-8888-888888888888" for e in entries) def test_case_insensitive_lookup(self) -> None: companies = _sample_companies() index = build_alias_index(companies) entries_upper = index.lookup("AAPL") entries_lower = index.lookup("aapl") assert len(entries_upper) == len(entries_lower) # --- Tests: Exact Ticker Match (Task 24.2) --- class TestExactTickerMatch: """Test resolving by ticker symbol.""" def test_aapl_resolves(self, resolver: SymbolResolver) -> None: result = resolver.resolve("AAPL") assert len(result.candidates) == 1 assert result.candidates[0].ticker == "AAPL" assert result.candidates[0].company_id == "11111111-1111-1111-1111-111111111111" assert result.candidates[0].match_type == MatchType.exact_ticker assert result.candidates[0].confidence >= 0.9 assert not result.is_ambiguous def test_googl_resolves(self, resolver: SymbolResolver) -> None: result = resolver.resolve("GOOGL") assert len(result.candidates) == 1 assert result.candidates[0].ticker == "GOOGL" assert result.candidates[0].match_type == MatchType.exact_ticker def test_single_char_ticker(self, resolver: SymbolResolver) -> None: """Ticker 'V' (Visa) should resolve correctly.""" result = resolver.resolve("V") assert len(result.candidates) == 1 assert result.candidates[0].ticker == "V" assert result.candidates[0].name == "Visa Inc." def test_ambiguity_margin_is_1_for_single_match(self, resolver: SymbolResolver) -> None: result = resolver.resolve("AAPL") assert result.ambiguity_margin == 1.0 # --- Tests: Ranked Candidates and Ambiguity Margins (Task 24.2) --- class TestRankedCandidates: """Test that candidates are ranked by confidence with proper margins.""" def test_candidates_sorted_descending(self, ambiguous_resolver: SymbolResolver) -> None: result = ambiguous_resolver.resolve("Carrier") # Multiple candidates should be sorted by confidence descending for i in range(len(result.candidates) - 1): assert result.candidates[i].confidence >= result.candidates[i + 1].confidence def test_ambiguity_margin_calculated(self, ambiguous_resolver: SymbolResolver) -> None: result = ambiguous_resolver.resolve("Carrier") # Both are alias matches → same confidence → margin = 0 assert result.ambiguity_margin == pytest.approx(0.0) def test_unambiguous_has_high_margin(self, resolver: SymbolResolver) -> None: result = resolver.resolve("AAPL") assert result.ambiguity_margin == 1.0 assert not result.is_ambiguous def test_three_way_ambiguity(self, multi_ambiguous_resolver: SymbolResolver) -> None: """Mercury alias shared by 3 companies → ambiguous with 3 candidates.""" result = multi_ambiguous_resolver.resolve("Mercury") assert len(result.candidates) == 3 assert result.is_ambiguous # All have same confidence (alias match), so margin = 0 assert result.ambiguity_margin == pytest.approx(0.0) def test_unique_alias_not_ambiguous(self, multi_ambiguous_resolver: SymbolResolver) -> None: """Mercury Insurance is unique to MCY.""" result = multi_ambiguous_resolver.resolve("Mercury Insurance") assert len(result.candidates) == 1 assert result.candidates[0].ticker == "MCY" assert not result.is_ambiguous # --- Tests: Explicit vs Inferred Exposure (Task 24.3) --- class TestMentionTypes: """Test separation of explicit mentions from inferred exposures.""" def test_explicit_mention_default(self, resolver: SymbolResolver) -> None: result = resolver.resolve("AAPL") assert result.mention_type == MentionType.explicit def test_inferred_mention(self, resolver: SymbolResolver) -> None: result = resolver.resolve("AAPL", mention_type=MentionType.inferred) assert result.mention_type == MentionType.inferred assert len(result.candidates) == 1 def test_inferred_preserves_candidates(self, resolver: SymbolResolver) -> None: explicit = resolver.resolve("Google", mention_type=MentionType.explicit) inferred = resolver.resolve("Google", mention_type=MentionType.inferred) # Same candidates, different mention_type assert len(explicit.candidates) == len(inferred.candidates) assert explicit.candidates[0].ticker == inferred.candidates[0].ticker assert explicit.mention_type == MentionType.explicit assert inferred.mention_type == MentionType.inferred def test_unresolved_preserves_mention_type(self, resolver: SymbolResolver) -> None: """Even empty results carry the mention_type.""" result = resolver.resolve("UnknownCorp", mention_type=MentionType.inferred) assert result.mention_type == MentionType.inferred assert len(result.candidates) == 0 def test_mention_type_enum_values(self) -> None: assert MentionType.explicit.value == "explicit" assert MentionType.inferred.value == "inferred" # --- Tests: Unresolved Mentions Preserved (Task 24.4) --- class TestUnresolvedMentions: """Test that unresolved mentions are preserved without invented tickers.""" def test_unknown_company_returns_empty(self, resolver: SymbolResolver) -> None: result = resolver.resolve("Palantir Technologies") assert len(result.candidates) == 0 assert not result.is_ambiguous def test_unknown_returns_unresolved_mention(self, resolver: SymbolResolver) -> None: result = resolver.resolve_or_unresolved( "Palantir Technologies", start_char=0, end_char=21 ) assert isinstance(result, UnresolvedMention) assert result.reason == UnresolvedReason.not_in_registry assert result.literal_text == "Palantir Technologies" def test_unresolved_preserves_offsets(self, resolver: SymbolResolver) -> None: result = resolver.resolve_or_unresolved( "SomeUnknownCo", start_char=42, end_char=55 ) assert isinstance(result, UnresolvedMention) assert result.start_char == 42 assert result.end_char == 55 def test_no_ticker_invented_for_unknown(self, resolver: SymbolResolver) -> None: """The resolver MUST NOT invent a ticker for unresolved mentions.""" result = resolver.resolve("Palantir Technologies") # No candidates means no ticker was invented assert len(result.candidates) == 0 # Using resolve_or_unresolved, canonical_id equivalent is None (UnresolvedMention) unresolved = resolver.resolve_or_unresolved( "Palantir Technologies", start_char=0, end_char=21 ) assert isinstance(unresolved, UnresolvedMention) # Verify the literal text is preserved exactly as given assert unresolved.literal_text == "Palantir Technologies" def test_empty_mention_returns_empty(self, resolver: SymbolResolver) -> None: result = resolver.resolve("") assert len(result.candidates) == 0 def test_gibberish_returns_empty(self, resolver: SymbolResolver) -> None: result = resolver.resolve("xyzzy123abc") assert len(result.candidates) == 0 def test_ambiguous_marked_as_unresolved(self, ambiguous_resolver: SymbolResolver) -> None: """Ambiguous aliases return UnresolvedMention with reason=ambiguous.""" result = ambiguous_resolver.resolve_or_unresolved( "Carrier", start_char=10, end_char=17 ) assert isinstance(result, UnresolvedMention) assert result.reason == UnresolvedReason.ambiguous assert result.literal_text == "Carrier" assert result.start_char == 10 assert result.end_char == 17 # --- Tests: Aliases Shared by Multiple Companies (Task 24.5) --- class TestSharedAliases: """Test behavior when aliases are shared by multiple companies.""" def test_shared_alias_returns_multiple_candidates( self, ambiguous_resolver: SymbolResolver ) -> None: result = ambiguous_resolver.resolve("Carrier") assert len(result.candidates) == 2 tickers = {c.ticker for c in result.candidates} assert "CARR" in tickers assert "CSX" in tickers def test_shared_alias_is_ambiguous(self, ambiguous_resolver: SymbolResolver) -> None: result = ambiguous_resolver.resolve("Carrier") # Both are alias matches with the same confidence, so margin = 0 assert result.is_ambiguous assert result.ambiguity_margin < 0.15 def test_three_companies_share_alias( self, multi_ambiguous_resolver: SymbolResolver ) -> None: """Three companies sharing 'Mercury' → all three returned as candidates.""" result = multi_ambiguous_resolver.resolve("Mercury") assert len(result.candidates) == 3 tickers = {c.ticker for c in result.candidates} assert "MCY" in tickers assert "MRCY" in tickers assert "MERC" in tickers def test_three_way_shared_alias_ambiguity_margin( self, multi_ambiguous_resolver: SymbolResolver ) -> None: """With 3 same-confidence candidates, margin between top-2 is 0.""" result = multi_ambiguous_resolver.resolve("Mercury") assert result.ambiguity_margin == pytest.approx(0.0) assert result.is_ambiguous def test_unique_alias_among_shared( self, multi_ambiguous_resolver: SymbolResolver ) -> None: """'Mercury Systems' is unique to MRCY even though 'Mercury' is shared.""" result = multi_ambiguous_resolver.resolve("Mercury Systems") assert len(result.candidates) == 1 assert result.candidates[0].ticker == "MRCY" assert not result.is_ambiguous assert result.ambiguity_margin == 1.0 def test_shared_alias_all_candidates_have_scores( self, ambiguous_resolver: SymbolResolver ) -> None: """All candidates from a shared alias should have valid confidence scores.""" result = ambiguous_resolver.resolve("Carrier") for candidate in result.candidates: assert 0.0 <= candidate.confidence <= 1.0 assert candidate.match_type == MatchType.alias def test_shared_alias_companies_have_distinct_ids( self, ambiguous_resolver: SymbolResolver ) -> None: """Shared-alias candidates should have unique company_ids.""" result = ambiguous_resolver.resolve("Carrier") company_ids = [c.company_id for c in result.candidates] assert len(company_ids) == len(set(company_ids)) def test_ticker_not_shared_even_when_alias_is( self, ambiguous_resolver: SymbolResolver ) -> None: """Direct ticker lookup for CARR is unambiguous even if 'Carrier' is shared.""" result = ambiguous_resolver.resolve("CARR") assert len(result.candidates) == 1 assert result.candidates[0].ticker == "CARR" assert not result.is_ambiguous def test_shared_alias_resolve_or_unresolved_returns_unresolved( self, ambiguous_resolver: SymbolResolver ) -> None: """resolve_or_unresolved returns UnresolvedMention for ambiguous aliases.""" result = ambiguous_resolver.resolve_or_unresolved( "Carrier", start_char=0, end_char=7 ) assert isinstance(result, UnresolvedMention) assert result.reason == UnresolvedReason.ambiguous # --- Tests: Exact Name Match --- class TestExactNameMatch: """Test resolving by full legal name.""" def test_apple_inc(self, resolver: SymbolResolver) -> None: result = resolver.resolve("Apple Inc.") assert len(result.candidates) == 1 assert result.candidates[0].ticker == "AAPL" assert result.candidates[0].match_type == MatchType.exact_name def test_alphabet_inc(self, resolver: SymbolResolver) -> None: result = resolver.resolve("Alphabet Inc.") # "Alphabet Inc." → normalized "alphabet" matches the alias entry. assert len(result.candidates) >= 1 assert result.candidates[0].ticker == "GOOGL" def test_microsoft_corporation(self, resolver: SymbolResolver) -> None: result = resolver.resolve("Microsoft Corporation") assert len(result.candidates) == 1 assert result.candidates[0].ticker == "MSFT" # --- Tests: Alias Match --- class TestAliasMatch: """Test resolving by known alias.""" def test_alphabet_alias(self, resolver: SymbolResolver) -> None: result = resolver.resolve("Alphabet") assert len(result.candidates) == 1 assert result.candidates[0].ticker == "GOOGL" def test_google_alias(self, resolver: SymbolResolver) -> None: result = resolver.resolve("Google") assert len(result.candidates) == 1 assert result.candidates[0].ticker == "GOOGL" assert result.candidates[0].match_type == MatchType.alias def test_facebook_alias(self, resolver: SymbolResolver) -> None: result = resolver.resolve("Facebook") assert len(result.candidates) == 1 assert result.candidates[0].ticker == "META" def test_iphone_product_alias(self, resolver: SymbolResolver) -> None: result = resolver.resolve("iPhone") assert len(result.candidates) == 1 assert result.candidates[0].ticker == "AAPL" def test_chase_alias(self, resolver: SymbolResolver) -> None: result = resolver.resolve("Chase") assert len(result.candidates) == 1 assert result.candidates[0].ticker == "JPM" # --- Tests: Case Insensitivity --- class TestCaseInsensitivity: """Test that matching is case-insensitive.""" def test_ticker_lowercase(self, resolver: SymbolResolver) -> None: result = resolver.resolve("aapl") assert len(result.candidates) == 1 assert result.candidates[0].ticker == "AAPL" def test_ticker_mixed_case(self, resolver: SymbolResolver) -> None: result = resolver.resolve("Aapl") assert len(result.candidates) == 1 assert result.candidates[0].ticker == "AAPL" def test_name_uppercase(self, resolver: SymbolResolver) -> None: result = resolver.resolve("MICROSOFT CORPORATION") assert len(result.candidates) == 1 assert result.candidates[0].ticker == "MSFT" def test_alias_mixed_case(self, resolver: SymbolResolver) -> None: result = resolver.resolve("GOOGLE") assert len(result.candidates) == 1 assert result.candidates[0].ticker == "GOOGL" def test_alias_all_lower(self, resolver: SymbolResolver) -> None: result = resolver.resolve("facebook") assert len(result.candidates) == 1 assert result.candidates[0].ticker == "META" # --- Tests: Suffix Variations --- class TestSuffixVariations: """Test that corporate suffixes (Inc, Corp, LLC) are stripped during matching.""" def test_without_inc(self, resolver: SymbolResolver) -> None: """'Apple' without 'Inc.' should still match via alias.""" result = resolver.resolve("Apple") assert len(result.candidates) == 1 assert result.candidates[0].ticker == "AAPL" def test_with_inc_period(self, resolver: SymbolResolver) -> None: result = resolver.resolve("Apple Inc.") assert len(result.candidates) == 1 assert result.candidates[0].ticker == "AAPL" def test_with_incorporated(self, resolver: SymbolResolver) -> None: result = resolver.resolve("Apple Incorporated") assert len(result.candidates) == 1 assert result.candidates[0].ticker == "AAPL" def test_corp_stripped(self, resolver: SymbolResolver) -> None: result = resolver.resolve("Microsoft Corp.") assert len(result.candidates) == 1 assert result.candidates[0].ticker == "MSFT" def test_corp_full(self, resolver: SymbolResolver) -> None: result = resolver.resolve("Microsoft Corp") assert len(result.candidates) == 1 assert result.candidates[0].ticker == "MSFT" def test_corporation_stripped(self, resolver: SymbolResolver) -> None: result = resolver.resolve("Microsoft Corporation") assert len(result.candidates) == 1 assert result.candidates[0].ticker == "MSFT" def test_llc_suffix(self) -> None: """Test that LLC suffix is stripped.""" resolver = SymbolResolver() resolver.load_registry([ { "id": "77777777-7777-7777-7777-777777777777", "ticker": "TEST", "legal_name": "TestCo LLC", "aliases": [], } ]) result = resolver.resolve("TestCo") assert len(result.candidates) == 1 assert result.candidates[0].ticker == "TEST" # --- Tests: AliasIndex Directly --- class TestAliasIndex: """Unit tests for the AliasIndex component.""" def test_normalize_strips_inc(self) -> None: assert AliasIndex.normalize("Apple Inc.") == "apple" def test_normalize_strips_corporation(self) -> None: assert AliasIndex.normalize("Microsoft Corporation") == "microsoft" def test_normalize_strips_llc(self) -> None: assert AliasIndex.normalize("SomeCompany LLC") == "somecompany" def test_normalize_strips_limited(self) -> None: assert AliasIndex.normalize("Acme Limited") == "acme" def test_normalize_preserves_meaningful_text(self) -> None: assert AliasIndex.normalize("Google") == "google" def test_normalize_collapses_whitespace(self) -> None: assert AliasIndex.normalize(" Apple Inc. ") == "apple" def test_empty_string(self) -> None: assert AliasIndex.normalize("") == "" def test_lookup_returns_empty_for_missing(self) -> None: idx = AliasIndex() assert idx.lookup("nonexistent") == [] def test_len(self) -> None: idx = AliasIndex() idx.add("Apple", IndexEntry("1", "AAPL", "Apple Inc.", "alias")) idx.add("Google", IndexEntry("2", "GOOGL", "Alphabet Inc.", "alias")) assert len(idx) == 2 def test_keys_returns_all_normalized_keys(self) -> None: idx = AliasIndex() idx.add("Apple", IndexEntry("1", "AAPL", "Apple Inc.", "alias")) idx.add("Google", IndexEntry("2", "GOOGL", "Alphabet Inc.", "alias")) keys = idx.keys() assert "apple" in keys assert "google" in keys # --- Tests: Explicit vs Inferred Classification (Task 24.3) --- class TestClassifyMention: """Test classify_mention separates explicit from inferred exposures.""" def test_explicit_with_direct_mention(self) -> None: """Company name in text with no relationship keywords → explicit.""" candidates = [ ResolutionCandidate( company_id="11111111-1111-1111-1111-111111111111", ticker="AAPL", name="Apple Inc.", confidence=0.95, match_type=MatchType.exact_ticker, ) ] context = "Apple announced record quarterly revenue of $94.8 billion." result = classify_mention("Apple", context, candidates) assert result == ClassifiedMentionType.explicit_mention def test_inferred_with_competitor_keyword(self) -> None: """Mention surrounded by competitor keywords → inferred.""" candidates = [ ResolutionCandidate( company_id="33333333-3333-3333-3333-333333333333", ticker="MSFT", name="Microsoft Corporation", confidence=0.80, match_type=MatchType.alias, ) ] context = "Apple's main competitor Microsoft may feel pressure from the announcement." result = classify_mention("Microsoft", context, candidates) assert result == ClassifiedMentionType.inferred_exposure def test_inferred_with_supplier_keyword(self) -> None: """Mention with supplier relationship keyword → inferred.""" candidates = [ ResolutionCandidate( company_id="22222222-2222-2222-2222-222222222222", ticker="NVDA", name="NVIDIA Corporation", confidence=0.95, match_type=MatchType.exact_ticker, ) ] context = "Tesla's key supplier NVDA could benefit from increased production volumes." result = classify_mention("NVDA", context, candidates) assert result == ClassifiedMentionType.inferred_exposure def test_unresolved_with_no_candidates(self) -> None: """No candidates → unresolved.""" context = "Palantir Technologies posted strong growth numbers." result = classify_mention("Palantir", context, []) assert result == ClassifiedMentionType.unresolved def test_explicit_even_with_relationship_word_when_attributed(self) -> None: """If explicit attribution keywords are present near the mention, stay explicit.""" candidates = [ ResolutionCandidate( company_id="33333333-3333-3333-3333-333333333333", ticker="MSFT", name="Microsoft Corporation", confidence=0.90, match_type=MatchType.exact_name, ) ] # "Microsoft announced" is explicit attribution even with "competitor" nearby context = "Microsoft announced earnings that beat competitor expectations." result = classify_mention("Microsoft", context, candidates) assert result == ClassifiedMentionType.explicit_mention def test_inferred_with_peer_keyword(self) -> None: """Sector peer reference → inferred.""" candidates = [ ResolutionCandidate( company_id="44444444-4444-4444-4444-444444444444", ticker="AMD", name="Advanced Micro Devices Inc.", confidence=0.80, match_type=MatchType.alias, ) ] context = "NVIDIA's results could impact sector peer AMD through changed market expectations." result = classify_mention("AMD", context, candidates) assert result == ClassifiedMentionType.inferred_exposure def test_explicit_when_mention_not_in_context(self) -> None: """If mention not found in context at all, default to explicit (alias match suffices).""" candidates = [ ResolutionCandidate( company_id="11111111-1111-1111-1111-111111111111", ticker="AAPL", name="Apple Inc.", confidence=0.95, match_type=MatchType.exact_ticker, ) ] # Context doesn't contain the mention text context = "Revenue increased significantly in Q4." result = classify_mention("AAPL", context, candidates) assert result == ClassifiedMentionType.explicit_mention def test_explicit_with_empty_context(self) -> None: """Empty context with valid candidates → explicit (alias resolution is enough).""" candidates = [ ResolutionCandidate( company_id="11111111-1111-1111-1111-111111111111", ticker="AAPL", name="Apple Inc.", confidence=0.95, match_type=MatchType.exact_ticker, ) ] result = classify_mention("AAPL", "", candidates) assert result == ClassifiedMentionType.explicit_mention def test_to_mention_type_explicit(self) -> None: """ClassifiedMentionType.explicit_mention → MentionType.explicit.""" assert to_mention_type(ClassifiedMentionType.explicit_mention) == MentionType.explicit def test_to_mention_type_inferred(self) -> None: """ClassifiedMentionType.inferred_exposure → MentionType.inferred.""" assert to_mention_type(ClassifiedMentionType.inferred_exposure) == MentionType.inferred def test_to_mention_type_unresolved(self) -> None: """ClassifiedMentionType.unresolved → MentionType.explicit (default).""" assert to_mention_type(ClassifiedMentionType.unresolved) == MentionType.explicit def test_inferred_with_exposure_keyword(self) -> None: """Direct use of 'exposure' keyword → inferred.""" candidates = [ ResolutionCandidate( company_id="55555555-5555-5555-5555-555555555555", ticker="INTC", name="Intel Corporation", confidence=0.80, match_type=MatchType.alias, ) ] context = "The tariffs create indirect exposure for INTC through its Asia supply chain." result = classify_mention("INTC", context, candidates) assert result == ClassifiedMentionType.inferred_exposure def test_enum_values(self) -> None: """Verify ClassifiedMentionType string values.""" assert ClassifiedMentionType.explicit_mention.value == "explicit_mention" assert ClassifiedMentionType.inferred_exposure.value == "inferred_exposure" assert ClassifiedMentionType.unresolved.value == "unresolved"