"""In-memory alias index for company name/ticker/alias lookup. Supports case-insensitive matching with common corporate suffix stripping (Inc., Corp., LLC, etc.) to maximize recall against varied document text. The primary entry point for consumers is `build_alias_index(companies)` which constructs a populated AliasIndex from company registry data. """ from __future__ import annotations import re from dataclasses import dataclass, field # Common corporate suffixes to strip for matching purposes. _SUFFIX_PATTERN = re.compile( r"\s*\b(" r"inc\.?|incorporated|" r"corp\.?|corporation|" r"co\.?|company|" r"ltd\.?|limited|" r"llc|l\.l\.c\.?|" r"plc|p\.l\.c\.?|" r"sa|s\.a\.?|" r"nv|n\.v\.?|" r"ag|" r"se|" r"group|" r"holdings?" r")\s*$", re.IGNORECASE, ) # Trailing punctuation after suffix removal. _TRAILING_PUNCT = re.compile(r"[.,;:\s]+$") @dataclass class IndexEntry: """A single entry linking a normalized key to a company.""" company_id: str ticker: str name: str match_type: str # "exact_ticker", "exact_name", "alias" @dataclass class AliasIndex: """Case-insensitive index mapping normalized strings to company entries. Stores tickers, full legal names, and known aliases. Returns all matching companies for a given query string. """ _entries: dict[str, list[IndexEntry]] = field(default_factory=dict) @staticmethod def normalize(text: str) -> str: """Normalize a string for matching: lowercase, strip suffixes and punctuation.""" s = text.strip().lower() # Strip corporate suffixes. s = _SUFFIX_PATTERN.sub("", s) # Remove trailing punctuation left behind. s = _TRAILING_PUNCT.sub("", s) # Collapse whitespace. s = re.sub(r"\s+", " ", s).strip() return s def add(self, key: str, entry: IndexEntry) -> None: """Add a lookup key mapped to an index entry.""" normalized = self.normalize(key) if not normalized: return self._entries.setdefault(normalized, []).append(entry) def lookup(self, query: str) -> list[IndexEntry]: """Return all entries matching the normalized query.""" normalized = self.normalize(query) if not normalized: return [] return list(self._entries.get(normalized, [])) def keys(self) -> list[str]: """Return all normalized keys in the index.""" return list(self._entries.keys()) def __len__(self) -> int: """Number of distinct normalized keys in the index.""" return len(self._entries) def build_alias_index(companies: list[dict]) -> AliasIndex: """Build a complete alias index from company registry data. This is the primary factory function for constructing an AliasIndex. It processes the same company dict format used by the symbol registry seed: Each company dict should contain: - id: str (company UUID) - ticker: str - legal_name: str Optional fields: - aliases: list[dict] with keys "alias" and optionally "alias_type" OR list[tuple[str, str]] of (alias_text, alias_type) - exchange: str (used for qualified ticker indexing) - sector: str - industry: str The function indexes: 1. Ticker symbols (exact_ticker match type) 2. Full legal names (exact_name match type) 3. Legal names with suffixes stripped (exact_name match type) 4. All known aliases (alias match type) Returns: A fully populated AliasIndex ready for lookup operations. """ index = AliasIndex() for company in companies: company_id = str(company["id"]) ticker = company["ticker"] name = company["legal_name"] # Index the ticker itself. entry_ticker = IndexEntry( company_id=company_id, ticker=ticker, name=name, match_type="exact_ticker", ) index.add(ticker, entry_ticker) # Index the full legal name. entry_name = IndexEntry( company_id=company_id, ticker=ticker, name=name, match_type="exact_name", ) index.add(name, entry_name) # Index known aliases. aliases = company.get("aliases", []) for alias_entry in aliases: if isinstance(alias_entry, dict): alias_text = alias_entry.get("alias", "") elif isinstance(alias_entry, (list, tuple)) and len(alias_entry) >= 1: alias_text = alias_entry[0] else: alias_text = str(alias_entry) if alias_text: entry_alias = IndexEntry( company_id=company_id, ticker=ticker, name=name, match_type="alias", ) index.add(alias_text, entry_alias) return index