"""Dependency-free, explainable scoring for profile recommendations. Adapted as a public portfolio excerpt from Cloudy's matching service. In the application, an async repository layer supplies the profile attributes while this module remains intentionally independent of persistence and APIs. """ from __future__ import annotations import math from collections.abc import Iterable, Mapping SKILL_WEIGHT = 0.30 INTEREST_WEIGHT = 0.35 LOCATION_WEIGHT = 0.15 PROJECT_TAG_WEIGHT = 0.20 LOCATION_DECAY_KM = 100.0 def _normalise(items: Iterable[str]) -> set[str]: """Return non-empty values as a case-insensitive set.""" return {item.strip().casefold() for item in items if item and item.strip()} def jaccard_similarity(left: Iterable[str], right: Iterable[str]) -> float: """Return set similarity in the inclusive range from 0.0 to 1.0.""" left_values = _normalise(left) right_values = _normalise(right) union = left_values | right_values if not union: return 0.0 return len(left_values & right_values) / len(union) def weighted_jaccard_similarity( left: Iterable[str], right: Iterable[str], affinity: Mapping[str, float] | None = None, ) -> float: """Personalise Jaccard similarity with optional preference weights. Values absent from ``affinity`` remain neutral at 1.0. Therefore omitting preferences preserves the result of ``jaccard_similarity`` exactly. """ left_values = _normalise(left) right_values = _normalise(right) union = left_values | right_values if not union: return 0.0 weights = affinity or {} shared_weight = sum(weights.get(value, 1.0) for value in left_values & right_values) total_weight = sum(weights.get(value, 1.0) for value in union) return shared_weight / total_weight if total_weight else 0.0 def haversine_distance_km( latitude_a: float, longitude_a: float, latitude_b: float, longitude_b: float ) -> float: """Calculate the great-circle distance between two geographic coordinates.""" earth_radius_km = 6_371.0 lat_a, lon_a, lat_b, lon_b = map( math.radians, (latitude_a, longitude_a, latitude_b, longitude_b) ) delta_lat = lat_b - lat_a delta_lon = lon_b - lon_a area = math.sin(delta_lat / 2) ** 2 + math.cos(lat_a) * math.cos(lat_b) * math.sin(delta_lon / 2) ** 2 return earth_radius_km * 2 * math.asin(math.sqrt(area)) def location_similarity( left: tuple[float | None, float | None] | None, right: tuple[float | None, float | None] | None, decay_km: float = LOCATION_DECAY_KM, ) -> float: """Turn distance into a linear score; incomplete coordinates score 0.0.""" if left is None or right is None or None in (*left, *right): return 0.0 distance = haversine_distance_km(*left, *right) # type: ignore[arg-type] return max(0.0, 1.0 - distance / decay_km) def recommendation_score( skill_similarity: float, interest_similarity: float, location_similarity_score: float, project_tag_similarity: float, ) -> float: """Combine the four transparent score components into one ranking value.""" return ( SKILL_WEIGHT * skill_similarity + INTEREST_WEIGHT * interest_similarity + LOCATION_WEIGHT * location_similarity_score + PROJECT_TAG_WEIGHT * project_tag_similarity )