kink-discovery / backend /benchmark_pair_sources.py
Perplexed7675's picture
Sync from kink_cli (Docker Space)
6ff91d6 verified
Raw
History Blame Contribute Delete
12.6 kB
"""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]