"""Lightweight learned ranking models for candidate prioritization.""" from __future__ import annotations import json from functools import lru_cache from pathlib import Path from typing import Any def resolve_ranking_model_path(model_path: str) -> Path: path = Path(model_path) if path.is_absolute(): return path return Path.cwd() / path @lru_cache(maxsize=16) def load_ranking_model(model_path: str) -> dict[str, Any]: path = resolve_ranking_model_path(model_path) return json.loads(path.read_text()) def compute_ranking_model_score( row: dict[str, Any], model_path: str | None, ) -> float | None: if not model_path: return None model = load_ranking_model(model_path) model_type = str(model.get("model_type", "")).lower() if model_type != "bucket_blend_v1": return None features = model.get("features", []) total_weight = 0.0 weighted_sum = 0.0 for feature in features: weight = float(feature.get("weight", 0.0)) if weight <= 0: continue key = _resolve_feature_key(str(feature.get("name", "")), row) if key is None: continue value = feature.get("values", {}).get(key) if value is None: continue weighted_sum += weight * float(value) total_weight += weight if total_weight <= 0: fallback = model.get("global_mean") return float(fallback) if fallback is not None else None return weighted_sum / total_weight def _resolve_feature_key(name: str, row: dict[str, Any]) -> str | None: if name == "event_type": return _safe_text(row.get("event_type")) if name == "direction_guidance_combo": direction = _safe_text(row.get("event_direction")) guidance = _safe_text(row.get("guidance_status")) if direction is None or guidance is None: return None return f"{direction}|{guidance}" if name == "reaction_bucket": return _bucketize( _safe_float(row.get("reaction_day_return")), [0.03, 0.05, 0.08, 0.12, 0.18, 0.25], ) if name == "close_bucket": return _bucketize( _safe_float(row.get("close_location")), [0.45, 0.55, 0.65, 0.70, 0.75, 0.83], ) if name == "volume_bucket": return _bucketize( _safe_float(row.get("volume_ratio_20d")), [1.0, 1.5, 2.0, 3.0, 4.0, 6.0], ) if name == "gap_bucket": return _bucketize( _safe_float(row.get("gap_size")), [0.0, 0.02, 0.05, 0.08, 0.15], ) if name == "document_bucket": return _bucketize( _safe_float(row.get("document_quality_score")), [0.60, 0.66, 0.70, 0.75, 0.80, 0.85], ) if name == "confidence_bucket": return _bucketize( _safe_float(row.get("parse_confidence_overall")), [0.60, 0.64, 0.70, 0.75, 0.80, 0.90], ) return None def _bucketize(value: float | None, edges: list[float]) -> str | None: if value is None or not edges: return None if value < edges[0]: return f"lt:{edges[0]:.4f}" for low, high in zip(edges, edges[1:]): if low <= value < high: return f"{low:.4f}:{high:.4f}" return f"ge:{edges[-1]:.4f}" def _safe_float(raw: Any) -> float | None: try: return float(raw) except (TypeError, ValueError): return None def _safe_text(raw: Any) -> str | None: if raw is None: return None text = str(raw).strip().lower() return text or None