evalstate/diffusers-pr-api
0
1from __future__ import annotations2 3import json4from collections import defaultdict5from collections.abc import Mapping, Sequence6from dataclasses import asdict7from datetime import UTC, datetime8from pathlib import Path9from typing import Any10 11from slop_farmer.config import PrSearchRefreshOptions12from slop_farmer.data.parquet_io import read_json, read_parquet_rows13from slop_farmer.data.snapshot_source import resolve_snapshot_source_dir14from slop_farmer.reports.pr_heuristics import (15 compile_cluster_suppression_rules,16 suppressed_pull_request_reasons,17)18from slop_farmer.reports.pr_scope import (19 PrScopeCluster,20 PrScopeClusterOptions,21 _build_scope_profile,22 _feature_idf,23 _include_pull_request,24 _normalize_vector,25 _pairwise_comparisons,26 build_pr_scope_clusters,27)28 29FEATURE_VERSION = "pr_scope_v1"30CANDIDATE_FORMULA_VERSION = "scope_cluster_candidate_v1"31DEFAULT_CANDIDATE_LIMIT = 532 33 34def resolve_pr_search_snapshot_dir(options: PrSearchRefreshOptions) -> Path:35 return resolve_snapshot_source_dir(36 snapshot_dir=options.snapshot_dir,37 local_snapshots_root=options.output_dir.resolve() / "snapshots",38 hf_repo_id=options.hf_repo_id,39 hf_revision=options.hf_revision,40 hf_materialize_dir=options.hf_materialize_dir,41 hf_output_dir=options.output_dir,42 )43 44 45def load_pr_search_snapshot(snapshot_dir: Path) -> dict[str, Any]:46 manifest_path = snapshot_dir / "manifest.json"47 manifest = read_json(manifest_path) if manifest_path.exists() else {}48 pull_requests = read_parquet_rows(snapshot_dir / "pull_requests.parquet")49 pr_files = read_parquet_rows(snapshot_dir / "pr_files.parquet")50 contributors = read_parquet_rows(snapshot_dir / "new_contributors.parquet")51 repo = manifest.get("repo") or (pull_requests[0].get("repo") if pull_requests else None) or ""52 snapshot_id = manifest.get("snapshot_id") or snapshot_dir.name53 return {54 "repo": repo,55 "snapshot_id": snapshot_id,56 "manifest": manifest,57 "pull_requests": pull_requests,58 "pr_files": pr_files,59 "contributors": contributors,60 }61 62 63def build_pr_scope_search_artifacts(64 pull_requests: Sequence[Mapping[str, Any]],65 pr_files: Sequence[Mapping[str, Any]],66 *,67 options: PrScopeClusterOptions | None = None,68 suppression_rules: Sequence[Mapping[str, Any]] = (),69 limit_prs: int | None = None,70) -> dict[str, Any]:71 settings = options or PrScopeClusterOptions()72 suppressed_prs = suppressed_pull_request_reasons(73 pull_requests,74 pr_files,75 compile_cluster_suppression_rules(suppression_rules),76 )77 active_prs = [78 row79 for row in pull_requests80 if _include_pull_request(row, settings) and int(row["number"]) not in suppressed_prs81 ]82 active_prs.sort(key=lambda row: int(row["number"]))83 if limit_prs is not None:84 if limit_prs < 1:85 raise ValueError("--limit-prs must be at least 1")86 active_prs = active_prs[:limit_prs]87 88 active_numbers = {int(row["number"]) for row in active_prs if row.get("number") is not None}89 filtered_pr_files = [90 row91 for row in pr_files92 if row.get("pull_request_number") is not None93 and int(row["pull_request_number"]) in active_numbers94 ]95 files_by_pr: defaultdict[int, list[Mapping[str, Any]]] = defaultdict(list)96 for row in filtered_pr_files:97 files_by_pr[int(row["pull_request_number"])].append(row)98 99 profiles = [100 _build_scope_profile(row, files_by_pr.get(int(row["number"]), []), settings)101 for row in active_prs102 ]103 feature_idf = _feature_idf(profiles, settings) if profiles else {}104 for profile in profiles:105 profile.vector = _normalize_vector(106 {107 feature: weight * feature_idf[feature]108 for feature, weight in profile.raw_vector.items()109 if feature in feature_idf110 }111 )112 113 comparisons = _pairwise_comparisons(profiles, settings) if len(profiles) > 1 else []114 comparison_rows = {_pair_key(entry.left, entry.right): entry for entry in comparisons}115 neighbor_rankings = _neighbor_rankings(comparisons, settings)116 clusters = build_pr_scope_clusters(117 active_prs,118 filtered_pr_files,119 options=settings,120 suppression_rules=suppression_rules,121 )122 123 documents = [_document_row(row) for row in active_prs]124 features = [_feature_row(profile) for profile in profiles]125 neighbors = _neighbor_rows(neighbor_rankings)126 cluster_rows = [_cluster_row(cluster) for cluster in clusters]127 cluster_members = _cluster_member_rows(clusters)128 cluster_candidates = _cluster_candidate_rows(129 profiles=profiles,130 comparison_rows=comparison_rows,131 clusters=clusters,132 )133 settings_json = {134 **asdict(settings),135 "feature_version": FEATURE_VERSION,136 "candidate_formula_version": CANDIDATE_FORMULA_VERSION,137 }138 return {139 "documents": documents,140 "features": features,141 "run_artifact": {142 "feature_version": FEATURE_VERSION,143 "idf_json": feature_idf,144 },145 "neighbors": neighbors,146 "clusters": cluster_rows,147 "cluster_members": cluster_members,148 "cluster_candidates": cluster_candidates,149 "settings_json": settings_json,150 }151 152 153def build_scope_feature_idf_for_indexed_documents(154 indexed_documents: Sequence[Mapping[str, Any]],155 pr_files: Sequence[Mapping[str, Any]],156 *,157 options: PrScopeClusterOptions | None = None,158) -> dict[str, float]:159 settings = options or PrScopeClusterOptions()160 indexed_numbers = {161 int(row["pr_number"]) for row in indexed_documents if row.get("pr_number") is not None162 }163 files_by_pr: defaultdict[int, list[Mapping[str, Any]]] = defaultdict(list)164 for row in pr_files:165 pr_number = row.get("pull_request_number")166 if pr_number is None:167 continue168 number = int(pr_number)169 if number in indexed_numbers:170 files_by_pr[number].append(row)171 profiles = [172 _build_scope_profile(173 _document_to_profile_row(row),174 files_by_pr.get(int(row["pr_number"]), []),175 settings,176 )177 for row in indexed_documents178 if row.get("pr_number") is not None179 ]180 return _feature_idf(profiles, settings)181 182 183def build_scope_feature_for_pull_request(184 pr_row: Mapping[str, Any],185 pr_files: Sequence[Mapping[str, Any]],186 *,187 feature_idf: Mapping[str, float],188 options: PrScopeClusterOptions | None = None,189) -> dict[str, Any]:190 settings = options or PrScopeClusterOptions()191 profile = _build_scope_profile(pr_row, pr_files, settings)192 profile.vector = _normalize_vector(193 {194 feature: weight * feature_idf[feature]195 for feature, weight in profile.raw_vector.items()196 if feature in feature_idf197 }198 )199 return _feature_row(profile)200 201 202def rank_scope_feature_matches(203 query_feature: Mapping[str, Any],204 indexed_features: Sequence[Mapping[str, Any]],205 *,206 options: PrScopeClusterOptions | None = None,207 limit: int = 10,208) -> list[dict[str, Any]]:209 settings = options or PrScopeClusterOptions()210 rows: list[dict[str, Any]] = []211 query_pr_number = int(query_feature["pr_number"])212 for feature in indexed_features:213 if int(feature["pr_number"]) == query_pr_number:214 continue215 pair = scope_feature_pair_explanation(query_feature, feature, options=settings)216 if pair["similarity"] < settings.min_similarity:217 continue218 rows.append(pair)219 rows.sort(220 key=lambda row: (221 -float(row["similarity"]),222 -float(row["content_similarity"]),223 int(row["right_pr_number"]),224 )225 )226 return rows[:limit]227 228 229def rank_scope_cluster_candidates(230 *,231 similarity_rows: Sequence[Mapping[str, Any]],232 clusters: Sequence[Mapping[str, Any]],233 cluster_members: Mapping[str, Sequence[int]],234 assigned_cluster_ids: set[str] | None = None,235 limit: int = DEFAULT_CANDIDATE_LIMIT,236) -> list[dict[str, Any]]:237 similarities_by_pr = {238 int(row["right_pr_number"]): row239 for row in similarity_rows240 if row.get("right_pr_number") is not None241 }242 candidate_rows: list[dict[str, Any]] = []243 assigned = assigned_cluster_ids or set()244 for cluster in clusters:245 cluster_id = str(cluster["cluster_id"])246 member_rows = [247 (member_pr_number, similarities_by_pr.get(member_pr_number))248 for member_pr_number in cluster_members.get(cluster_id, ())249 ]250 member_similarities = [251 (member_pr_number, similarity_row)252 for member_pr_number, similarity_row in member_rows253 if similarity_row is not None and float(similarity_row["similarity"]) > 0.0254 ]255 if not member_similarities and cluster_id not in assigned:256 continue257 member_similarities.sort(key=lambda item: (-float(item[1]["similarity"]), item[0]))258 top_similarities = [float(entry["similarity"]) for _, entry in member_similarities[:3]]259 max_member_similarity = top_similarities[0] if top_similarities else 0.0260 avg_top_member_similarity = (261 sum(top_similarities) / len(top_similarities) if top_similarities else 0.0262 )263 matched_member_count = len(member_similarities)264 best_member_pr_number = member_similarities[0][0] if member_similarities else None265 best_match = member_similarities[0][1] if member_similarities else None266 candidate_score = (267 max_member_similarity * 0.60268 + avg_top_member_similarity * 0.30269 + min(matched_member_count, 3) / 3.0 * 0.10270 )271 evidence = {272 "matched_member_pr_numbers": [member for member, _ in member_similarities[:5]],273 "best_member_pr_number": best_member_pr_number,274 "best_shared_filenames": (275 list(best_match["shared_filenames"][:5]) if best_match is not None else []276 ),277 "best_shared_directories": (278 list(best_match["shared_directories"][:5]) if best_match is not None else []279 ),280 "reason": _candidate_reason(281 matched_member_count=matched_member_count,282 best_comparison=best_match,283 ),284 }285 candidate_rows.append(286 {287 "cluster_id": cluster_id,288 "candidate_score": candidate_score,289 "matched_member_count": matched_member_count,290 "best_member_pr_number": best_member_pr_number,291 "max_member_similarity": max_member_similarity,292 "avg_top_member_similarity": avg_top_member_similarity,293 "evidence": evidence,294 "assigned": cluster_id in assigned,295 }296 )297 candidate_rows.sort(298 key=lambda row: (299 -float(row["candidate_score"]),300 -int(row["matched_member_count"]),301 str(row["cluster_id"]),302 )303 )304 for rank, row in enumerate(candidate_rows[:limit], start=1):305 row["candidate_rank"] = rank306 return candidate_rows[:limit]307 308 309def scope_feature_pair_explanation(310 left_feature: Mapping[str, Any],311 right_feature: Mapping[str, Any],312 *,313 options: PrScopeClusterOptions | None = None,314) -> dict[str, Any]:315 settings = options or PrScopeClusterOptions()316 weight_total = (317 settings.content_weight318 + settings.size_weight319 + settings.breadth_weight320 + settings.concentration_weight321 )322 if weight_total <= 0.0:323 raise ValueError("PR scope similarity weights must sum to a positive value.")324 325 left_vector = _json_dict(left_feature.get("vector_json"))326 right_vector = _json_dict(right_feature.get("vector_json"))327 left_filenames = set(_json_list(left_feature.get("filenames_json")))328 right_filenames = set(_json_list(right_feature.get("filenames_json")))329 left_directories = set(_json_list(left_feature.get("directories_json")))330 right_directories = set(_json_list(right_feature.get("directories_json")))331 content_similarity = _cosine_similarity(left_vector, right_vector)332 if (333 content_similarity <= 0.0334 and not left_filenames.intersection(right_filenames)335 and not left_directories.intersection(right_directories)336 ):337 similarity = 0.0338 else:339 size_similarity = _ratio_similarity(340 int(left_feature.get("total_changed_lines") or 0),341 int(right_feature.get("total_changed_lines") or 0),342 )343 breadth_similarity = (344 _ratio_similarity(345 int(left_feature.get("file_count") or 0),346 int(right_feature.get("file_count") or 0),347 )348 + _ratio_similarity(349 int(left_feature.get("directory_count") or 0),350 int(right_feature.get("directory_count") or 0),351 )352 ) / 2.0353 concentration_similarity = max(354 0.0,355 1.0356 - abs(357 float(left_feature.get("dominant_dir_share") or 0.0)358 - float(right_feature.get("dominant_dir_share") or 0.0)359 ),360 )361 similarity = (362 content_similarity * settings.content_weight363 + size_similarity * settings.size_weight364 + breadth_similarity * settings.breadth_weight365 + concentration_similarity * settings.concentration_weight366 ) / weight_total367 return {368 "left_pr_number": int(left_feature["pr_number"]),369 "right_pr_number": int(right_feature["pr_number"]),370 "similarity": similarity,371 "content_similarity": content_similarity,372 "size_similarity": size_similarity,373 "breadth_similarity": breadth_similarity,374 "concentration_similarity": concentration_similarity,375 "shared_filenames": sorted(left_filenames & right_filenames)[:10],376 "shared_directories": sorted(377 left_directories & right_directories,378 key=lambda value: (-value.count("/"), value),379 )[:10],380 }381 return {382 "left_pr_number": int(left_feature["pr_number"]),383 "right_pr_number": int(right_feature["pr_number"]),384 "similarity": similarity,385 "content_similarity": content_similarity,386 "size_similarity": 0.0,387 "breadth_similarity": 0.0,388 "concentration_similarity": 0.0,389 "shared_filenames": [],390 "shared_directories": [],391 }392 393 394def scope_options_from_settings(settings_json: Mapping[str, Any] | None) -> PrScopeClusterOptions:395 if not settings_json:396 return PrScopeClusterOptions()397 defaults = asdict(PrScopeClusterOptions())398 values = {key: settings_json[key] for key in defaults if key in settings_json}399 return PrScopeClusterOptions(**values)400 401 402def iso_timestamp() -> str:403 return datetime.now(tz=UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z")404 405 406def _document_row(row: Mapping[str, Any]) -> dict[str, Any]:407 return {408 "pr_number": int(row["number"]),409 "github_id": row.get("github_id"),410 "author_login": row.get("author_login"),411 "state": row.get("state"),412 "draft": bool(row.get("draft")),413 "merged": bool(row.get("merged")),414 "title": row.get("title") or "",415 "base_ref": row.get("base_ref"),416 "created_at": row.get("created_at"),417 "updated_at": row.get("updated_at"),418 "merged_at": row.get("merged_at"),419 "additions": int(row.get("additions") or 0),420 "deletions": int(row.get("deletions") or 0),421 "changed_files": int(row.get("changed_files") or 0),422 "comments_count": int(row.get("comments_count") or 0),423 "review_comments_count": int(row.get("review_comments_count") or 0),424 "html_url": row.get("html_url"),425 }426 427 428def _document_to_profile_row(row: Mapping[str, Any]) -> dict[str, Any]:429 return {430 "number": int(row["pr_number"]),431 "additions": int(row.get("additions") or 0),432 "deletions": int(row.get("deletions") or 0),433 "changed_files": int(row.get("changed_files") or 0),434 }435 436 437def _feature_row(profile: Any) -> dict[str, Any]:438 return {439 "pr_number": profile.number,440 "feature_version": FEATURE_VERSION,441 "total_changed_lines": profile.total_changed_lines,442 "file_count": profile.file_count,443 "directory_count": profile.directory_count,444 "dominant_dir_share": profile.dominant_dir_share,445 "filenames_json": sorted(profile.filenames),446 "directories_json": sorted(profile.directories),447 "vector_json": profile.vector,448 }449 450 451def _neighbor_rankings(452 comparisons: Sequence[Any], options: PrScopeClusterOptions453) -> dict[int, list[dict[str, Any]]]:454 ranked: defaultdict[int, list[tuple[float, int, Any]]] = defaultdict(list)455 for entry in comparisons:456 if entry.similarity < options.min_similarity:457 continue458 ranked[entry.left].append((entry.similarity, entry.right, entry))459 ranked[entry.right].append((entry.similarity, entry.left, entry))460 461 results: dict[int, list[dict[str, Any]]] = {}462 for pr_number, items in ranked.items():463 ordered = sorted(items, key=lambda item: (-item[0], item[1]))[: options.max_neighbors]464 results[pr_number] = [465 {466 "other_pr_number": other_pr_number,467 "rank": rank,468 "comparison": comparison,469 }470 for rank, (_, other_pr_number, comparison) in enumerate(ordered, start=1)471 ]472 return results473 474 475def _neighbor_rows(476 neighbor_rankings: Mapping[int, Sequence[Mapping[str, Any]]],477) -> list[dict[str, Any]]:478 rows: dict[tuple[int, int], dict[str, Any]] = {}479 for pr_number, ranked_neighbors in neighbor_rankings.items():480 for ranked_neighbor in ranked_neighbors:481 comparison = ranked_neighbor["comparison"]482 left_pr = min(pr_number, int(ranked_neighbor["other_pr_number"]))483 right_pr = max(pr_number, int(ranked_neighbor["other_pr_number"]))484 pair_key = (left_pr, right_pr)485 row = rows.get(pair_key)486 if row is None:487 row = {488 "left_pr_number": left_pr,489 "right_pr_number": right_pr,490 "rank_from_left": None,491 "rank_from_right": None,492 "similarity": comparison.similarity,493 "content_similarity": comparison.content_similarity,494 "size_similarity": comparison.size_similarity,495 "breadth_similarity": comparison.breadth_similarity,496 "concentration_similarity": comparison.concentration_similarity,497 "shared_filenames_json": comparison.shared_filenames,498 "shared_directories_json": comparison.shared_directories,499 }500 rows[pair_key] = row501 if pr_number == left_pr:502 row["rank_from_left"] = int(ranked_neighbor["rank"])503 else:504 row["rank_from_right"] = int(ranked_neighbor["rank"])505 return [rows[key] for key in sorted(rows)]506 507 508def _cluster_row(cluster: PrScopeCluster) -> dict[str, Any]:509 return {510 "cluster_id": cluster.cluster_id,511 "representative_pr_number": cluster.representative_pr_number,512 "cluster_size": len(cluster.pr_numbers),513 "average_similarity": cluster.average_similarity,514 "summary": cluster.summary,515 "shared_filenames_json": cluster.shared_filenames,516 "shared_directories_json": cluster.shared_directories,517 }518 519 520def _cluster_member_rows(clusters: Sequence[PrScopeCluster]) -> list[dict[str, Any]]:521 rows: list[dict[str, Any]] = []522 for cluster in clusters:523 for pr_number in cluster.pr_numbers:524 rows.append(525 {526 "cluster_id": cluster.cluster_id,527 "pr_number": pr_number,528 "member_role": (529 "representative"530 if pr_number == cluster.representative_pr_number531 else "member"532 ),533 }534 )535 rows.sort(536 key=lambda row: (537 row["cluster_id"],538 row["member_role"] != "representative",539 row["pr_number"],540 )541 )542 return rows543 544 545def _cluster_candidate_rows(546 *,547 profiles: Sequence[Any],548 comparison_rows: Mapping[tuple[int, int], Any],549 clusters: Sequence[PrScopeCluster],550) -> list[dict[str, Any]]:551 cluster_ids_by_pr: defaultdict[int, set[str]] = defaultdict(set)552 cluster_members: dict[str, list[int]] = {}553 for cluster in clusters:554 cluster_members[cluster.cluster_id] = list(cluster.pr_numbers)555 for pr_number in cluster.pr_numbers:556 cluster_ids_by_pr[pr_number].add(cluster.cluster_id)557 558 rows: list[dict[str, Any]] = []559 for profile in sorted(profiles, key=lambda item: item.number):560 candidates = _cluster_candidates_for_pr(561 pr_number=profile.number,562 comparison_rows=comparison_rows,563 clusters=clusters,564 assigned_cluster_ids=cluster_ids_by_pr.get(profile.number, set()),565 cluster_members=cluster_members,566 )567 rows.extend(candidates)568 return rows569 570 571def _cluster_candidates_for_pr(572 *,573 pr_number: int,574 comparison_rows: Mapping[tuple[int, int], Any],575 clusters: Sequence[PrScopeCluster],576 assigned_cluster_ids: set[str],577 cluster_members: Mapping[str, Sequence[int]],578) -> list[dict[str, Any]]:579 candidate_rows: list[dict[str, Any]] = []580 for cluster in clusters:581 member_similarities: list[tuple[int, Any]] = []582 for member_pr_number in cluster_members[cluster.cluster_id]:583 if member_pr_number == pr_number:584 continue585 comparison = comparison_rows.get(_pair_key(pr_number, member_pr_number))586 if comparison is None or comparison.similarity <= 0.0:587 continue588 member_similarities.append((member_pr_number, comparison))589 if not member_similarities and cluster.cluster_id not in assigned_cluster_ids:590 continue591 member_similarities.sort(key=lambda item: (-item[1].similarity, item[0]))592 top_similarities = [entry.similarity for _, entry in member_similarities[:3]]593 max_member_similarity = top_similarities[0] if top_similarities else 0.0594 avg_top_member_similarity = (595 sum(top_similarities) / len(top_similarities) if top_similarities else 0.0596 )597 matched_member_count = len(member_similarities)598 candidate_score = (599 max_member_similarity * 0.60600 + avg_top_member_similarity * 0.30601 + min(matched_member_count, 3) / 3.0 * 0.10602 )603 best_member_pr_number = member_similarities[0][0] if member_similarities else None604 best_comparison = member_similarities[0][1] if member_similarities else None605 evidence = {606 "matched_member_pr_numbers": [member for member, _ in member_similarities[:5]],607 "best_member_pr_number": best_member_pr_number,608 "best_shared_filenames": (609 list(best_comparison.shared_filenames[:5]) if best_comparison is not None else []610 ),611 "best_shared_directories": (612 list(best_comparison.shared_directories[:5]) if best_comparison is not None else []613 ),614 "reason": _candidate_reason(615 matched_member_count=matched_member_count,616 best_comparison=best_comparison,617 ),618 }619 candidate_rows.append(620 {621 "pr_number": pr_number,622 "cluster_id": cluster.cluster_id,623 "candidate_score": candidate_score,624 "matched_member_count": matched_member_count,625 "best_member_pr_number": best_member_pr_number,626 "max_member_similarity": max_member_similarity,627 "avg_top_member_similarity": avg_top_member_similarity,628 "evidence_json": evidence,629 "assigned": cluster.cluster_id in assigned_cluster_ids,630 }631 )632 633 candidate_rows.sort(634 key=lambda row: (635 -row["candidate_score"],636 -row["matched_member_count"],637 row["cluster_id"],638 )639 )640 for rank, row in enumerate(candidate_rows[:DEFAULT_CANDIDATE_LIMIT], start=1):641 row["candidate_rank"] = rank642 return candidate_rows[:DEFAULT_CANDIDATE_LIMIT]643 644 645def _candidate_reason(*, matched_member_count: int, best_comparison: Any | None) -> str:646 if best_comparison is None:647 return "cluster membership matches existing scope assignment"648 shared_filenames = (649 list(best_comparison.shared_filenames)650 if hasattr(best_comparison, "shared_filenames")651 else list(best_comparison.get("shared_filenames") or [])652 )653 shared_directories = (654 list(best_comparison.shared_directories)655 if hasattr(best_comparison, "shared_directories")656 else list(best_comparison.get("shared_directories") or [])657 )658 if matched_member_count >= 2:659 return "overlapping files and directories with multiple cluster members"660 if shared_filenames:661 return "overlapping changed files with a cluster member"662 if shared_directories:663 return "overlapping directories with a cluster member"664 return "similar change shape to a cluster member"665 666 667def _pair_key(left: int, right: int) -> tuple[int, int]:668 return (left, right) if left <= right else (right, left)669 670 671def _json_dict(raw: Any) -> dict[str, float]:672 if isinstance(raw, dict):673 return {str(key): float(value) for key, value in raw.items()}674 if isinstance(raw, str) and raw:675 payload = json.loads(raw)676 if isinstance(payload, dict):677 return {str(key): float(value) for key, value in payload.items()}678 return {}679 680 681def _json_list(raw: Any) -> list[str]:682 if isinstance(raw, list):683 return [str(item) for item in raw]684 if isinstance(raw, str) and raw:685 payload = json.loads(raw)686 if isinstance(payload, list):687 return [str(item) for item in payload]688 return []689 690 691def _cosine_similarity(left: Mapping[str, float], right: Mapping[str, float]) -> float:692 if not left or not right:693 return 0.0694 if len(left) > len(right):695 left, right = right, left696 return sum(weight * right.get(feature, 0.0) for feature, weight in left.items())697 698 699def _ratio_similarity(left: int, right: int) -> float:700 largest = max(left, right)701 if largest <= 0:702 return 1.0703 return min(left, right) / largest704 