Multi-stage evidence-grounded inference architecture replacing the monolithic 9B model extraction pipeline. CPU-first specialist services handle routine extraction while the 9B vLLM model is preserved for semantic adjudication of ambiguous cases. Key components: - Capability-aware inference gateway (OpenAI-compatible + Ollama) - Endpoint registry with DB migrations and REST API - Sentence-aware document segmenter (property tests) - Deterministic financial parsing with offset integrity - Symbol resolution with ambiguity detection - Specialist service (GLiNER2, dynamic batching, K8s deployment) - Company-specific sentiment (FinBERT, calibration) - Retrieval-based novelty and duplicate detection - Confidence calibration pipeline - Deterministic routing engine (property tests) - 9B adjudication layer with VRAM gating - Stock-specific impact model (features, labels, baseline, trained) - Pipeline orchestrator (state machine, queues, leases, feature flags) - Bounded parallelism (async workers, semaphore, load shedding) - Observability (tracing, metrics, alerts) - Compatibility adapter (v3→v2 golden mapping tests) - Shadow/canary promotion framework - Active learning and fine-tuning pipeline Test results: 1,161 tests pass, ruff lint clean. All 282 spec tasks completed.
838 lines
32 KiB
Python
838 lines
32 KiB
Python
"""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"
|