"""
=============================================================================
CS525 · 第 11 讲模块：知识世界、学生模型与合成数据的生成器（knowledge_world.py）
=============================================================================

对应讲义：lectures/lecture11_synthetic_data.md §0.4、§3、§4

-----------------------------------------------------------------------------
【这个模块提供什么】
-----------------------------------------------------------------------------
    Learner              学生模型：对每条"知识"记录见过多少种**不同的表述**；
                         知道它的概率 = 1 − exp(−有效曝光 / κ)（见下面"为什么这样建模"）
    mc_accuracy          四选一问答的期望准确率（不知道就猜，25%）
    StoryWorld           EntiGraph 的世界：60 本"冷门书"，每本书里 16 个实体，分成 2 个"情节"；
                         书里只写出了情节内的一部分关系（显式），其余关系要推理才知道（隐式）
    ConceptWorld         SBP 和第 03 个脚本的世界：300 个"概念"（人物、地点、事件……），每个有 10 个属性；
                         网页文档讲某个概念的几个属性；概念的热度服从 Zipf 分布（少数概念被反复讲，大多数只出现一两次）

-----------------------------------------------------------------------------
【为什么这样建模学生】
-----------------------------------------------------------------------------
EntiGraph 论文的出发点：语言模型从数据里学一条事实，需要看到它的**很多种不同的表述**（论文说"几百到几千种"）。
Allen-Zhu & Li（*Physics of Language Models 3.1*，2023）用受控实验说明了原因：
同一条事实只以一种方式出现时，模型能把这段文字背下来，却不能在别的问法下"提取"出这条知识；
把同样的内容改写成多种表述（换顺序、换句式）之后，问答准确率从很低升到很高。

所以这里的学生模型只记两件事：一条知识（一个"键"）
    出现过多少种**不同的表述**（distinct），以及
    同一种表述被**重复**了多少次（repeat）。
有效曝光 E = distinct + β · repeat（β 很小：原样重复几乎教不了"提取"），
知道这条知识的概率 = 1 − exp(−E / κ)。

它是一个刻画"多样性 vs 重复"的最小模型：没有语言、没有遗忘（真实的继续预训练会让模型忘掉一部分通用能力，
EntiGraph 论文里"直接在原书上继续预训练"比基础模型还差，就有这个原因）。讲义会说明哪些结论依赖于这些简化。

-----------------------------------------------------------------------------
【数据联系】
-----------------------------------------------------------------------------
    * 第 7 讲 Nemotron-CC 的改写、第 10 讲"小领域的瓶颈是独特数据量"——本讲研究怎样"造出"更多独特的表述。
    * 第 13 讲（数据归因）会问：模型的某个回答来自哪些训练文档？合成数据让这个问题更难。
"""

import numpy as np

# ============================================================================= 学生模型


class Learner:
    """
    学生模型：对每个"知识键"记录不同表述的数量和重复的次数。

    【参数】n_keys：知识键的总数；kappa：需要多少有效曝光才"大概知道"（越大越难学）；
            beta：一次原样重复相当于多少次新表述
    【思路】训练数据 = 一串 (键, 表述编号) 的曝光。同一个 (键, 表述) 出现第二次起算"重复"。
            用 np.unique 对 键 × 表述空间 + 表述 的组合编码去重，就能一次算出每个键的 distinct 和 repeat。
    【边界】表述编号要求是非负整数，且小于 form_space（构造时给的上限）。
    """

    def __init__(self, n_keys, kappa=4.0, beta=0.5, form_space=1 << 40):
        self.n_keys, self.kappa, self.beta, self.form_space = n_keys, kappa, beta, form_space
        self.keys = []
        self.forms = []

    def expose(self, keys, forms, times=1):
        """
        加入一批曝光。

        【参数】keys、forms：等长的整数数组；times：这批数据被看几遍（原样重复，例如把原书重复 20 遍）
        【边界】times 可以是小数（例如 12.5 遍）：整数部分当成整遍，小数部分按比例随机挑一部分再看一遍
                （为了确定性，挑的是前面那一部分）。
        """
        keys = np.asarray(keys, dtype=np.int64)
        forms = np.asarray(forms, dtype=np.int64)
        whole = int(times)
        for _ in range(whole):
            self.keys.append(keys)
            self.forms.append(forms)
        frac = times - whole
        if frac > 0:
            m = int(round(frac * len(keys)))
            self.keys.append(keys[:m])
            self.forms.append(forms[:m])

    def known_prob(self):
        """
        每个键"被知道"的概率。

        【返回】长度 n_keys 的数组：1 − exp(−(distinct + β·repeat)/κ)
        【numpy 小知识】np.unique(code, return_counts=False) 去重；np.bincount(keys, minlength=n) 按键计数。
        """
        if not self.keys:
            return np.zeros(self.n_keys)
        k = np.concatenate(self.keys)
        f = np.concatenate(self.forms)
        total = np.bincount(k, minlength=self.n_keys).astype(float)
        uniq = np.unique(k * self.form_space + f)
        distinct = np.bincount(uniq // self.form_space, minlength=self.n_keys).astype(float)
        eff = distinct + self.beta * np.log1p(total - distinct)
        return 1.0 - np.exp(-eff / self.kappa)


def mc_accuracy(q_opts, correct_col=0):
    """
    四选一问答的期望准确率。

    【参数】q_opts：(题数, 4) 数组，学生"知道"每个选项是答案的概率（第 correct_col 列是正确答案）
    【返回】每道题答对的概率（长度为题数的数组）
    【思路】假设学生对每个选项是否"知道"相互独立：知道的选项里随机挑一个；一个都不知道就四个里随机猜。
            枚举 2^4 = 16 种"知道哪些选项"的组合，精确地算期望：
                P(对) = Σ_S P(知道的集合 = S) · [正确答案 ∈ S] / |S|  +  P(S 为空) · 1/4
    【边界】错误选项被"知道"（例如学过幻觉生成的错误事实）会把学生引向错误答案——这就是幻觉的代价。
    """
    q = np.clip(np.asarray(q_opts, float), 0.0, 1.0)
    n, k = q.shape
    acc = np.zeros(n)
    for mask in range(1 << k):
        inc = np.array([(mask >> j) & 1 for j in range(k)], dtype=bool)
        p = np.prod(np.where(inc[None, :], q, 1 - q), axis=1)
        size = inc.sum()
        if size == 0:
            acc += p / k
        elif inc[correct_col]:
            acc += p / size
    return acc


# ============================================================================= EntiGraph 的世界


class StoryWorld:
    """
    60 本"冷门书"（像 QuALITY 的 265 本书），每本 16 个实体，分成 2 个"情节"，每个情节 8 个实体。

    【参数】seed；n_docs：书的数量；clusters、per_cluster：每本书几个情节、每个情节几个实体
    【属性】
        n_ent：实体总数；doc_of[e]：实体 e 在哪本书；cluster_of[e]：在哪个情节
        edges：显式关系（书里写出来的），每个情节是一棵随机树（8 个实体、7 条边）
        dist：(n_ent, n_ent) 同一情节内的最短路径长度（1 = 显式，2–7 = 隐式，0 = 不相关或自己）
        questions：(题数, 4) 的实体编号：第 0 列是问题的主语 x，第 1 列是正确答案 y（和 x 在同一情节），
                   第 2–4 列是干扰项（同一本书、不同情节的实体——"看起来相关、其实不相关"）
        q_dist：每道题的关系距离（1 = 显式，≥ 2 = 隐式）
    【知识键】关系 (x, y) 不分方向，键 = min·n_ent + max。
    【token 记账】书里每条关系约 20 个 token，每本书另有 40 个 token 的其他内容（一本书约 280 个 token）。
    """

    TOK_FACT, TOK_OVERHEAD = 20, 40

    def __init__(self, seed=0, n_docs=60, clusters=2, per_cluster=8):
        rng = np.random.default_rng(seed)
        self.n_docs, self.per_doc = n_docs, clusters * per_cluster
        self.n_ent = n_docs * self.per_doc
        ents = np.arange(self.n_ent)
        self.doc_of = ents // self.per_doc
        self.cluster_of = ents // per_cluster                     # 全局唯一的情节编号
        edges = []
        for c in range(n_docs * clusters):
            members = c * per_cluster + rng.permutation(per_cluster)
            for i in range(1, per_cluster):                       # 随机树：每个新节点连到前面某个节点
                edges.append((members[i], members[rng.integers(0, i)]))
        self.edges = np.array(edges)
        self.dist = self._distances(per_cluster)
        self.doc_edges = [self.edges[self.doc_of[self.edges[:, 0]] == d] for d in range(n_docs)]
        self.questions, self.q_dist = self._make_questions(rng)

    def key(self, a, b):
        a, b = np.minimum(a, b), np.maximum(a, b)
        return a * self.n_ent + b

    @property
    def n_keys(self):
        return self.n_ent * self.n_ent

    def _distances(self, per_cluster):
        """情节内的最短路径（Floyd–Warshall，每个情节只有 8 个节点）；不同情节的距离记为 0（不相关）。"""
        d = np.zeros((self.n_ent, self.n_ent), dtype=int)
        for c in range(self.n_ent // per_cluster):
            m = np.arange(c * per_cluster, (c + 1) * per_cluster)
            inf = 99
            D = np.full((per_cluster, per_cluster), inf)
            np.fill_diagonal(D, 0)
            sel = (self.cluster_of[self.edges[:, 0]] == c)
            for a, b in self.edges[sel]:
                D[a - m[0], b - m[0]] = D[b - m[0], a - m[0]] = 1
            for k in range(per_cluster):
                D = np.minimum(D, D[:, k:k + 1] + D[k:k + 1, :])
            d[np.ix_(m, m)] = D
        return d

    def _make_questions(self, rng):
        """
        每个情节内的每一对实体出一道题（问"x 和谁有关系？"），干扰项是同一本书里别的情节的实体。
        """
        qs, ds = [], []
        for a in range(self.n_ent):
            for b in range(a + 1, self.n_ent):
                if self.dist[a, b] > 0:
                    x, y = (a, b) if rng.random() < 0.5 else (b, a)
                    pool = np.flatnonzero((self.doc_of == self.doc_of[x]) & (self.cluster_of != self.cluster_of[x]))
                    qs.append([x, y, *rng.choice(pool, 3, replace=False)])
                    ds.append(self.dist[a, b])
        return np.array(qs), np.array(ds)

    def source_tokens(self):
        """原始语料的 token 数。"""
        return self.n_docs * self.TOK_OVERHEAD + len(self.edges) * self.TOK_FACT

    def accuracy(self, known):
        """
        闭卷问答：学生对每道题的 4 个选项各自"知道 (x, 选项) 有关系"的概率 → 期望准确率。

        【参数】known：Learner.known_prob() 的结果
        【返回】每道题答对的概率
        """
        x = self.questions[:, :1]
        q = known[self.key(x, self.questions[:, 1:])]
        return mc_accuracy(q)

    # --------------------------------------------------------------------- 数据
    def raw_exposures(self):
        """原书：每条显式关系以一种表述出现一次（表述编号 0）。"""
        return self.key(self.edges[:, 0], self.edges[:, 1]), np.zeros(len(self.edges), dtype=np.int64)

    def rephrase(self, n_rewrites, rng, styles=4, variants=3, p_halluc=0.01):
        """
        改写基线（WRAP / EntiGraph 论文的 Rephrase baseline）：让老师把整本书换个风格重写一遍。

        【参数】n_rewrites：一共改写多少次（每次随机挑一本书）；styles × variants：一条关系最多有几种不同的表述
                （4 种风格，每种风格的多次采样只有几种不同的说法）；p_halluc：每条关系被写错的概率
        【返回】(键, 表述, token 数)
        【思路】改写只换说法，不增加新的关系——书里没写出来的隐式关系，改写也不会写出来。
        """
        keys, forms = [], []
        docs = rng.integers(0, self.n_docs, size=n_rewrites)
        for d in docs:
            e = self.doc_edges[d]
            f = 1 + rng.integers(0, styles * variants, size=len(e))
            a, b = e[:, 0].copy(), e[:, 1].copy()
            bad = rng.random(len(e)) < p_halluc
            b[bad] = self._wrong_partner(a[bad], rng)
            keys.append(self.key(a, b))
            forms.append(f)
        k = np.concatenate(keys) if keys else np.zeros(0, dtype=np.int64)
        f = np.concatenate(forms) if forms else np.zeros(0, dtype=np.int64)
        tokens = n_rewrites * self.TOK_OVERHEAD + len(k) * self.TOK_FACT
        return k, f, tokens

    def entigraph(self, n_gen, rng, reason=(0.95, 0.81, 0.69, 0.58, 0.5, 0.42, 0.36), p_halluc=0.02,
                  all_pairs_on_path=True):
        """
        EntiGraph：从一本书里抽出实体，随机挑一对，让老师（把整本书放在上下文里）分析它们的关系。

        【参数】n_gen：生成多少段文字；reason[d−1]：两个实体相距 d 步时，老师在上下文里找到这条关系链的概率；
                p_halluc：老师"编造"一个关系的概率（把 x 和另一个情节的实体说成有关系）；
                all_pairs_on_path：True 时，一段分析会讲到路径上任意两个实体的关系（EntiGraph 论文的玩具模型的做法）
        【返回】(键, 表述, token 数)
        【思路】
            1. 实体抽取：每本书的 16 个实体；
            2. 关系分析：随机挑一本书、一对实体 (x, y)。
               在同一个情节里（相距 d 步），老师以 reason[d−1] 的概率找到连接它们的关系链，把链上的关系都讲一遍；
               不在同一个情节：老师写一段"它们没有直接关系"的分析（不产生知识，但也占 token）。
            3. 每段生成的文字都是一种**新的表述**（不同的实体组合 → 不同的上下文）——这就是 EntiGraph 的多样性来源。
        【数据联系】论文的提示词也生成三个实体的组合；这里只做两个实体的组合，不影响结论。
        """
        keys, forms = [], []
        tokens = 0
        form_id = 1_000                                        # 和原书、改写的表述编号错开
        docs = rng.integers(0, self.n_docs, size=n_gen)
        offs = np.argsort(rng.random((n_gen, self.per_doc)), axis=1)[:, :2]   # 每段随机挑两个不同的实体
        for g in range(n_gen):
            x, y = docs[g] * self.per_doc + offs[g]
            d = self.dist[x, y]
            tokens += self.TOK_OVERHEAD
            if d == 0 or rng.random() > reason[min(d, len(reason)) - 1]:
                if rng.random() < p_halluc:                     # 编造一个关系
                    keys.append([self.key(x, y)])
                    forms.append([form_id + g])
                    tokens += self.TOK_FACT
                continue
            path = self._path(x, y)
            if all_pairs_on_path:
                pa = [(path[i], path[j]) for i in range(len(path)) for j in range(i + 1, len(path))]
            else:
                pa = [(path[0], path[-1])] + [(path[i], path[i + 1]) for i in range(len(path) - 1)]
            pa = np.array(pa)
            keys.append(self.key(pa[:, 0], pa[:, 1]))
            forms.append(np.full(len(pa), form_id + g))
            tokens += len(pa) * self.TOK_FACT
        k = np.concatenate(keys) if keys else np.zeros(0, dtype=np.int64)
        f = np.concatenate(forms) if forms else np.zeros(0, dtype=np.int64)
        return k, f, tokens

    def synthesize(self, kind, budget, rng, chunk=None, **kw):
        """
        按 token 预算生成合成数据：反复调用 rephrase / entigraph，直到生成的 token 数达到 budget。

        【参数】kind："rephrase" 或 "entigraph"；budget：要生成多少 token；rng；chunk：每次生成几段
                （默认：改写每次 20 本书，EntiGraph 每次 500 段）；其余参数传给生成函数
        【返回】(键, 表述, 实际生成的 token 数)——最后一批可能让总数略超过 budget（不到一批的量）
        """
        fn = self.rephrase if kind == "rephrase" else self.entigraph
        chunk = chunk or (20 if kind == "rephrase" else 500)
        keys, forms, tokens = [], [], 0
        offset = 0
        while tokens < budget:
            k, f, t = fn(chunk, rng, **kw)
            if kind == "entigraph":
                f = f + offset                       # 每一批的表述编号错开，保证每段生成都是新的表述
                offset += chunk
            keys.append(k)
            forms.append(f)
            tokens += t
        return np.concatenate(keys), np.concatenate(forms), tokens

    def _path(self, x, y):
        """情节内从 x 到 y 的一条最短路径（沿着距离递减的邻居走）。"""
        path = [x]
        while path[-1] != y:
            cur = path[-1]
            nbrs = np.concatenate([self.edges[self.edges[:, 0] == cur, 1], self.edges[self.edges[:, 1] == cur, 0]])
            nxt = nbrs[np.argmin([self.dist[n, y] if n != y else 0 for n in nbrs])]
            path.append(int(nxt))
        return path

    def _wrong_partner(self, a, rng):
        """给每个实体随机挑一个同一本书、不同情节的实体（"看起来相关、其实不相关"）。"""
        out = np.empty(len(a), dtype=np.int64)
        for i, x in enumerate(a):
            pool = np.flatnonzero((self.doc_of == self.doc_of[x]) & (self.cluster_of != self.cluster_of[x]))
            out[i] = rng.choice(pool)
        return out


# ============================================================================= SBP 的世界


class ConceptWorld:
    """
    300 个"概念"，每个 10 个属性，每个属性的取值是 8 个候选值之一。网页文档讲某个概念的几个属性。

    【参数】seed；n_concepts、n_slots、n_values；zipf：概念热度的 Zipf 指数
    【属性】truth[c, s]：概念 c 的属性 s 的真实取值；pop：每个概念的热度（被写进文档的概率）
    【知识键】(概念, 属性, 取值)：键 = (c · n_slots + s) · n_values + v。学到错误的取值就是学到了"幻觉"。
    【数据联系】热门概念被很多文档反复讲（头部），冷门概念只出现一两次（长尾）——第 6 讲的知识世界也是这个结构。
    """

    TOK_FACT, TOK_OVERHEAD = 20, 40

    def __init__(self, seed=0, n_concepts=300, n_slots=10, n_values=8, zipf=1.0):
        rng = np.random.default_rng(seed)
        self.C, self.S, self.V = n_concepts, n_slots, n_values
        self.truth = rng.integers(0, n_values, size=(n_concepts, n_slots))
        w = 1.0 / np.arange(1, n_concepts + 1) ** zipf
        self.pop = w / w.sum()
        self._form_counter = 1

    @property
    def n_keys(self):
        return self.C * self.S * self.V

    def key(self, c, s, v):
        return (np.asarray(c) * self.S + np.asarray(s)) * self.V + np.asarray(v)

    def new_forms(self, n):
        """n 个从没用过的表述编号（每篇新文档、每次生成都用新的表述）。"""
        f = np.arange(self._form_counter, self._form_counter + n, dtype=np.int64)
        self._form_counter += n
        return f

    def sample_docs(self, n_docs, rng, facts_per_doc=4):
        """
        抽 n_docs 篇真实文档：每篇按热度挑一个概念，讲它随机 facts_per_doc 个属性的真实取值。

        【返回】字典：concept（每篇的概念）、slots（每篇的属性，形状 (n, k)）、forms（每条事实的表述编号）
        """
        c = rng.choice(self.C, size=n_docs, p=self.pop)
        slots = np.argsort(rng.random((n_docs, self.S)), axis=1)[:, :facts_per_doc]
        forms = self.new_forms(n_docs * facts_per_doc).reshape(n_docs, facts_per_doc)
        return {"concept": c, "slots": slots, "forms": forms}

    def doc_exposures(self, docs):
        """文档 → (键, 表述)。"""
        c = np.repeat(docs["concept"], docs["slots"].shape[1])
        s = docs["slots"].ravel()
        return self.key(c, s, self.truth[c, s]), docs["forms"].ravel()

    def doc_tokens(self, n_docs, facts_per_doc=4):
        return n_docs * (self.TOK_OVERHEAD + facts_per_doc * self.TOK_FACT)

    def questions(self, concepts, rng):
        """
        对给定的概念，每个属性出一道四选一题：选项 = 真实取值 + 3 个随机的错误取值。

        【返回】(概念, 属性, 选项 (n, 4))，第 0 列是正确答案
        """
        c = np.repeat(concepts, self.S)
        s = np.tile(np.arange(self.S), len(concepts))
        opts = np.zeros((len(c), 4), dtype=np.int64)
        opts[:, 0] = self.truth[c, s]
        for i in range(len(c)):
            wrong = np.setdiff1d(np.arange(self.V), [opts[i, 0]])
            opts[i, 1:] = rng.choice(wrong, 3, replace=False)
        return c, s, opts

    def accuracy(self, known, qs):
        """闭卷问答的期望准确率（每道题）。"""
        c, s, opts = qs
        return mc_accuracy(known[self.key(c[:, None], s[:, None], opts)])

    def confident_wrong(self, known, qs, threshold=0.5):
        """
        "自信地答错"的比例：某个错误选项被知道的概率 > threshold，而正确答案没有。
        这是学到幻觉的直接后果（讲义称之为"幻觉率"）。
        """
        c, s, opts = qs
        q = known[self.key(c[:, None], s[:, None], opts)]
        return (q[:, 1:].max(1) > threshold) & (q[:, 0] <= threshold)


if __name__ == "__main__":
    sw = StoryWorld()
    print(f"StoryWorld：{sw.n_docs} 本书、{sw.n_ent} 个实体、{len(sw.edges)} 条显式关系、{len(sw.questions)} 道题"
          f"（显式 {np.sum(sw.q_dist == 1)}、隐式 {np.sum(sw.q_dist >= 2)}）；原书共 {sw.source_tokens()} 个 token")
    rng = np.random.default_rng(0)
    L = Learner(sw.n_keys)
    print(f"  什么都没学：准确率 {sw.accuracy(L.known_prob()).mean():.3f}（四选一的随机猜测）")
    k, f = sw.raw_exposures()
    L.expose(k, f)
    print(f"  原书看一遍：准确率 {sw.accuracy(L.known_prob()).mean():.3f}")
    cw = ConceptWorld()
    docs = cw.sample_docs(1500, rng)
    seen = np.unique(docs["concept"])
    print(f"ConceptWorld：{cw.C} 个概念 × {cw.S} 个属性；1500 篇文档覆盖了 {len(seen)} 个概念；"
          f"最热门的概念出现 {np.bincount(docs['concept']).max()} 次")
