#!/usr/bin/env python3 """exp16 — V-B translation-spread signal + dst-variant extraction from the ru drafts (research/20 §B1). miner-v1. $0. pymorphy3 lemmatization (ru target; §A2 Popović&Ney — lemmatize the target). For each src candidate X: the chunks where X occurs give (source, ru-draft) pairs. We estimate X's ru rendering per chunk by chunk-level co-occurrence (Dice of X's chunk-set with each ru lemma's chunk-set — the ru lemma over-represented exactly in X's chunks). spread = LTCR-style dispersion of the dominant rendering across chunks (how inconsistently X is translated). +dst_variants = raw canon material. ⚠ CONFOUND (§D1): records.json drafts were generated WITH seed glossary injection → GT terms are rendered CONSISTENTLY (spread suppressed). On these drafts the spread signal is a LOWER BOUND; the cold-start slice (no injection) is where spread is measured ecologically. This module is honest about it. """ from __future__ import annotations import re from collections import Counter, defaultdict import regex import exp16_common as X _pymorphy = None def _lemmatizer(): global _pymorphy if _pymorphy is None: import pymorphy3 _pymorphy = pymorphy3.MorphAnalyzer() return _pymorphy _RE_RU_TOKEN = regex.compile(r"[\p{Cyrillic}\-]+") _STOP_RU = set("и в во не что он на я с со как а то все она так его но да ты к у же вы за бы по только ее " "мне было вот от меня еще нет о из ему теперь когда даже ну вдруг ли если уже или ни быть " "был него до вас нибудь опять уж вам ведь там потом себя ничего ей может они тут где есть " "надо ней для мы тебя их чем была сам чтоб без будто чего раз тоже себе под будет ж тогда " "кто этот того потому этого какой совсем ним здесь этом один почти мой тем чтобы нее сейчас " "были куда зачем всех никогда можно при наконец два об другой хоть после над больше тот " "через эти нас про всего них какая много разве три эту моя впрочем свою этой перед иногда " "лучше чуть том нельзя такой им более всегда конечно всю между это как its the и".split()) def lemmatize_ru(text: str) -> list[str]: m = _lemmatizer() out = [] for tok in _RE_RU_TOKEN.findall(text.lower()): if len(tok) < 3 or tok in _STOP_RU: continue lemma = m.parse(tok)[0].normal_form if lemma in _STOP_RU or len(lemma) < 3: continue out.append(lemma) return out def build_chunk_lemmas(chunks) -> list[set]: """Per-chunk set of ru draft lemmas (draft side).""" return [set(lemmatize_ru(c.draft)) for c in chunks] def build_name_lemmas(chunks, min_cap_frac=0.6) -> set: """Lemmas that appear predominantly CAPITALIZED across the drafts (name-like) — used to gate the Palladius channel against common ru words that coincidentally segment into Palladius syllables (e.g. 'найти'=най+ти). Sentence-initial caps are diluted by counting all occurrences.""" m = _lemmatizer() cap = Counter() low = Counter() for c in chunks: for tok in regex.findall(r"[\p{Cyrillic}\-]+", c.draft): if len(tok) < 3: continue lemma = m.parse(tok.lower())[0].normal_form if tok[0].isupper(): cap[lemma] += 1 else: low[lemma] += 1 out = set() for lm in set(cap) | set(low): tot = cap[lm] + low[lm] if tot >= 2 and cap[lm] / tot >= min_cap_frac: out.add(lm) return out def _dice(a: set, b: set) -> float: if not a or not b: return 0.0 inter = len(a & b) return 2 * inter / (len(a) + len(b)) class SpreadModel: """Chunk-level co-occurrence spread + dst-variant extraction over the ru drafts.""" def __init__(self, chunks): self.chunks = chunks self.chunk_lemmas = build_chunk_lemmas(chunks) # list[set] self.lemma_chunks = defaultdict(set) for i, s in enumerate(self.chunk_lemmas): for lm in s: self.lemma_chunks[lm].add(i) self._occ_cache: dict[str, set] = {} self._dst_cache: dict[str, tuple] = {} self._spread_cache: dict[str, float] = {} self.name_lemmas = build_name_lemmas(chunks) # capitalized-predominant lemmas (name-like) def is_name_lemma(self, lemma: str) -> bool: return lemma in self.name_lemmas def cand_chunk_indices(self, cand_norm: str) -> list[int]: if cand_norm not in self._occ_cache: self._occ_cache[cand_norm] = {i for i, c in enumerate(self.chunks) if cand_norm in c.nsource} return self._occ_cache[cand_norm] def dst_variants(self, cand_norm: str, top=4, min_dice=0.05): """Ru lemmas whose chunk-set best overlaps the candidate's chunk-set (Dice). Returns [(lemma, dice, chunk_count)] — the candidate's likely ru renderings + co-salient context. Co-salience prefilter: only lemmas present in >= max(2, 30%) of the candidate's chunks are scored (keeps the rendering, drops singleton co-occurrences — 40x faster, same top variants).""" if cand_norm in self._dst_cache: return self._dst_cache[cand_norm] cidx = set(self.cand_chunk_indices(cand_norm)) if not cidx: self._dst_cache[cand_norm] = ([], cidx) return [], cidx need = max(2, int(round(0.30 * len(cidx)))) counts = Counter() for i in cidx: counts.update(self.chunk_lemmas[i]) scored = [] for lm, cnt in counts.items(): if cnt < min(need, len(cidx)): continue lc = self.lemma_chunks[lm] d = _dice(cidx, lc) if d >= min_dice: scored.append((lm, round(d, 3), len(lc & cidx))) scored.sort(key=lambda t: (-t[1], -t[2], t[0])) res = (scored[:top], cidx) self._dst_cache[cand_norm] = res return res def spread(self, cand_norm: str) -> float: """LTCR-style dispersion: among the candidate's chunks, how dispersed is the top ru associate? High when the candidate is rendered by DIFFERENT dominant lemmas across chunks (inconsistent). On injected drafts this is suppressed (confound §D1).""" if cand_norm in self._spread_cache: return self._spread_cache[cand_norm] val = self._spread(cand_norm) self._spread_cache[cand_norm] = val return val def _spread(self, cand_norm: str) -> float: variants, cidx = self.dst_variants(cand_norm, top=6) if len(cidx) < 2 or not variants: return 0.0 # for each candidate chunk, which of the top variants is present? count distinct dominant sets top_lemmas = [v[0] for v in variants] per_chunk_dom = [] for i in cidx: present = [lm for lm in top_lemmas if lm in self.chunk_lemmas[i]] per_chunk_dom.append(present[0] if present else None) doms = [d for d in per_chunk_dom if d] if not doms: return 0.0 distinct = len(set(doms)) # spread in [0,1): 0 if one dominant lemma everywhere; grows with distinct renderings return (distinct - 1) / max(len(doms), 1) if __name__ == "__main__": chunks = X.load_chunks() sm = SpreadModel(chunks) print("V-B spread + dst-variants (ON INJECTED DRAFTS — lower bound, §D1 confound)\n") for s in ["方源", "蛊师", "古月", "花酒行者", "四代族长", "元石", "空窍", "白凝冰"]: sn = X.norm(s) variants, cidx = sm.dst_variants(sn) sp = sm.spread(sn) vs = ", ".join(f"{lm}({d})" for lm, d, _ in variants[:4]) print(f" {s:<6} chunks={len(cidx):<3} spread={sp:.2f} top-dst-assoc: {vs}")