range / src /range_curate /_core /models.py
Brianferrell787's picture
RANGE: clean package identity, example taxonomy API, Gradio 6.27.0
c4aee46 verified
Raw History Blame Contribute Delete
15.8 kB
"""Immutable public data contracts for news relevance decisions."""
from __future__ import annotations
import html
from collections.abc import Mapping
from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum
from types import MappingProxyType
from zoneinfo import ZoneInfo
_ARTICLE_TIMEZONE = ZoneInfo("America/New_York")
def parse_article_time(value: datetime | str) -> datetime:
"""Parse an article time, interpreting naive values as America/New_York."""
if isinstance(value, datetime):
timestamp = value
elif isinstance(value, str) and value.strip():
timestamp = datetime.fromisoformat(value.strip())
else:
raise TypeError("timestamp must be a datetime or non-empty ISO-8601 string")
if timestamp.tzinfo is None:
timestamp = timestamp.replace(tzinfo=_ARTICLE_TIMEZONE)
return timestamp
class ArticleAdmissibilityStatus(str, Enum):
ACCEPTED = "accepted"
PLAUSIBLE_UNRESOLVED = "plausible_unresolved"
REJECTED = "rejected"
class ExposureRelevanceStatus(str, Enum):
SUPPORTED = "supported"
PLAUSIBLE_UNRESOLVED = "plausible_unresolved"
NOT_SUPPORTED = "not_supported"
class ExposureFamily(str, Enum):
SP500 = "EXPOSURE:SP500"
NASDAQ100 = "EXPOSURE:NASDAQ100"
RUSSELL2000 = "EXPOSURE:RUSSELL2000"
MARKET_WIDE = "MARKET_WIDE"
@property
def label(self) -> str:
"""Return the concise name used in user-facing summaries."""
return {
ExposureFamily.SP500: "S&P 500",
ExposureFamily.NASDAQ100: "Nasdaq-100",
ExposureFamily.RUSSELL2000: "Russell 2000",
ExposureFamily.MARKET_WIDE: "Broad market",
}[self]
class IdentityStatus(str, Enum):
ACCEPTED = "accepted"
AMBIGUOUS = "ambiguous"
REJECTED = "rejected"
UNRESOLVED = "unresolved"
class EvidenceStatus(str, Enum):
ACCEPTED = "accepted"
UNRESOLVED = "unresolved"
REJECTED = "rejected"
@dataclass(frozen=True, slots=True)
class Article:
article_id: str
timestamp: datetime | str
text: str
source: str = "unknown"
content_id: str | None = None
decision_bucket: str | None = None
def __post_init__(self) -> None:
if not self.article_id.strip():
raise ValueError("article_id must be non-empty")
if not isinstance(self.text, str) or not self.text.strip():
raise ValueError("text must be a non-empty string")
object.__setattr__(self, "timestamp", parse_article_time(self.timestamp))
object.__setattr__(self, "content_id", self.content_id or self.article_id)
@dataclass(frozen=True, slots=True)
class Observation:
observation_id: str
article_id: str
matched_surface: str
span_start: int
span_end: int
entity_concept: str
detection_method: str
confidence: float | None = None
candidate_canonical_id: str | None = None
catalog_entry_id: str | None = None
catalog_version: str | None = None
rule_id: str | None = None
tag_type: str | None = None
event_family: str | None = None
market_wide_authorized: bool = False
context_status: str = "not_required"
source_id: str | None = None
def __post_init__(self) -> None:
if self.span_start < 0 or self.span_end <= self.span_start:
raise ValueError("observation offsets must describe a non-empty span")
if not self.observation_id or not self.article_id or not self.matched_surface:
raise ValueError("observation identity and surface must be non-empty")
if self.confidence is not None and not 0 <= self.confidence <= 1:
raise ValueError("confidence must be in [0, 1]")
@dataclass(frozen=True, slots=True)
class ObservationBundle:
article: Article
observations: tuple[Observation, ...] = ()
@dataclass(frozen=True, slots=True)
class IdentityResolution:
resolution_id: str
observation_id: str
article_id: str
status: IdentityStatus
canonical_id: str | None
reason_code: str
resolution_method: str
catalog_version: str | None = None
def __post_init__(self) -> None:
if self.status is IdentityStatus.ACCEPTED and not self.canonical_id:
raise ValueError("accepted identities require a canonical_id")
if self.status is not IdentityStatus.ACCEPTED and self.canonical_id is not None:
raise ValueError("only accepted identities may assign canonical_id")
@dataclass(frozen=True, slots=True)
class EvidencePath:
evidence_id: str
article_id: str
tag_type: str
tag_detail: str
matched_surface: str
detection_method: str
status: EvidenceStatus
link_status: str
target_id: str | None
exposure_family: ExposureFamily | None
reason_code: str
tier: int
observation_ids: tuple[str, ...] = ()
identity_resolution_ids: tuple[str, ...] = ()
relationship_ids: tuple[str, ...] = ()
catalog_version: str | None = None
event_family: str | None = None
explicit_market_wide_rule: bool = False
def __post_init__(self) -> None:
if self.tier < 0:
raise ValueError("evidence tier must be non-negative")
if self.status is EvidenceStatus.ACCEPTED and self.exposure_family is None:
raise ValueError("accepted exposure evidence requires an exposure family")
if self.link_status == "resolved_exposure" and not self.relationship_ids:
raise ValueError("resolved exposure evidence requires relationship lineage")
if self.exposure_family is ExposureFamily.MARKET_WIDE and (
self.link_status != "market_wide" or not self.explicit_market_wide_rule
):
raise ValueError("MARKET_WIDE requires an explicitly authorized rule")
@dataclass(frozen=True, slots=True)
class ArticleAdmissibilityDecision:
article_id: str
status: ArticleAdmissibilityStatus
accepted_evidence: tuple[EvidencePath, ...] = ()
unresolved_evidence: tuple[EvidencePath, ...] = ()
rejected_evidence: tuple[EvidencePath, ...] = ()
reason_codes: tuple[str, ...] = ()
@dataclass(frozen=True, slots=True)
class ExposureRelevanceDecision:
article_id: str
exposure_family: ExposureFamily
status: ExposureRelevanceStatus
evidence_paths: tuple[EvidencePath, ...] = ()
unresolved_paths: tuple[EvidencePath, ...] = ()
reason_codes: tuple[str, ...] = ()
def __post_init__(self) -> None:
if self.status is ExposureRelevanceStatus.SUPPORTED and not self.evidence_paths:
raise ValueError("supported exposure decisions require evidence lineage")
if (
self.status is ExposureRelevanceStatus.PLAUSIBLE_UNRESOLVED
and not self.unresolved_paths
):
raise ValueError(
"plausible unresolved exposure decisions require unresolved evidence"
)
if any(
path.exposure_family is not self.exposure_family
for path in self.evidence_paths
):
raise ValueError("evidence paths must support the decision exposure family")
@dataclass(frozen=True, slots=True, repr=False)
class RelevanceDecision:
article: Article
article_decision: ArticleAdmissibilityDecision
exposure_decisions: Mapping[ExposureFamily, ExposureRelevanceDecision]
observations: tuple[Observation, ...] = ()
resolutions: tuple[IdentityResolution, ...] = ()
def __post_init__(self) -> None:
if self.article.article_id != self.article_decision.article_id:
raise ValueError("article decision provenance mismatch")
decisions = dict(self.exposure_decisions)
if any(key is not value.exposure_family for key, value in decisions.items()):
raise ValueError("exposure decision mapping keys must match their values")
observation_ids = {row.observation_id for row in self.observations}
if len(observation_ids) != len(self.observations):
raise ValueError("observation IDs must be unique")
if any(row.article_id != self.article.article_id for row in self.observations):
raise ValueError("observation provenance mismatch")
resolution_ids = {row.resolution_id for row in self.resolutions}
if len(resolution_ids) != len(self.resolutions):
raise ValueError("identity resolution IDs must be unique")
for resolution in self.resolutions:
if resolution.article_id != self.article.article_id:
raise ValueError("identity resolution provenance mismatch")
if resolution.observation_id not in observation_ids:
raise ValueError(
"identity resolution references an unknown observation"
)
article_paths = (
*self.article_decision.accepted_evidence,
*self.article_decision.unresolved_evidence,
*self.article_decision.rejected_evidence,
)
exposure_paths = tuple(
path
for decision in decisions.values()
for path in (*decision.evidence_paths, *decision.unresolved_paths)
)
for path in (*article_paths, *exposure_paths):
if path.article_id != self.article.article_id:
raise ValueError("evidence path provenance mismatch")
if not set(path.observation_ids).issubset(observation_ids):
raise ValueError("evidence path references an unknown observation")
if not set(path.identity_resolution_ids).issubset(resolution_ids):
raise ValueError(
"evidence path references an unknown identity resolution"
)
object.__setattr__(self, "exposure_decisions", MappingProxyType(decisions))
@property
def status(self) -> str:
"""Return the plain-language overall result."""
return {
ArticleAdmissibilityStatus.ACCEPTED: "relevant",
ArticleAdmissibilityStatus.PLAUSIBLE_UNRESOLVED: "needs_review",
ArticleAdmissibilityStatus.REJECTED: "not_relevant",
}[self.article_decision.status]
@property
def accepted(self) -> bool:
"""Whether the article has accepted financial evidence."""
return self.article_decision.status is ArticleAdmissibilityStatus.ACCEPTED
@property
def relevant_to(self) -> tuple[str, ...]:
"""Plain-language markets with a completed relevance path."""
return tuple(
family.label
for family in ExposureFamily
if (
(result := self.exposure_decisions.get(family)) is not None
and result.status is ExposureRelevanceStatus.SUPPORTED
)
)
@staticmethod
def _surface(path: EvidencePath) -> str:
surface = " ".join(path.matched_surface.split())
return surface if len(surface) <= 60 else surface[:57].rstrip() + "..."
@classmethod
def _why_for_path(cls, path: EvidencePath) -> str:
surface = cls._surface(path)
if path.link_status == "resolved_exposure" and path.exposure_family:
return f"{surface} connects to {path.exposure_family.label}"
if path.link_status == "direct_asset":
return f"fund or ticker found: {surface}"
if path.link_status == "direct_benchmark":
return f"index or benchmark found: {surface}"
if path.link_status == "market_wide":
return f"broad-market news found: {surface}"
return {
"macroeconomic_indicator": f"economic indicator found: {surface}",
"financial_regulator": f"financial regulator found: {surface}",
"sector_or_industry": f"industry or sector found: {surface}",
"company_event": f"company event found: {surface}",
}.get(path.tag_type, f"financial information found: {surface}")
@property
def why(self) -> str:
"""Return a short explanation; detailed lineage remains available separately."""
if self.article_decision.status is ArticleAdmissibilityStatus.REJECTED:
return "No financial match found."
paths = (
self.article_decision.accepted_evidence
if self.article_decision.status is ArticleAdmissibilityStatus.ACCEPTED
else self.article_decision.unresolved_evidence
)
phrases: list[str] = []
seen: set[str] = set()
for path in sorted(
paths,
key=lambda item: (
item.tier,
item.matched_surface.casefold(),
item.evidence_id,
),
):
key = path.matched_surface.casefold().strip()
if key in seen:
continue
seen.add(key)
phrases.append(self._why_for_path(path))
if not phrases:
return (
"Possible financial information was found, but it needs review."
if self.status == "needs_review"
else "Financial information found."
)
visible = phrases[:3]
reason = "; ".join(visible)
if len(phrases) > len(visible):
reason += f"; and {len(phrases) - len(visible)} more match(es)"
if self.status == "needs_review":
reason += ", but it could not be connected to a configured market"
return reason[0].upper() + reason[1:] + "."
def summary(self) -> dict[str, object]:
"""Return the small, JSON-friendly result most callers need."""
return {
"article_id": self.article.article_id,
"status": self.status,
"accepted": self.accepted,
"why": self.why,
"relevant_to": list(self.relevant_to),
}
def __repr__(self) -> str:
return (
"RelevanceDecision("
f"article_id={self.article.article_id!r}, "
f"status={self.status!r}, "
f"relevant_to={self.relevant_to!r}, "
f"why={self.why!r})"
)
def _repr_markdown_(self) -> str:
"""Render a concise decision card in Jupyter without an IPython dependency."""
def markdown(value: object) -> str:
escaped = html.escape(str(value))
for character in ("\\", "`", "*", "_", "[", "]"):
escaped = escaped.replace(character, f"\\{character}")
return escaped
heading = {
"relevant": "Keep",
"needs_review": "Check",
"not_relevant": "Ignore",
}[self.status]
lines = [f"### {heading}", "", f"**Why:** {markdown(self.why)}"]
if self.relevant_to:
lines.extend(("", f"**Markets:** {', '.join(self.relevant_to)}"))
lines.extend(
(
"",
(
f"*{markdown(self.article.source)} | "
f"{markdown(self.article.timestamp.isoformat())} | "
f"{markdown(self.article.article_id)}*"
),
)
)
return "\n".join(lines)
@dataclass(frozen=True, slots=True)
class RankedCandidate:
decision_bucket: str
exposure_family: ExposureFamily
rank: int
article_id: str
accepted_evidence_ids: tuple[str, ...]
decision: RelevanceDecision = field(compare=False, repr=False)