"""
=============================================================================
第 12 讲 · 工具模块 1：fact_world.py —— 一个"知道真相"的虚构知识世界 + 一个会幻觉的模拟 LLM
=============================================================================

对应讲义：lectures/lecture12_grounding_hallucination.md 全讲（尤其 §1、§2、§4）

-----------------------------------------------------------------------------
【为什么要模拟 Why a simulated world】
-----------------------------------------------------------------------------
检测幻觉的方法（NLI、SelfCheck、RARR……）都要回答一个问题：这个 claim 是真的吗？有依据吗？
在真实数据上，"真相"要靠人工标注，又贵又有噪声。这里我们造一个**完全知道真相**的小世界：

    · 150 位虚构的研究者（Entity），每人 7 个属性（RELATIONS）：出生年份、出生城市、研究领域、
      博士学校、博士毕业年份、雇主、获奖。全部是编造的名字，不对应任何真实的人或机构。
    · 一个知识库（KB）：每人 4 段短文（Passage），各写 1–2 个属性；20% 的人还有一段**过时的**
      "旧员工名录"（stale passage，1998 年的 staff directory），写着以前的雇主 ——
      检索到它就可能得到"有依据但错误"的回答。
    · 30 对**同名者**（namesake）：名和姓相同、只有中间名缩写不同（Mira J. Okafen vs Mira T. Okafen）。
      检索器丢掉了单字符 token，分不清两人，会把同名者的段落一起找回来；模型和 NLI 都可能把两个人
      混在一起（讲义 §5.4 的 AT&T 例子）。
    · 一个模拟 LLM 的"参数记忆"（parametric memory）：对每个 (人, 属性)，模型要么
      **记得对**（known）、要么**记错了且每次都错得一样**（wrong，比如记成了同名者的值）、
      要么**不知道**（unknown，生成时只能编 —— fabricate）。越"有名"（popularity 越高）的人，记得越多。
    · 一个模拟生成过程 generate_bio：给（或不给）检索到的上下文，写一段短传记。每个 claim 都带着
      它的"来历"标签（source）：从上下文抄的、记忆里对的、记忆里错的、现编的、读错上下文的、
      混进同名者的……
    · 每人一篇真实论文（标题 + 两位合作者）。模型可能记得它，也可能编一条不存在的参考文献
      （§6.2 的"幻觉引用"，Agrawal et al. 2023）。
    · 一个标准库写的 BM25 检索器（带一个玩具级的词干提取），RAG 和 RARR 都用它。

因为每个 claim 的真值和来历都已知，讲义里的每一个检测方法都可以和"真相"对照。
这些数字说明**机制**，不代表任何真实 LLM 的表现。

-----------------------------------------------------------------------------
【机制和论文的对应 How the knobs map to the literature】
-----------------------------------------------------------------------------
    · 知识缺口 + 长尾（tail entities 记不住）           → popularity 决定 known 的概率（讲义 §2）
    · 训练数据里的混淆 / 错误被"记住"                    → kind = "wrong"：一致的错误（SelfCheck 的盲区，§6）
    · 采样随机性：同一个不知道的事实每次编得不一样          → fabricate() 的 temperature（SelfCheckGPT 的前提）
    · 记得但记不牢（recall 不稳定）                      → strength：每次采样以这个概率想起来
    · 对 prompt 表面形式敏感                            → variant：换一种问法，编出来的值也会变（Raj et al. 2022）
    · RAG 的"检索损失"与"生成损失"（Fall 2024 slides）    → stale / namesake 段落；p_misread（读错上下文）
    · 知识冲突：参数记忆压过上下文                         → p_use_context < 1
    · "只用上下文回答"的 prompt、弃权                      → GenConfig.strict / GenConfig.abstain

-----------------------------------------------------------------------------
【怎样换成真实模型 Plugging in a real model】
-----------------------------------------------------------------------------
真实系统里，generate_bio 换成一次 LLM 调用（prompt = 问题 + 检索到的段落），claim 的抽取换成一次
"把回答拆成原子 claim"的 LLM 调用，检测器（checkers.py）里的 nli() 换成一个真正的 NLI 模型
（例如在 MNLI / ANLI 上训练的 T5 或 DeBERTa）。这些替换都不改变检测器本身的逻辑。
本目录不调用任何外部服务。

-----------------------------------------------------------------------------
【运行方式】
-----------------------------------------------------------------------------
    python3 fact_world.py        # 自检：打印世界的统计、一次检索结果和两段生成的传记（< 1 秒）

只用 Python 标准库。
"""

import math
import random
import re
from dataclasses import dataclass, field

# =============================================================================
# 世界的"词表"：属性、候选值、句子模板
# =============================================================================

RELATIONS = ["born_year", "born_city", "field", "phd_univ", "phd_year", "employer", "award"]
NUMERIC = ("born_year", "phd_year")          # 数值型属性（NLI 对数字特别容易出错，§5.4）

FIRST_NAMES = ["Mira", "Tomas", "Ines", "Kofi", "Lena", "Ravi", "Sana", "Oren", "Yuki", "Dara",
               "Emil", "Nadia", "Pavel", "Zara", "Idris", "Maren", "Teo", "Alma", "Bruno", "Celia"]
SYLLABLES = ["ka", "fen", "lo", "mar", "ti", "vos", "ren", "da", "sul", "bek", "or", "win", "tal", "nu",
             "pe", "zan", "ri", "gol"]
INITIALS = list("ABCDEFGHJKLMNPRSTVW")

CITIES = ["Corvan", "Ellismere", "Tarsk", "Vellune", "Oduna", "Brask", "Kelmor", "Saffra", "Draven",
          "Lisk", "Morrow", "Quell"]
FIELDS = ["glacier acoustics", "tidal genomics", "lattice cryptography", "soil robotics",
          "polar linguistics", "fungal computing", "desert hydrology", "quantum botany"]
UNIVERSITIES = ["Aldren University", "Bexley Institute", "Carrow University", "Dunmere College",
                "Esker Polytechnic", "Farrin University", "Gelt Academy", "Harrow Institute"]
EMPLOYERS = ["Northfold Lab", "Ironleaf Institute", "Saltmarsh Center", "Bluecrest Lab", "Ashgrove Institute",
             "Pinecliff Center", "Redwater Lab", "Stonebridge Institute", "Larkspur Center", "Mistvale Lab"]
AWARDS = ["Halden Prize", "Corris Medal", "Vey Award", "Ostrom Prize", "Talmur Medal", "Brin Award"]

# 每个属性所有可能的取值（模型"编"的时候就在这里面挑）
CANDIDATES = {
    "born_year": list(range(1935, 1986)),
    "born_city": CITIES,
    "field": FIELDS,
    "phd_univ": UNIVERSITIES,
    "phd_year": list(range(1960, 2017)),
    "employer": EMPLOYERS,
    "award": AWARDS,
}

# 句子模板：主语 + 谓语从句。段落和回答用同一套模板，这样检索（BM25）和"读"的难度一致。
CLAUSE = {
    "born_year": "was born in {v}",
    "born_city": "comes from {v}",
    "field": "works on {v}",
    "phd_univ": "earned a doctorate at {v}",
    "phd_year": "finished the doctorate in {v}",
    "employer": "is employed by the {v}",
    "award": "received the {v}",
}
# RARR 的 research 阶段要"提问题"而不是"复述 claim"：每个属性一个不含答案的问句（§7.1）
QUESTION = {
    "born_year": "when was {n} born",
    "born_city": "where does {n} come from",
    "field": "what does {n} work on",
    "phd_univ": "where did {n} earn a doctorate",
    "phd_year": "when did {n} finish the doctorate",
    "employer": "who is {n} employed by",
    "award": "which award, prize or medal did {n} receive",
}
# 知识库里每人 4 段短文：把 7 个属性随机分成 2 + 2 + 2 + 1 个（每人的分法不同）
PASSAGE_SIZES = (2, 2, 2, 1)
# 论文标题的词表（"幻觉引用"实验用，§6.2）：标题 = 形容词 + 名词 + "for" + 领域
TITLE_ADJ = ["Spectral", "Adaptive", "Sparse", "Robust", "Recursive", "Stochastic", "Latent", "Coupled"]
TITLE_NOUN = ["Models", "Methods", "Signatures", "Dynamics", "Estimators", "Networks", "Patterns", "Maps"]
PRONOUN = "This researcher"     # 回答里第二句以后常用的指代词（需要 decontextualization 才能单独核对）
FILLER = "Here is a short biography."   # 不需要核对的句子（slides: "Sure! I can help you with that"）


# =============================================================================
# 数据结构
# =============================================================================
@dataclass
class Entity:
    """
    一位虚构的研究者。

    【字段】eid 编号（= 在 world.entities 里的下标）；name 全名（含中间名缩写）；facts 属性 → 真值；
        popularity ∈ (0,1)：越大越"有名"，模型越可能记得；namesake：同名者的 eid（没有就是 -1）。
    【Python 小知识】@dataclass 自动生成 __init__ 等方法：Entity(eid=0, name="...", ...) 就能创建对象，
        字段写在类体里、带类型注解。field(default_factory=dict) 让每个对象有自己的空字典
        （直接写 = {} 会让所有对象共享同一个字典，这是 Python 的经典陷阱）。
    """
    eid: int
    name: str
    first: str
    last: str
    facts: dict = field(default_factory=dict)
    popularity: float = 0.5
    namesake: int = -1


@dataclass
class Passage:
    """
    知识库里的一段短文。

    【字段】pid 编号；eid 这段话讲的是谁；facts = [(eid, relation, value), ...] 这段话**陈述的**事实
        （过时段落陈述的是旧值）；text 文本（检索用）；kind = "bio"（正常）或 "stale"（过时）。
    【可信联系】facts 是"标准答案"层面的结构化信息，只给**模拟 NLI** 和**评估**用；
        模拟 LLM 只通过 facts 读上下文（相当于"读懂了文本"），检索只看 text。
    """
    pid: int
    eid: int
    facts: list
    text: str
    kind: str = "bio"


@dataclass
class Claim:
    """
    回答里的一个原子 claim（atomic fact）：关于某人某属性的一个值。

    【字段】eid / relation / value；source = 这个值从哪来：
        "context"        从检索到的上下文抄来（主体正确的段落）
        "context_stale"  从过时段落抄来（有依据，但已经不对了）
        "namesake"       把上下文里同名者的值当成了这个人的（读混了）
        "misread"        读错了上下文（数字抄错、张冠李戴）→ intrinsic hallucination
        "mem_known"      参数记忆，记得对
        "mem_wrong"      参数记忆，记错了（每次都错得一样）
        "fabricated"     不知道，现编的（每次可能编得不一样）
    【方法】text()：decontextualized 的单句（用全名做主语，可以脱离上下文单独核对）。
    """
    eid: int
    relation: str
    value: object
    source: str
    name: str = ""

    def text(self):
        return self.name + " " + CLAUSE[self.relation].format(v=self.value) + "."


@dataclass
class Sentence:
    """
    回答里的一句话：0 个 claim（客套话）、1 个或 2 个 claim（复合句）。

    【字段】text 原文；claims 这句话包含的原子 claim；uses_pronoun 主语是不是 "This researcher"
        （是的话，这句话**单独拿出来**就不知道在说谁 —— 需要 decontextualization）。
    """
    text: str
    claims: list
    uses_pronoun: bool = False


@dataclass
class Response:
    """
    模拟 LLM 的一次回答。

    【字段】eid 问的是谁；sentences 句子列表；context 生成时给的检索段落（闭卷时是空列表）。
    【方法】claims() 把所有句子里的 claim 摊平成一个列表；text() 拼成一段文字。
    """
    eid: int
    sentences: list
    context: list = field(default_factory=list)

    def claims(self):
        # 【Python 小知识】列表推导式里可以写两层 for：先遍历句子，再遍历句子里的 claim
        return [c for s in self.sentences for c in s.claims]

    def text(self):
        return " ".join(s.text for s in self.sentences)


@dataclass
class Memory:
    """
    模型对某个 (人, 属性) 的参数记忆。

    【字段】kind ∈ {"known", "wrong", "unknown"}；value：记住的值（unknown 时为 None）；
        strength ∈ (0,1)：每次采样时"想得起来"的概率（greedy 解码时 strength ≥ 0.5 就算想得起来）。
    """
    kind: str
    value: object
    strength: float


@dataclass
class Reference:
    """
    模型给出的一条参考文献（"幻觉引用"实验，Agrawal et al. 2023）。

    【字段】eid 为谁的传记引用；title 标题；coauthors 合作者（不含本人，按字母排序的元组）；
        real：知识库里是否真有这篇论文（真相，检测器看不到）。
    """
    eid: int
    title: str
    coauthors: tuple
    real: bool


@dataclass
class GenConfig:
    """
    模拟 LLM 的生成行为。每个字段都是一个**明确的旋钮**，讲义 §2、§4 解释它们对应的真实现象。

    【字段】
        p_mention        每个属性被写进传记的概率（啰嗦程度 verbosity）
        p_use_context    上下文里有这个属性时，照着上下文写的概率（否则用参数记忆：knowledge conflict）
        p_use_context_strict  prompt 要求"只用给定的上下文"时的同一概率
        p_misread        照上下文写时，抄错的概率（intrinsic hallucination，Fall 2024 slides 的 "Generation losses"）
        w_namesake       上下文里同时有本人和同名者的值时，选中同名者的值的相对权重
        strict           prompt 是否要求"只用上下文，不要用其他知识"
        p_omit_strict    strict 时，上下文里没有的属性被略去（而不是凭记忆写）的概率
        abstain          模型是否会在"自己不知道"时略去这个属性（selective abstention）
        p_aware          abstain 时，模型"意识到自己不知道"的概率（不是每次都能意识到）
        p_compound       相邻两个 claim 合成一个复合句的概率
        p_pronoun        第二句以后用 "This researcher" 做主语的概率
        p_filler         开头加一句客套话的概率
    """
    p_mention: float = 0.75
    p_use_context: float = 0.85
    p_use_context_strict: float = 0.97
    p_misread: float = 0.05
    w_namesake: float = 0.35
    strict: bool = False
    p_omit_strict: float = 0.8
    abstain: bool = False
    p_aware: float = 0.6
    p_compound: float = 0.5
    p_pronoun: float = 0.6
    p_filler: float = 0.5


# =============================================================================
# 一个极简的 BM25 检索器（标准库实现）
# =============================================================================
def stem(w):
    """
    一个极简的"去后缀"词干提取（Porter 1980 stemmer 的玩具版）：去掉 -ing / -ed / -es / -s / -e 之一。

    【思路】让 "received" 和 "receive"、"comes" 和 "come"、"works" 和 "work" 变成同一个词干，
        否则问句（"what did X receive"）和段落（"X received the ..."）在字面上对不上。
        真实的 BM25 系统（例如 Lucene 的英文分析器）也会做词干提取。
    【边界】去掉后缀后少于 3 个字母就不去（"is"、"was"、"the" 保持原样）。
    """
    for suf in ("ing", "ed", "es", "s", "e"):
        if w.endswith(suf) and len(w) - len(suf) >= 3:
            return w[:-len(suf)]
    return w


def tokenize(text):
    """
    把文本切成小写的词干（字母和数字），**丢掉只有一个字符的 token**。

    【思路】丢掉单字符 token 是很多检索 / 向量化工具的默认做法（例如 scikit-learn 的 CountVectorizer
        默认只保留 2 个字符以上的词）。在这个世界里它有一个后果：中间名缩写 "J" 被丢掉，
        检索器分不清 Mira J. Okafen 和 Mira T. Okafen —— 一个真实而常见的"检索损失"（§4.2）。
    【Python 小知识】re.findall(r"[a-z0-9]{2,}", s) 返回所有长度 ≥ 2 的连续字母 / 数字片段。
    """
    return [stem(w) for w in re.findall(r"[a-z0-9]{2,}", text.lower())]


class BM25:
    """
    BM25 关键词检索（Robertson & Zaragoza 2009 的标准形式）。

    【思路】一个词对一篇文档的得分 = IDF(词) × 词频的饱和函数（出现越多越高、但有上限 k1），
        并按文档长度归一化（参数 b）。罕见词（比如姓氏、缩写）的 IDF 高，所以名字匹配主导排序。
    【参数】docs：文本列表；k1、b：BM25 的两个标准超参数。
    【可信联系】检索是 RAG 的第一环：检索错了（同名者、过时段落），后面再"忠实"也会错（§4.2）。
    """

    def __init__(self, docs, k1=1.5, b=0.75):
        self.k1, self.b = k1, b
        self.docs = [tokenize(d) for d in docs]
        self.avgdl = math.fsum(len(d) for d in self.docs) / len(self.docs)
        df = {}
        for d in self.docs:
            # 【Python 小知识】sorted(set(d))：去重后排序。集合的遍历顺序依赖哈希种子，先排序保证可复现。
            for w in sorted(set(d)):
                df[w] = df.get(w, 0) + 1
        n = len(self.docs)
        self.idf = {w: math.log((n - c + 0.5) / (c + 0.5) + 1.0) for w, c in df.items()}
        # 每篇文档的词频表
        self.tf = []
        for d in self.docs:
            t = {}
            for w in d:
                t[w] = t.get(w, 0) + 1
            self.tf.append(t)

    def scores(self, query):
        """返回每篇文档对 query 的 BM25 得分（列表，下标 = 文档编号）。"""
        q = tokenize(query)
        out = []
        for i, t in enumerate(self.tf):
            dl = len(self.docs[i])
            s = 0.0
            for w in q:
                f = t.get(w, 0)
                if f:
                    s += self.idf[w] * f * (self.k1 + 1) / (f + self.k1 * (1 - self.b + self.b * dl / self.avgdl))
            out.append(s)
        return out

    def top(self, query, k):
        """
        返回得分最高的 k 篇文档编号。

        【边界】得分相同时按编号从小到大排（排序键 (-score, i)），保证结果确定。
        """
        sc = self.scores(query)
        order = sorted(range(len(sc)), key=lambda i: (-sc[i], i))
        return order[:k]


# =============================================================================
# 世界
# =============================================================================
def _clip(x, lo, hi):
    return max(lo, min(hi, x))


class World:
    """
    整个虚构世界：人、知识库、检索器、模型的参数记忆、模拟生成。

    【思路】构造函数只用一个固定种子的随机数生成器，所以每个脚本 import 后得到的世界逐字相同；
        各个演示自己的随机性（采样、NLI 噪声）用各自的 rng，互不影响。
    【参数】seed：世界的种子；n_base：基础人数；n_namesakes：再加多少个同名者；
        p_stale：有过时段落的人的比例；p_known_lo / p_known_hi：最不出名 / 最出名的人，每个属性被记住的概率；
        q_wrong：没记住的属性里，"记错"（而不是"不知道"）的比例；prompt_sens：换问法对编造值的影响强度。
    """

    def __init__(self, seed=12, n_base=120, n_namesakes=30, p_stale=0.2,
                 p_known_lo=0.10, p_known_hi=0.95, q_wrong=0.40, prompt_sens=1.0):
        rng = random.Random(seed)
        self.prompt_sens = prompt_sens
        self.entities = []
        used = set()            # 只用来判断"这个名字用过没有"，不遍历，所以不影响可复现性
        # ---- 1. 基础人物 ----
        for _ in range(n_base):
            while True:
                first = rng.choice(FIRST_NAMES)
                last = "".join(rng.choice(SYLLABLES) for _ in range(rng.choice([2, 3]))).capitalize()
                if (first, last) not in used:
                    used.add((first, last))
                    break
            self._add_entity(rng, first, last)
        # ---- 2. 同名者：名和姓都一样，只有中间名缩写不同 ----
        for base in rng.sample(range(n_base), n_namesakes):
            e = self.entities[base]
            ns = self._add_entity(rng, e.first, e.last, avoid_initial=e.name.split()[1][0])
            ns.namesake, e.namesake = e.eid, ns.eid
        # ---- 3. 知识库段落 ----
        self.passages = []
        self.stale_of = {}       # eid -> 过时段落里的旧雇主
        for e in self.entities:
            rels = RELATIONS[:]
            rng.shuffle(rels)              # 每人随机分组：哪些属性写在同一段里
            start = 0
            for size in PASSAGE_SIZES:
                group = sorted(rels[start:start + size], key=RELATIONS.index)
                self._add_passage(e.eid, [(e.eid, r, e.facts[r]) for r in group], "bio")
                start += size
        for e in self.entities:
            if rng.random() < p_stale:
                old = rng.choice([x for x in EMPLOYERS if x != e.facts["employer"]])
                self.stale_of[e.eid] = old
                text = (e.name + " " + CLAUSE["employer"].format(v=old) + " (staff directory, 1998).")
                self.passages.append(Passage(len(self.passages), e.eid, [(e.eid, "employer", old)], text, "stale"))
        self.bm25 = BM25([p.text for p in self.passages])
        # ---- 4. 模型的参数记忆 ----
        self.memory = {}
        for e in self.entities:
            p_known = p_known_lo + (p_known_hi - p_known_lo) * e.popularity
            for r in RELATIONS:
                self.memory[(e.eid, r)] = self._make_memory(rng, e, r, p_known, q_wrong)
        self._fab_cache = {}
        # ---- 5. 每人一篇真实论文，以及模型对它的记忆 ----
        self.papers = {}         # eid -> (title, coauthors)
        self.paper_memory = {}   # eid -> strength（0 表示没见过这篇论文）
        for e in self.entities:
            title = "%s %s for %s" % (rng.choice(TITLE_ADJ), rng.choice(TITLE_NOUN), e.facts["field"])
            others = [x.name for x in self.entities if x.eid not in (e.eid, e.namesake)]
            self.papers[e.eid] = (title, tuple(sorted(rng.sample(others, 2))))
            p_known = p_known_lo + (p_known_hi - p_known_lo) * e.popularity
            self.paper_memory[e.eid] = (_clip(0.55 + 0.45 * e.popularity + rng.gauss(0, 0.08), 0.3, 0.99)
                                        if rng.random() < p_known else 0.0)

    # ---------------------------------------------------------------- 构造用的小函数
    def _add_entity(self, rng, first, last, avoid_initial=None):
        """生成一个人：随机属性 + 随机 popularity。返回新建的 Entity。"""
        init = rng.choice([c for c in INITIALS if c != avoid_initial])
        name = first + " " + init + ". " + last
        by = rng.randint(1935, 1985)
        facts = {
            "born_year": by,
            "born_city": rng.choice(CITIES),
            "field": rng.choice(FIELDS),
            "phd_univ": rng.choice(UNIVERSITIES),
            "phd_year": by + rng.randint(25, 31),
            "employer": rng.choice(EMPLOYERS),
            "award": rng.choice(AWARDS),
        }
        e = Entity(len(self.entities), name, first, last, facts, rng.random())
        self.entities.append(e)
        return e

    def _add_passage(self, eid, facts, kind):
        name = self.entities[eid].name
        text = name + " " + " and ".join(CLAUSE[r].format(v=v) for _, r, v in facts) + "."
        self.passages.append(Passage(len(self.passages), eid, facts, text, kind))

    def _make_memory(self, rng, e, r, p_known, q_wrong):
        """
        决定模型对 (e, r) 记得对 / 记错 / 不知道。

        【思路】有名的人（popularity 高）出现在训练语料里的次数多 → 更可能记得对（known）。
            没记住的那部分里，q_wrong 的比例是"记错了"：一半以上来自**同名者混淆**（训练语料里两个
            同名的人被混成一个），其余是一个随机的错值（数值型则是真值 ± 1–4 年）。
            记错的值在每次生成时都一样 —— 这就是"一致的幻觉"（consistent hallucination）。
        【边界】strength 截断在 [0.3, 0.99]：再有名的事实也不是 100% 想得起来。
        """
        truth = e.facts[r]
        u = rng.random()
        strength = _clip(0.55 + 0.45 * e.popularity + rng.gauss(0, 0.08), 0.3, 0.99)
        if u < p_known:
            return Memory("known", truth, strength)
        if rng.random() < q_wrong:
            if e.namesake >= 0 and rng.random() < 0.6 and self.entities[e.namesake].facts[r] != truth:
                wrong = self.entities[e.namesake].facts[r]
            elif r in NUMERIC:
                wrong = truth + rng.choice([-1, 1]) * rng.randint(1, 4)
            else:
                wrong = rng.choice([v for v in CANDIDATES[r] if v != truth])
            return Memory("wrong", wrong, strength)
        return Memory("unknown", None, 0.0)

    # ---------------------------------------------------------------- 检索
    def retrieve(self, query, k):
        """BM25 检索 top-k 段落（返回 Passage 列表）。"""
        return [self.passages[i] for i in self.bm25.top(query, k)]

    def passages_of(self, eid):
        """某人在知识库里的全部段落（包括过时段落）。"""
        return [p for p in self.passages if p.eid == eid]

    # ---------------------------------------------------------------- 真相（oracle）
    def is_true(self, c):
        """claim 的值是否等于这个人这个属性的真值（factuality）。"""
        return self.entities[c.eid].facts[c.relation] == c.value

    @staticmethod
    def is_grounded(c, passages):
        """
        claim 是否被给定段落之一**陈述**（grounding / faithfulness）。

        【思路】要求主体（eid）、属性、值都一致：同名者段落里的同样的值**不算**依据。
        【可信联系】grounded ≠ true：过时段落陈述的旧雇主是 grounded 但不 true（§1.3 的 2×2 表）。
        【Python 小知识】any(生成器) 只要有一个为真就返回 True，并且会提前停止。
        """
        key = (c.eid, c.relation, c.value)
        return any(key in p.facts for p in passages)

    # ---------------------------------------------------------------- 编造
    def fab_logits(self, eid, r, variant):
        """
        模型在"不知道"时，对每个候选值的偏好（logit）。

        【思路】logit = 这个 (人, 属性) 固有的偏好 + prompt_sens × 这种问法（variant）带来的扰动。
            数值型属性再加一个以"大致年代"为中心的二次惩罚，所以编出来的年份通常离真值不远
            （近似错误 near-miss 正是 NLI 最难分辨的，§5.4）。
            温度 T → 0 时永远选 logit 最大的那个：**编造也会变得一致**（§6.1 的温度实验）。
        【Python 小知识】random.Random("fab|3|award") 用字符串做种子：Python 会对字符串做 SHA-512，
            结果与 PYTHONHASHSEED 无关、与 Python 版本无关，所以同一个 (人, 属性, 问法) 永远得到同一组数。
        【边界】结果缓存在 self._fab_cache 里，同一组参数只算一次。
        """
        key = (eid, r, variant)
        if key in self._fab_cache:
            return self._fab_cache[key]
        cands = CANDIDATES[r]
        base = random.Random("fab|%d|%s" % (eid, r))
        pert = random.Random("fabv|%d|%s|%d" % (eid, r, variant))
        logits = [base.gauss(0, 1) + self.prompt_sens * pert.gauss(0, 1) for _ in cands]
        if r in NUMERIC:
            center = self.entities[eid].facts[r] + base.gauss(0, 5)
            logits = [lg - ((y - center) / 6.0) ** 2 for lg, y in zip(logits, cands)]
        self._fab_cache[key] = logits
        return logits

    def fabricate(self, eid, r, temperature, variant, rng):
        """
        "编"一个值：按 softmax(logit / T) 抽样；T = 0 时取 argmax（greedy decoding）。

        【思路】这是采样一致性检测（SelfCheckGPT）的前提：不知道的东西每次编得不一样。
        【边界】减去最大 logit 再取 exp，避免上溢（log-sum-exp 技巧的简化版）。
        【Python 小知识】rng.choices(候选, weights=权重) 按权重抽一个（返回列表，取 [0]）。
        """
        logits = self.fab_logits(eid, r, variant)
        cands = CANDIDATES[r]
        if temperature <= 0:
            best = max(range(len(cands)), key=lambda i: (logits[i], -i))
            return cands[best]
        m = max(logits)
        w = [math.exp((lg - m) / temperature) for lg in logits]
        return rng.choices(cands, weights=w)[0]

    # ---------------------------------------------------------------- 生成
    def answer_value(self, eid, r, rng, context=None, temperature=0.0, variant=0, cfg=None):
        """
        模拟 LLM 决定"这个人这个属性写什么"。返回 Claim，或 None（略去不写）。

        【思路】按顺序走一遍真实 LLM 的几条"信息来源"：
            1. 上下文里有这个属性吗？有 → 以 p_use_context 的概率照着写（可能读错、可能混进同名者）。
            2. strict prompt 要求只用上下文 → 上下文没有的属性，大多略去。
            3. 参数记忆：known / wrong 且"想得起来" → 写记忆里的值。
            4. 否则：abstain 模式下可能意识到"不知道"而略去；不然就**编**。
        【参数】context：Passage 列表或 None（闭卷）；temperature：0 = greedy；variant：问法编号；cfg：GenConfig。
        【返回】Claim（带 source 标签）或 None。
        【可信联系】这一个函数里就包含了讲义 §2 的大部分"幻觉成因"：知识缺口（unknown）、
            记错（wrong）、采样随机性（temperature）、知识冲突（p_use_context）、读错上下文（p_misread）。
        """
        cfg = cfg or GenConfig()
        ent = self.entities[eid]
        # ---- 1. 上下文 ----
        if context:
            opts = []      # (值, 权重, 来源)
            for p in context:
                for feid, frel, fval in p.facts:
                    if frel != r:
                        continue
                    if feid == eid:
                        opts.append((fval, 1.0, "context_stale" if p.kind == "stale" else "context"))
                    elif feid == ent.namesake:
                        opts.append((fval, cfg.w_namesake, "namesake"))
            p_ctx = cfg.p_use_context_strict if cfg.strict else cfg.p_use_context
            if opts and rng.random() < p_ctx:
                if rng.random() < cfg.p_misread:
                    v = self._misread(r, opts[0][0], context, rng)
                    return Claim(eid, r, v, "misread", ent.name)
                pick = rng.choices(range(len(opts)), weights=[o[1] for o in opts])[0]
                return Claim(eid, r, opts[pick][0], opts[pick][2], ent.name)
            if not opts and cfg.strict and rng.random() < cfg.p_omit_strict:
                return None
        # ---- 2. 参数记忆 ----
        mem = self.memory[(eid, r)]
        if mem.kind != "unknown":
            recalled = (mem.strength >= 0.5) if temperature <= 0 else (rng.random() < mem.strength)
            if recalled:
                return Claim(eid, r, mem.value, "mem_known" if mem.kind == "known" else "mem_wrong", ent.name)
        # ---- 3. 不知道（或这次没想起来）----
        if cfg.abstain and mem.kind == "unknown" and rng.random() < cfg.p_aware:
            return None
        return Claim(eid, r, self.fabricate(eid, r, temperature, variant, rng), "fabricated", ent.name)

    @staticmethod
    def _misread(r, right, context, rng):
        """
        读错上下文：数值型 → 抄成上下文里另一个年份或差 1–2 年；类别型 → 换成另一个候选值。
        这样得到的 claim **和上下文矛盾**（intrinsic hallucination）。
        """
        if r in NUMERIC:
            others = [v for p in context for _, rr, v in p.facts if rr in NUMERIC and v != right]
            if others and rng.random() < 0.5:
                return rng.choice(others)
            return right + rng.choice([-2, -1, 1, 2])
        return rng.choice([v for v in CANDIDATES[r] if v != right])

    def generate_bio(self, eid, rng, context=None, temperature=0.0, variant=0, cfg=None):
        """
        模拟 LLM 写一段短传记："Write a short biography of <name>."

        【思路】先决定写哪些属性（每个以 p_mention 的概率），逐个调用 answer_value 得到 claim，
            再把 claim 渲染成句子：可能合成复合句（"X was born in 1952 and comes from Tarsk."），
            第二句以后可能用 "This researcher" 指代，开头可能有一句客套话。
        【参数】eid：写谁；rng：这个演示自己的随机数生成器；context：检索段落（None = 闭卷）；
            temperature、variant、cfg：见 answer_value。
        【返回】Response。
        【边界】至少写 2 个属性（避免空回答）；被略去的属性不会出现在回答里。
        """
        cfg = cfg or GenConfig()
        rels = [r for r in RELATIONS if rng.random() < cfg.p_mention]
        if len(rels) < 2:
            rels = sorted(rng.sample(RELATIONS, 2), key=RELATIONS.index)
        claims = []
        for r in rels:
            c = self.answer_value(eid, r, rng, context, temperature, variant, cfg)
            if c is not None:
                claims.append(c)
        return Response(eid, self._render(eid, claims, rng, cfg), list(context or []))

    def _render(self, eid, claims, rng, cfg):
        """把 claim 列表渲染成句子列表（见 generate_bio）。"""
        name = self.entities[eid].name
        sents = []
        if rng.random() < cfg.p_filler:
            sents.append(Sentence(FILLER, [], False))
        i, first = 0, True
        while i < len(claims):
            take = 2 if (i + 1 < len(claims) and rng.random() < cfg.p_compound) else 1
            group = claims[i:i + take]
            pron = (not first) and rng.random() < cfg.p_pronoun
            subj = PRONOUN if pron else name
            text = subj + " " + " and ".join(CLAUSE[c.relation].format(v=c.value) for c in group) + "."
            sents.append(Sentence(text, group, pron))
            first = False
            i += take
        return sents

    # ---------------------------------------------------------------- 参考文献（§6.2）
    def _softmax_pick(self, logits, temperature, rng):
        """按 softmax(logit / T) 抽一个下标；T = 0 时取 argmax（平局取下标小的）。"""
        if temperature <= 0:
            return max(range(len(logits)), key=lambda i: (logits[i], -i))
        m = max(logits)
        return rng.choices(range(len(logits)), weights=[math.exp((lg - m) / temperature) for lg in logits])[0]

    def _fab_coauthors(self, eid, title, temperature, rng):
        """
        为一个（编出来的）标题"回忆"两个合作者：在所有人名上按 softmax 抽，不放回。

        【思路】偏好（logit）由**标题**决定（用标题做种子），所以 T = 0 时同一个标题每次给出同样的作者，
            T > 0 时每次可能不同 —— 编出来的细节不稳定，这正是 indirect query 的依据。
        """
        names = [x.name for x in self.entities if x.eid != eid]
        g = random.Random("pauth|" + title)
        logits = [1.5 * g.gauss(0, 1) for _ in names]
        out = []
        for _ in range(2):
            i = self._softmax_pick(logits, temperature, rng)
            out.append(names[i])
            logits[i] = -1e9          # 不放回：同一个人不会出现两次
        return tuple(sorted(out))

    def cite_paper(self, eid, rng, temperature=0.0, variant=0):
        """
        模型在传记末尾"给一条参考文献"。

        【思路】见过这篇论文（paper_memory > 0）且这次想得起来 → 给出真实的标题和合作者；
            否则编一个标题（在 64 个"形容词 + 名词"组合里按 softmax 挑）和两个合作者。
        【返回】Reference
        """
        strength = self.paper_memory[eid]
        recalled = strength > 0 and ((strength >= 0.5) if temperature <= 0 else (rng.random() < strength))
        if recalled:
            title, co = self.papers[eid]
            return Reference(eid, title, co, True)
        g = random.Random("ptitle|%d|%d" % (eid, variant))
        combos = [(a, n) for a in TITLE_ADJ for n in TITLE_NOUN]
        logits = [1.5 * g.gauss(0, 1) for _ in combos]
        a, n = combos[self._softmax_pick(logits, temperature, rng)]
        title = "%s %s for %s" % (a, n, self.entities[eid].facts["field"])
        if title == self.papers[eid][0]:      # 碰巧编中了真标题：当作真引用
            return Reference(eid, title, self.papers[eid][1], True)
        return Reference(eid, title, self._fab_coauthors(eid, title, temperature, rng), False)

    def ask_exists(self, ref, rng, p_real=0.92, p_fake=0.6):
        """
        Direct query："Does the paper '<title>' exist?" 模型回答 yes 的概率：
        真实论文 p_real；编出来的论文 p_fake（模型倾向于认可自己说过的话）。

        【可信联系】这两个数是**明确的假设**：直接问能分辨多少，完全取决于模型"认得"自己编的东西的程度。
            Agrawal et al.（2023）发现，在 GPT-4 上直接问和间接问效果接近，在 ChatGPT / Llama-2-70B 上间接问更好。
        """
        return rng.random() < (p_real if ref.real else p_fake)

    def ask_coauthors(self, ref, rng, temperature=1.0):
        """
        Indirect query："Who are the authors of '<title>'?"（每次是一个新的会话）

        【思路】真实论文：以 strength 的概率答对，否则编；编出来的论文：从同一个"按标题定的"分布里重新抽。
        """
        strength = self.paper_memory[ref.eid]
        if ref.real and strength > 0 and ((strength >= 0.5) if temperature <= 0 else (rng.random() < strength)):
            return self.papers[ref.eid][1]
        return self._fab_coauthors(ref.eid, ref.title, temperature, rng)

    def rag_context(self, eid, k=5):
        """
        RAG 的检索：query = "biography of <name>"，取 BM25 top-k（默认 k = 5）。

        【边界】k = 5 时平均能找回本人约 82% 的属性：有同名者的人（占 40%），检索器分不清两人，
            top-5 里混着两个人的段落，本人的信息常常不全（retrieval is missing information）；
            有过时段落的人，过时段落和正确段落可能同时出现（disputed information，Fall 2024 slides）。
        """
        return self.retrieve("biography of " + self.entities[eid].name, k)

    # ---------------------------------------------------------------- 统计
    def memory_counts(self):
        """返回 {"known": n, "wrong": n, "unknown": n}（按 RELATIONS × 人 计数）。"""
        out = {"known": 0, "wrong": 0, "unknown": 0}
        for m in self.memory.values():
            out[m.kind] += 1
        return out


def popularity_tier(e):
    """把 popularity 分成三档：head（> 0.7）、torso、tail（< 0.3）。"""
    return "head" if e.popularity > 0.7 else ("tail" if e.popularity < 0.3 else "torso")


# =============================================================================
# 自检
# =============================================================================
if __name__ == "__main__":
    w = World()
    print("entities:", len(w.entities), " passages:", len(w.passages),
          " stale passages:", len(w.stale_of), " namesake pairs:", sum(1 for e in w.entities if e.namesake >= 0) // 2)
    print("memory:", w.memory_counts())
    e = next(x for x in w.entities if x.namesake >= 0 and x.eid in w.stale_of)
    print("\n例子：", e.name, e.facts, " popularity=%.2f" % e.popularity)
    print("RAG 检索 top-5：")
    for p in w.rag_context(e.eid):
        print("   [%3d %-5s] %s" % (p.pid, p.kind, p.text))
    rng = random.Random(0)
    for label, ctx in [("闭卷 closed-book", None), ("RAG k=5", w.rag_context(e.eid))]:
        r = w.generate_bio(e.eid, rng, context=ctx)
        print("\n" + label + "：", r.text())
        for c in r.claims():
            print("   %-10s %-22s %-14s true=%s grounded=%s" % (c.relation, c.value, c.source, w.is_true(c),
                                                             w.is_grounded(c, ctx or [])))
