Spaces:
Sleeping
Sleeping
| """Strategies for sourcing **similar kink pairs** into the normalization / merge benchmark. | |
| Human labeling workflow (``merge`` | ``alias_only`` | ``reject``) starts from **candidate pairs** | |
| drawn from the live catalog. Use :func:`collect_all_pair_records` (or the CLI | |
| ``scripts/source_normalization_benchmark_pairs.py``) to emit JSONL, then curate rows into | |
| ``tests/fixtures/normalization_benchmark_v1.json`` (or a future ``*_pairs.json``). | |
| Tiers (in priority order for review — high signal first): | |
| 1. **merge_signature_same_bucket** — Same ``(cluster, merge_signature(name))`` and at least two | |
| rows. These are exactly what :func:`backend.kink_merge.propose_merge_clusters` groups; the | |
| canonical/duplicate split is popularity-driven. Pairs are unordered; cap large buckets with | |
| ``max_pairs_per_bucket`` and optional RNG sampling. | |
| 2. **fingerprint_collision** — Same title **fingerprint** (see ``scripts/find_name_duplicate_clusters.py`` | |
| / ``frontend/name-fingerprint.js``) but **different** ``merge_signature``. Often “soft” | |
| near-duplicates (e.g. ``blowjob`` vs ``Blow job``): good **negatives** for hard-merge or | |
| **alias_only** positives — *never* auto-merge on signature key alone. | |
| 3. **similarity_edge** — Rows from ``similarityedge`` above a score floor (``catalog``, | |
| ``fetlife_similar``, …). Catches graph-based similarity that signatures miss; label separately | |
| because many edges are *related* but not title duplicates. | |
| **Sampling:** After union of tiers, apply ``sample_random`` with a fixed ``seed`` so exports are | |
| reproducible for diffs and partial review batches. | |
| **Metrics note:** Pair-level precision/recall needs frozen labels on ``pair_key``; cluster-level | |
| tests in ``tests/test_normalization_benchmark.py`` stay orthogonal. | |
| """ | |
| from __future__ import annotations | |
| import random | |
| import re | |
| import sqlite3 | |
| from collections import defaultdict | |
| from collections.abc import Iterable, Iterator, Sequence | |
| from typing import Any | |
| from backend.kink_merge import merge_graph_eligible, merge_signature | |
| def fingerprint_play_title(raw: str) -> str: | |
| """Collapse play titles for duplicate-ish grouping (keep in sync with find_name_duplicate_clusters / name-fingerprint.js).""" | |
| s = (raw or "").strip().lower() | |
| if not s: | |
| return "" | |
| s = re.sub(r"[\u2018\u2019\u201c\u201d\u0060\u00b4]", "'", s) | |
| s = re.sub(r"\s+", " ", s) | |
| s = re.sub(r"\bgood\s+morning\s+", "goodmorning ", s) | |
| s = re.sub(r"\bdeep\s*throating\b", "deepthroat", s) | |
| s = re.sub(r"\bdeepthroating\b", "deepthroat", s) | |
| s = re.sub(r"\bdeep\s*throat\b", "deepthroat", s) | |
| s = re.sub(r"\bblow\s+jobs\b", "blowjob", s, flags=re.IGNORECASE) | |
| s = re.sub(r"\bblow\s+job\b", "blowjob", s, flags=re.IGNORECASE) | |
| s = re.sub(r"\bblowjobs\b", "blowjob", s) | |
| s = re.sub(r"\bstrap\s+ons\b", "strapon", s, flags=re.IGNORECASE) | |
| s = re.sub(r"\bstrap\s+on\b", "strapon", s, flags=re.IGNORECASE) | |
| s = re.sub(r"\bstrapons\b", "strapon", s) | |
| s = re.sub(r"[^\w\s]", " ", s) | |
| return re.sub(r"\s+", "", re.sub(r"\s+", " ", s).strip()) | |
| def _row_dict(kink_id: str, name: str, cluster: str, popularity: float) -> dict[str, Any]: | |
| return { | |
| "kink_id": kink_id, | |
| "name": name, | |
| "cluster": cluster, | |
| "popularity": float(popularity or 0.0), | |
| } | |
| def _merge_sig_bucket_key(name: str, cluster: str) -> tuple[str, str] | None: | |
| if not merge_graph_eligible(name): | |
| return None | |
| return (cluster, merge_signature(name)) | |
| def _sample_pair_indices(n: int, max_pairs: int, rng: random.Random | None) -> Iterator[tuple[int, int]]: | |
| """Yield up to ``max_pairs`` unordered pairs (i, j) with i < j from range(n).""" | |
| total = n * (n - 1) // 2 | |
| if total <= max_pairs: | |
| for i in range(n): | |
| for j in range(i + 1, n): | |
| yield i, j | |
| return | |
| if rng is None: | |
| rng = random.Random(0) # noqa: S311 — deterministic fallback cap when caller omitted RNG | |
| seen: set[tuple[int, int]] = set() | |
| attempts = 0 | |
| max_attempts = max_pairs * 20 | |
| while len(seen) < max_pairs and attempts < max_attempts: | |
| attempts += 1 | |
| i, j = sorted((rng.randrange(n), rng.randrange(n))) | |
| if i == j: | |
| continue | |
| a, b = (i, j) if i < j else (j, i) | |
| if (a, b) in seen: | |
| continue | |
| seen.add((a, b)) | |
| yield a, b | |
| def collect_merge_signature_pairs( | |
| rows: Sequence[tuple[str, str, str, float]], | |
| *, | |
| max_pairs_per_bucket: int = 40, | |
| rng: random.Random | None = None, | |
| ) -> list[dict[str, Any]]: | |
| """Pairs that share the same merge-signature bucket (same cluster).""" | |
| buckets: dict[tuple[str, str], list[tuple[str, str, str, float]]] = defaultdict(list) | |
| for kid, name, cluster, pop in rows: | |
| key = _merge_sig_bucket_key(name, cluster) | |
| if key is None: | |
| continue | |
| buckets[key].append((kid, name, cluster, float(pop or 0.0))) | |
| out: list[dict[str, Any]] = [] | |
| for (cluster, sig), group in sorted(buckets.items()): | |
| if len(group) < 2: | |
| continue | |
| group.sort(key=lambda t: t[0]) | |
| for i, j in _sample_pair_indices(len(group), max_pairs_per_bucket, rng): | |
| a, b = group[i], group[j] | |
| lo, hi = sorted((a, b), key=lambda t: t[0]) | |
| out.append( | |
| { | |
| "strategy": "merge_signature_same_bucket", | |
| "cluster": cluster, | |
| "merge_signature": sig, | |
| "fingerprint": fingerprint_play_title(lo[1]), | |
| "left": _row_dict(lo[0], lo[1], lo[2], lo[3]), | |
| "right": _row_dict(hi[0], hi[1], hi[2], hi[3]), | |
| "pair_key": f"{lo[0]}|{hi[0]}", | |
| }, | |
| ) | |
| return out | |
| def collect_fingerprint_collision_pairs( | |
| rows: Sequence[tuple[str, str, str, float]], | |
| *, | |
| min_fingerprint_len: int = 4, | |
| max_pairs_per_fingerprint: int = 30, | |
| rng: random.Random | None = None, | |
| ) -> list[dict[str, Any]]: | |
| """Same collapsed fingerprint, different merge_signature (subset / alias / reject review).""" | |
| by_fp: dict[str, list[tuple[str, str, str, float]]] = defaultdict(list) | |
| for kid, name, cluster, pop in rows: | |
| fp = fingerprint_play_title(name) | |
| if len(fp) < min_fingerprint_len: | |
| continue | |
| by_fp[fp].append((kid, name, cluster, float(pop or 0.0))) | |
| out: list[dict[str, Any]] = [] | |
| for fp, group in sorted(by_fp.items(), key=lambda x: (-len(x[1]), x[0])): | |
| if len(group) < 2: | |
| continue | |
| sigs: dict[str, list[tuple[str, str, str, float]]] = defaultdict(list) | |
| for item in group: | |
| sigs[merge_signature(item[1])].append(item) | |
| if len(sigs) < 2: | |
| continue | |
| flat: list[tuple[str, str, str, float]] = [] | |
| for g in sigs.values(): | |
| flat.extend(g) | |
| candidates = [ | |
| (i, j) | |
| for i in range(len(flat)) | |
| for j in range(i + 1, len(flat)) | |
| if merge_signature(flat[i][1]) != merge_signature(flat[j][1]) | |
| ] | |
| if not candidates: | |
| continue | |
| if len(candidates) > max_pairs_per_fingerprint: | |
| r = rng or random.Random(0) # noqa: S311 | |
| r.shuffle(candidates) | |
| candidates = candidates[:max_pairs_per_fingerprint] | |
| for i, j in candidates: | |
| a, b = flat[i], flat[j] | |
| lo, hi = sorted((a, b), key=lambda t: t[0]) | |
| out.append( | |
| { | |
| "strategy": "fingerprint_collision", | |
| "fingerprint": fp, | |
| "merge_signature_left": merge_signature(lo[1]), | |
| "merge_signature_right": merge_signature(hi[1]), | |
| "left": _row_dict(lo[0], lo[1], lo[2], lo[3]), | |
| "right": _row_dict(hi[0], hi[1], hi[2], hi[3]), | |
| "pair_key": f"{lo[0]}|{hi[0]}", | |
| }, | |
| ) | |
| return out | |
| def collect_similarity_edge_pairs( | |
| conn: sqlite3.Connection, | |
| *, | |
| min_score: float = 0.82, | |
| similarity_types: Sequence[str] | None = None, | |
| max_pairs: int = 50_000, | |
| ) -> list[dict[str, Any]]: | |
| """Pairs linked in ``similarityedge`` (graph similarity, not signature identity).""" | |
| types = tuple(similarity_types) if similarity_types else ("catalog", "fetlife_similar") | |
| placeholders = ",".join("?" * len(types)) | |
| q = f""" | |
| SELECT s.similarity_type, s.score, s.method, | |
| l.id AS lid, l.name AS lname, l.cluster AS lcluster, COALESCE(ml.popularity, 0) AS lpop, | |
| r.id AS rid, r.name AS rname, r.cluster AS rcluster, COALESCE(mr.popularity, 0) AS rpop | |
| FROM similarityedge s | |
| JOIN kink l ON l.id = s.left_kink_id | |
| JOIN kink r ON r.id = s.right_kink_id | |
| LEFT JOIN fetlifekinkmeta ml ON ml.kink_id = l.id | |
| LEFT JOIN fetlifekinkmeta mr ON mr.kink_id = r.id | |
| WHERE s.score >= ? | |
| AND s.similarity_type IN ({placeholders}) | |
| AND s.left_kink_id < s.right_kink_id | |
| """ # noqa: S608 — IN list is only `?` placeholders; values bound via parameters | |
| cur = conn.execute(q, (min_score, *types)) | |
| out: list[dict[str, Any]] = [] | |
| for row in cur.fetchall(): | |
| if len(out) >= max_pairs: | |
| break | |
| lid, rid = str(row["lid"]), str(row["rid"]) | |
| lname, rname = str(row["lname"]), str(row["rname"]) | |
| lcl, rcl = str(row["lcluster"]), str(row["rcluster"]) | |
| lpop, rpop = float(row["lpop"] or 0), float(row["rpop"] or 0) | |
| if lid > rid: | |
| lid, rid, lname, rname, lcl, rcl, lpop, rpop = rid, lid, rname, lname, rcl, lcl, rpop, lpop | |
| lo = (lid, lname, lcl, lpop) | |
| hi = (rid, rname, rcl, rpop) | |
| out.append( | |
| { | |
| "strategy": "similarity_edge", | |
| "similarity_type": str(row["similarity_type"]), | |
| "score": float(row["score"]), | |
| "method": str(row["method"] or ""), | |
| "merge_signature_left": merge_signature(lo[1]), | |
| "merge_signature_right": merge_signature(hi[1]), | |
| "fingerprint_left": fingerprint_play_title(lo[1]), | |
| "fingerprint_right": fingerprint_play_title(hi[1]), | |
| "left": _row_dict(lo[0], lo[1], lo[2], lo[3]), | |
| "right": _row_dict(hi[0], hi[1], hi[2], hi[3]), | |
| "pair_key": f"{lo[0]}|{hi[0]}", | |
| }, | |
| ) | |
| return out | |
| def dedupe_pair_records(records: Iterable[dict[str, Any]]) -> list[dict[str, Any]]: | |
| """Keep first occurrence of each ``pair_key`` (merge tiers may overlap graph edges).""" | |
| seen: set[str] = set() | |
| out: list[dict[str, Any]] = [] | |
| for rec in records: | |
| key = rec.get("pair_key") | |
| if not key or key in seen: | |
| continue | |
| seen.add(key) | |
| out.append(rec) | |
| return out | |
| def collect_all_pair_records( | |
| rows: Sequence[tuple[str, str, str, float]], | |
| conn: sqlite3.Connection | None, | |
| *, | |
| strategies: Sequence[str], | |
| max_pairs_per_merge_bucket: int = 40, | |
| max_pairs_per_fingerprint: int = 30, | |
| similarity_min_score: float = 0.82, | |
| similarity_max_pairs: int = 50_000, | |
| rng: random.Random | None = None, | |
| ) -> list[dict[str, Any]]: | |
| """Union requested strategies in order (merge → fingerprint → similarity).""" | |
| want = {str(s).strip() for s in strategies} | |
| merged: list[dict[str, Any]] = [] | |
| if "merge_signature" in want: | |
| merged.extend( | |
| collect_merge_signature_pairs( | |
| rows, | |
| max_pairs_per_bucket=max_pairs_per_merge_bucket, | |
| rng=rng, | |
| ), | |
| ) | |
| if "fingerprint_collision" in want: | |
| merged.extend( | |
| collect_fingerprint_collision_pairs( | |
| rows, | |
| max_pairs_per_fingerprint=max_pairs_per_fingerprint, | |
| rng=rng, | |
| ), | |
| ) | |
| if "similarity_edge" in want: | |
| if conn is None: | |
| raise ValueError("similarity_edge strategy requires an open sqlite connection") | |
| merged.extend( | |
| collect_similarity_edge_pairs( | |
| conn, | |
| min_score=similarity_min_score, | |
| max_pairs=similarity_max_pairs, | |
| ), | |
| ) | |
| return merged | |
| def sample_pair_records( | |
| records: Sequence[dict[str, Any]], | |
| n: int, | |
| *, | |
| seed: int, | |
| ) -> list[dict[str, Any]]: | |
| if n <= 0 or len(records) <= n: | |
| return list(records) | |
| rng = random.Random(seed) # noqa: S311 — benchmark export reproducibility | |
| items = list(records) | |
| rng.shuffle(items) | |
| return items[:n] | |