range / src /range_curate /api.py
Brianferrell787's picture
Publish retained Phase60 CUDA preview with matching installation wheels
8947dc2 verified
Raw History Blame Contribute Delete
11.1 kB
"""Public API for deterministic financial-news relevance screening."""
from __future__ import annotations
import hashlib
import os
from collections.abc import Iterable, Sequence
from datetime import datetime
from functools import lru_cache
from pathlib import Path
from typing import Literal
from ._core.accelerated.indicator import AcceleratedRelevanceIndicator
from ._core.accelerated.compact import (
ArrowCompactFacts,
ColumnArticleBatch,
CompactRelevanceIndicator,
)
from ._core.models import Article, ExposureFamily, RelevanceDecision
from .backends import available_backends, create_backend
from .customization import (
DefaultRanker,
DefaultTaxonomy,
Ranker,
Taxonomy,
)
from .models import (
BackendInfo,
Prediction,
PythonScreeningBatch,
RankedPrediction,
)
TimestampLike = datetime | str
def _workers(value: int | None) -> int:
if value is None:
return max(1, min(10, os.cpu_count() or 1))
if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
raise ValueError("workers must be a positive integer or None")
return value
def _article_id(text: str, timestamp: TimestampLike) -> str:
payload = f"{timestamp}\0{text}".encode("utf-8")
return f"article-{hashlib.sha256(payload).hexdigest()[:20]}"
def _optional_length(
name: str,
values: Sequence[object] | None,
expected: int,
) -> None:
if values is not None and len(values) != expected:
raise ValueError(f"{name} length {len(values)} does not match {expected}")
class RelevanceEngine:
"""Exact rich prediction plus explicitly separate compact screening paths."""
__slots__ = (
"_backend",
"_indicator",
"_ranker",
"_screening_indicator",
"_taxonomy",
"_workers",
)
def __init__(
self,
taxonomy: Taxonomy | None = None,
*,
ranker: Ranker | None = None,
backend: Literal["cpu", "cuda"] = "cpu",
workers: int | None = None,
) -> None:
resolved_taxonomy = taxonomy or DefaultTaxonomy()
if not isinstance(resolved_taxonomy, Taxonomy):
raise TypeError("taxonomy must be a Taxonomy or None")
resolved_ranker = ranker or DefaultRanker()
if not isinstance(resolved_ranker, Ranker):
raise TypeError("ranker must inherit Ranker")
self._taxonomy = resolved_taxonomy
self._ranker = resolved_ranker
self._workers = _workers(workers)
self._backend = create_backend(backend)
self._indicator = AcceleratedRelevanceIndicator(
config=resolved_taxonomy.config,
catalogs=resolved_taxonomy.catalogs,
)
self._screening_indicator = CompactRelevanceIndicator(
config=resolved_taxonomy.config,
catalogs=resolved_taxonomy.catalogs,
)
@classmethod
def from_yaml(
cls,
path: str | Path,
*,
ranker: Ranker | None = None,
backend: Literal["cpu", "cuda"] = "cpu",
workers: int | None = None,
) -> "RelevanceEngine":
return cls(
Taxonomy.from_yaml(path),
ranker=ranker,
backend=backend,
workers=workers,
)
@property
def taxonomy(self) -> Taxonomy:
return self._taxonomy
@property
def ranker(self) -> Ranker:
return self._ranker
@property
def backend(self) -> str:
return self._backend.name
@property
def backend_info(self) -> BackendInfo:
return self._backend.describe(self._screening_indicator)
@property
def workers(self) -> int:
return self._workers
def predict(
self,
text: str,
*,
timestamp: TimestampLike,
article_id: str | None = None,
source: str = "unknown",
) -> Prediction:
"""Build one rich decision with evidence and explanation provenance."""
article = Article(
article_id=(
_article_id(text, timestamp) if article_id is None else article_id
),
timestamp=timestamp,
text=text,
source=source,
)
return self._indicator.predict(article)
def predict_batch(
self,
texts: Sequence[str],
*,
timestamps: Sequence[TimestampLike],
article_ids: Sequence[str] | None = None,
sources: Sequence[str] | None = None,
workers: int | None = None,
) -> tuple[Prediction, ...]:
"""Build rich decisions; use `screen_batch()` for throughput work."""
count = len(texts)
if len(timestamps) != count:
raise ValueError("texts and timestamps must have equal lengths")
_optional_length("article_ids", article_ids, count)
_optional_length("sources", sources, count)
articles = tuple(
Article(
article_id=(
article_ids[index] if article_ids is not None else f"row-{index}"
),
timestamp=timestamps[index],
text=text,
source=sources[index] if sources is not None else "unknown",
)
for index, text in enumerate(texts)
)
return self._indicator.predict_batch(
articles,
workers=_workers(workers) if workers is not None else self._workers,
)
def screen_batch(
self,
texts: Sequence[str],
*,
timestamps: Sequence[TimestampLike],
article_ids: Sequence[str] | None = None,
sources: Sequence[str] | None = None,
workers: int | None = None,
) -> PythonScreeningBatch:
"""Run compact list screening with implicit zero-observation rows."""
count = len(texts)
if len(timestamps) != count:
raise ValueError("texts and timestamps must have equal lengths")
_optional_length("article_ids", article_ids, count)
_optional_length("sources", sources, count)
ids = (
article_ids
if article_ids is not None
else tuple(f"row-{index}" for index in range(count))
)
batch = ColumnArticleBatch(
article_ids=ids,
timestamps=timestamps,
texts=texts,
sources=sources,
)
return self._backend.screen_batch(
self._screening_indicator,
batch,
workers=_workers(workers) if workers is not None else self._workers,
)
def screen_arrow(
self,
data,
*,
text_column: str = "text",
timestamp_column: str = "timestamp",
workers: int | None = None,
row_count: int | None = None,
) -> ArrowCompactFacts:
"""Run the sparse Arrow path without materializing per-row Python objects."""
if row_count is not None and (
isinstance(row_count, bool)
or not isinstance(row_count, int)
or row_count < 0
):
raise ValueError("row_count must be a non-negative integer or None")
discovered = getattr(data, "num_rows", None)
if discovered is not None:
discovered = int(discovered)
if row_count is None:
row_count = discovered
elif row_count != discovered:
raise ValueError(
f"row_count {row_count} does not match Arrow rows {discovered}"
)
return self._backend.screen_arrow(
self._screening_indicator,
self._taxonomy,
data,
text_column=text_column,
timestamp_column=timestamp_column,
workers=_workers(workers) if workers is not None else self._workers,
row_count=row_count,
)
def explain(
self,
prediction: RelevanceDecision,
*,
format: Literal["text", "mermaid"] = "text",
detail: Literal["summary", "details"] = "summary",
) -> str:
"""Format already-recorded evidence; this does not rerun prediction."""
if not isinstance(prediction, RelevanceDecision):
raise TypeError("prediction must be a rich Prediction")
if detail not in {"summary", "details"}:
raise ValueError("detail must be 'summary' or 'details'")
return self._indicator.explain(
prediction,
output_format=format,
detail=detail,
)
def rank(
self,
predictions: Iterable[Prediction],
*,
exposure_family: ExposureFamily | str,
decision_bucket: datetime | str,
limit: int | None = None,
) -> tuple[RankedPrediction, ...]:
return self._ranker.rank(
predictions,
config=self._taxonomy.config,
exposure_family=exposure_family,
decision_bucket=decision_bucket,
limit=limit,
)
@lru_cache(maxsize=1)
def _default_engine() -> RelevanceEngine:
return RelevanceEngine()
def predict(
text: str,
*,
timestamp: TimestampLike,
article_id: str | None = None,
source: str = "unknown",
) -> Prediction:
return _default_engine().predict(
text,
timestamp=timestamp,
article_id=article_id,
source=source,
)
def predict_batch(
texts: Sequence[str],
*,
timestamps: Sequence[TimestampLike],
article_ids: Sequence[str] | None = None,
sources: Sequence[str] | None = None,
workers: int | None = None,
) -> tuple[Prediction, ...]:
return _default_engine().predict_batch(
texts,
timestamps=timestamps,
article_ids=article_ids,
sources=sources,
workers=workers,
)
def screen_batch(
texts: Sequence[str],
*,
timestamps: Sequence[TimestampLike],
article_ids: Sequence[str] | None = None,
sources: Sequence[str] | None = None,
workers: int | None = None,
) -> PythonScreeningBatch:
return _default_engine().screen_batch(
texts,
timestamps=timestamps,
article_ids=article_ids,
sources=sources,
workers=workers,
)
def screen_arrow(
data,
*,
text_column: str = "text",
timestamp_column: str = "timestamp",
workers: int | None = None,
row_count: int | None = None,
) -> ArrowCompactFacts:
return _default_engine().screen_arrow(
data,
text_column=text_column,
timestamp_column=timestamp_column,
workers=workers,
row_count=row_count,
)
__all__ = [
"RelevanceEngine",
"available_backends",
"predict",
"predict_batch",
"screen_arrow",
"screen_batch",
]