"""
=============================================================================
第 2 讲 · 代码 2：去重（deduplication）——精确去重、MinHash + LSH 近似去重、重复子串
=============================================================================

对应讲义：lectures/lecture02_data.md 第 5 节

-----------------------------------------------------------------------------
【这个文件要回答的问题 What this file answers】
-----------------------------------------------------------------------------
网页数据里到处是重复：镜像站整页复制、转载换了页眉页脚、每个页面都带着同样的版权声明。
重复会让模型背诵、浪费算力、让评测失真（代码 03、04）。怎样在几十亿个文档里找出重复？
这个文件在 crawl_world.py 的迷你爬取上（我们知道哪些页面互为副本）回答四个问题：

    演示 1：精确去重——给每个页面算一个哈希，哈希相同就是重复。能找到多少副本？漏掉了什么？
    演示 2：Jaccard 相似度——"近似重复"怎样量化？为什么要先去掉页眉页脚？
    演示 3：MinHash 与 LSH——怎样不比较所有页面对，就找出相似的页面？
            MinHash 估计 Jaccard 的误差随哈希个数怎样变化；LSH 的"S 形曲线"。
    演示 4：MinHash + LSH 去重的精确率和召回率；以及用（截断的）后缀数组找出重复出现的长子串
            ——Lee et al. (2021) 的 ExactSubstr。

-----------------------------------------------------------------------------
【运行方式】
-----------------------------------------------------------------------------
    python3 02_dedup.py          # 约 1 秒

需要 numpy（MinHash 的向量化计算）。依赖本目录的 crawl_world.py 和第 1 讲的 console.py（只读导入）。

【Python 小知识】
    - hashlib.md5(b).hexdigest()：把一段字节变成一个固定长度的"指纹"（哈希）。内容相同 → 指纹相同；
      内容差一个字节 → 指纹完全不同。Python 自带的 hash() 每次运行会加随机盐，不能用来做可复现的去重。
    - zlib.crc32(b)：一个更快、更短（32 位）的哈希，这里用来把每个 shingle 变成一个整数。
"""

import hashlib
import math
import os
import random
import sys
import time
import zlib

import numpy as np

HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(HERE, "..", "lecture01"))

from console import pad, print_header  # noqa: E402
from crawl_world import SITES, build_crawl, tokenize  # noqa: E402

SHINGLE_N = 3          # 用几个相邻的词组成一个 shingle
PRIME = (1 << 31) - 1  # MinHash 的哈希函数 h(x) = (a·x + b) mod PRIME，PRIME = 2^31 − 1 是一个素数


# =============================================================================
# 工具：shingle、Jaccard、MinHash、LSH、并查集
# =============================================================================
def shingles(text, n=SHINGLE_N):
    """
    把文本变成 shingle 的集合：所有"相邻 n 个词"的片段。

    【例子】n = 3 时，"the soup was tasty" → {"the soup was", "soup was tasty"}
    【参数】text：文本；n：每个 shingle 几个词
    【返回】set[str]。文本少于 n 个词时，返回只含整段文本的集合（避免空集）
    【FM 联系】Lee et al. (2021) 的 NearDup 用 5-gram（5 个 token）做 shingle；我们的页面很短，用 3 个词。
    """
    ws = tokenize(text)
    if len(ws) < n:
        return {" ".join(ws)}
    return {" ".join(ws[i:i + n]) for i in range(len(ws) - n + 1)}


def jaccard(a, b):
    """
    两个集合的 Jaccard 相似度 = |A ∩ B| / |A ∪ B|，在 0（完全不同）到 1（完全相同）之间。

    【边界】两个都是空集时返回 1.0。
    """
    if not a and not b:
        return 1.0
    return len(a & b) / len(a | b)


def minhash_signature(sh, a, b):
    """
    一个 shingle 集合的 MinHash 签名：k 个哈希函数，每个取"集合里所有元素哈希值的最小值"。

    【思路】对一个随机的哈希函数 h，P[min h(A) == min h(B)] = Jaccard(A, B)。
            （直觉：把 A ∪ B 里的元素按 h 随机排个序，排第一的那个元素同时属于 A 和 B 的概率，
             正好是 |A ∩ B| / |A ∪ B|。）所以 k 个签名分量里"相等"的比例，就是 Jaccard 的无偏估计。
    【参数】sh：shingle 集合；a, b：两个长度为 k 的整数数组，第 i 个哈希函数是 (a[i]·x + b[i]) mod PRIME
    【返回】长度为 k 的 numpy 整数数组
    【numpy 小知识】x[None, :] 把一维数组变成 1 行的二维数组；(k, 1) 和 (1, m) 的数组相乘会"广播"成 (k, m)。
            .min(axis=1) 对每一行取最小值。这样 k 个哈希函数 × m 个 shingle 一次算完，不用写两层循环。
    """
    x = np.array(sorted(zlib.crc32(s.encode()) & 0x7FFFFFFF for s in sh), dtype=np.int64)
    return ((a[:, None] * x[None, :] + b[:, None]) % PRIME).min(axis=1)


def make_hash_params(k, seed):
    """生成 k 个随机哈希函数的参数 (a, b)。a 在 [1, PRIME) 里，b 在 [0, PRIME) 里。"""
    rng = np.random.default_rng(seed)
    return rng.integers(1, PRIME, size=k, dtype=np.int64), rng.integers(0, PRIME, size=k, dtype=np.int64)


def lsh_candidates(sigs, bands, rows):
    """
    局部敏感哈希（LSH）的"分带"（banding）技巧：找出可能相似的页面对，而不用比较所有页面对。

    【思路】把每个签名切成 bands 段、每段 rows 个数。两个页面只要**有一段完全相同**，就成为候选对。
            Jaccard 为 s 的两个页面，某一段完全相同的概率是 s^rows；至少一段相同的概率是
            1 − (1 − s^rows)^bands——这是一条 S 形曲线：s 大时接近 1，s 小时接近 0。
            每一段用一个字典（"桶"）：段的内容 → 落在这个桶里的页面。只有同一个桶里的页面才两两比较。
    【参数】sigs：(n_pages, bands·rows) 的签名矩阵；bands、rows：分带参数
    【返回】set[(i, j)]，i < j 的候选页面对
    【边界】一个桶里页面很多时（比如几百个几乎一样的垃圾页），候选对会按平方增长——真实系统要专门处理。
    """
    cands = set()
    for band in range(bands):
        buckets = {}
        for i, sig in enumerate(sigs):
            key = tuple(sig[band * rows:(band + 1) * rows].tolist())
            buckets.setdefault(key, []).append(i)
        for members in buckets.values():
            for x in range(len(members)):
                for y in range(x + 1, len(members)):
                    cands.add((members[x], members[y]))
    return cands


def clusters_from_pairs(n, pairs):
    """
    并查集（union-find）：把"相似的页面对"连成簇。A 像 B、B 像 C，则 A、B、C 在同一个簇。

    【参数】n：页面数；pairs：页面对的可迭代对象
    【返回】长度为 n 的列表，第 i 个元素是页面 i 所在簇的代表（簇里编号最小的页面）
    """
    parent = list(range(n))

    def find(x):
        """找 x 所在树的根（簇的代表），顺便做路径压缩。"""
        while parent[x] != x:
            parent[x] = parent[parent[x]]    # 路径压缩：让树变矮，下次查找更快
            x = parent[x]
        return x

    for i, j in sorted(pairs):
        ri, rj = find(i), find(j)
        if ri != rj:
            parent[max(ri, rj)] = min(ri, rj)
    return [find(i) for i in range(n)]


def removal_report(pages, rep):
    """
    根据簇的代表，决定删掉哪些页面（每个簇只保留编号最小的那一个），并和真相比较。

    【参数】pages：list[Page]；rep：clusters_from_pairs 的结果（或任何"页面 → 簇代表"的列表）
    【返回】dict：removed（删掉的页面数）、precision（删掉的页面里，真的是某个已保留页面的副本的比例）、
            recall（所有"多余的副本"里被删掉的比例）、wrong（删错的页面编号列表）
    【思路】真相里，同一个 cluster 的页面互为副本；一个 cluster 有 m 个页面，就有 m − 1 个"多余的副本"。
            一个被删掉的页面算"删对了"，条件是：它所在的去重簇里，被保留的那个页面和它属于同一个真相 cluster。
    """
    removed = [i for i in range(len(pages)) if rep[i] != i]
    correct = [i for i in removed if pages[rep[i]].cluster == pages[i].cluster]
    sizes = {}
    for p in pages:
        sizes[p.cluster] = sizes.get(p.cluster, 0) + 1
    redundant = sum(m - 1 for m in sizes.values())
    return dict(removed=len(removed), precision=len(correct) / max(1, len(removed)),
                recall=len(correct) / max(1, redundant), wrong=[i for i in removed if i not in set(correct)])


# =============================================================================
# 演示 1：精确去重
# =============================================================================
def demo_exact(pages):
    """
    演示 1：整页 md5 精确去重，按副本类型（镜像 / 转载）统计找到了多少。

    【参数】pages：list[Page]
    【返回】rep：每个页面所在簇的代表（第一次出现的那个页面的编号），给演示 4 比较用
    """
    print_header("演示 1：精确去重 —— 页面的哈希相同，就是重复")
    seen = {}
    rep = []
    for p in pages:
        h = hashlib.md5(" ".join(p.text.split()).encode()).hexdigest()   # 先把连续空白合并成一个空格
        # 【Python 小知识】d.setdefault(k, v)：k 不在字典里时存入 v 并返回 v；已经在里面时返回原来的值。
        # 所以每个哈希只记住第一次出现的页面，后面的重复页面都指向它。
        rep.append(seen.setdefault(h, p.pid))
    r = removal_report(pages, rep)
    kinds = {"mirror": 0, "repost": 0}
    found = {"mirror": 0, "repost": 0}
    for p in pages:
        if p.copy_kind in kinds:
            kinds[p.copy_kind] += 1
            # 这个副本和它的原始页面是否被判为同一个簇
            orig = next(q for q in pages if q.cluster == p.cluster and q.copy_kind == "original")
            found[p.copy_kind] += rep[p.pid] == rep[orig.pid]     # 副本和它的原始页面被分进了同一个簇
    print(f"{len(pages)} 个页面，md5 哈希。删掉 {r['removed']} 个页面；精确率 {r['precision']:.0%}，召回率 {r['recall']:.0%}。")
    print(f"  镜像（整页原样复制）：{kinds['mirror']} 个，找到 {found['mirror']} 个")
    print(f"  转载（换了页眉页脚、可能改了一个字母或少一行）：{kinds['repost']} 个，找到 {found['repost']} 个")
    p = next(p for p in pages if p.copy_kind == "repost")
    orig = next(q for q in pages if q.cluster == p.cluster and q.copy_kind == "original")
    print("\n一对没被找到的副本：")
    print(f"  原始 [{orig.domain}]  " + orig.text.replace("\n", " ⏎ "))
    print(f"  转载 [{p.domain}]  " + p.text.replace("\n", " ⏎ "))
    print("""
观察 Observations：
  · 精确去重又快又准：镜像全部找到，删掉的页面百分之百是真的副本（精确率 100%）。
    它只需要给每个页面算一个哈希、放进字典——几十亿个文档也只是一次线性扫描。
  · 但它对"差一点"的副本无能为力：转载换了页眉页脚，哈希就完全不同，159 个转载只找到 8 个
    （这 8 个恰好转回了原来的站点、又没有改动，和原页一字不差），召回率因此只有 54%。
    网页上的大部分重复正是这种"换了外壳的同一段内容"。
  · 真实流程里，精确去重也常常按"行"或"段落"做（CCNet 按段落哈希；C4 把重复出现的三句话片段只保留一次），
    这样能删掉跨页面的样板，但也会把不同文章里"恰好相同"的句子一起删掉。""")
    return rep


# =============================================================================
# 演示 2：Jaccard 相似度
# =============================================================================
def demo_jaccard(pages):
    """
    演示 2：五类页面对的 Jaccard 相似度（整页 vs 只看正文）。

    【思路】真副本对直接从真相里取；"不同页面"对随机抽 300 对（跳过同一个 cluster 的）。
    【参数】pages：list[Page]
    【返回】无（只打印）
    """
    print_header("演示 2：Jaccard 相似度 —— 近似重复怎样量化？先去掉页眉页脚会怎样？")
    rng = random.Random(0)
    by_cluster = {}
    for p in pages:
        by_cluster.setdefault(p.cluster, []).append(p)
    pair_sets = {"镜像对（同一内容，整页复制）": [], "转载对（同一内容，换了站点）": [],
                 "同站点的不同页面": [], "不同站点的不同页面": [], "两个垃圾广告页面": []}
    for ps in by_cluster.values():
        orig = next(q for q in ps if q.copy_kind == "original")
        for q in ps:
            if q.copy_kind == "mirror":
                pair_sets["镜像对（同一内容，整页复制）"].append((orig, q))
            elif q.copy_kind == "repost":
                pair_sets["转载对（同一内容，换了站点）"].append((orig, q))
    content = [p for p in pages if not p.junk and p.domain != "mirror.example"]
    while len(pair_sets["同站点的不同页面"]) < 300 or len(pair_sets["不同站点的不同页面"]) < 300:
        p, q = rng.sample(content, 2)
        if p.cluster == q.cluster:
            continue
        key = "同站点的不同页面" if p.domain == q.domain else "不同站点的不同页面"
        if len(pair_sets[key]) < 300:
            pair_sets[key].append((p, q))
    spam = [p for p in pages if p.group == "垃圾·广告"]
    pair_sets["两个垃圾广告页面"] = [tuple(rng.sample(spam, 2)) for _ in range(300)]
    print(f"shingle = 相邻 {SHINGLE_N} 个词。每类页面对的 Jaccard 相似度（平均 / 最小 / 最大）：\n")
    print(pad("页面对", 34) + pad("对数", 6, ">") + pad("整页", 22, ">") + pad("只看正文（去掉页眉页脚）", 30, ">"))
    for name, prs in pair_sets.items():
        full = [jaccard(shingles(p.text), shingles(q.text)) for p, q in prs]
        body = [jaccard(shingles("\n".join(p.content)), shingles("\n".join(q.content))) for p, q in prs]
        fmt = lambda v: f"{sum(v) / len(v):.2f} / {min(v):.2f} / {max(v):.2f}"
        print(pad(name, 34) + pad(len(prs), 6, ">") + pad(fmt(full), 22, ">") + pad(fmt(body), 30, ">"))
    print("""
观察 Observations：
  · 镜像对的 Jaccard 都是 1。转载对看整页时平均只有 0.43——页眉页脚换了，而它们占了短页面的一大块；
    只看正文时平均 0.86（改一个字母只影响几个 shingle，少一行影响得多一些，最低 0.45）。
  · 同站点的不同页面看整页时平均有 0.28、最高 0.67——这全是共享的页眉页脚贡献的；只看正文就降到 0.10。
    所以**先做正文提取、再算相似度**很重要：否则"同一个站点的两篇不同文章"可能比"同一篇文章的两个副本"还像。
    RefinedWeb、FineWeb 等流程都把正文提取放在去重之前。
  · 两个垃圾广告页面的相似度几乎是 0：它们用的是同一小撮关键词，但顺序是随机的。shingle 比较的是
    **词的序列**，不是词的集合——关键词堆砌不会被近似去重当成重复，要靠质量过滤（代码 01）去掉。""")


# =============================================================================
# 演示 3：MinHash 与 LSH
# =============================================================================
def demo_minhash(pages):
    """
    演示 3：(a) MinHash 的估计误差随哈希个数的变化；(b) LSH 分带的候选率 vs 理论 S 形曲线。

    【参数】pages：list[Page]
    【返回】(sigs, cands)：正文的 MinHash 签名矩阵和 LSH 候选对，给演示 4 复用
    """
    print_header("演示 3：MinHash 估计 Jaccard，LSH 只比较\"可能相似\"的页面对")
    rng = random.Random(1)
    bodies = [shingles("\n".join(p.content)) for p in pages]
    # (a) 估计误差 vs 哈希个数：随机挑 300 对页面（一半是真副本，一半是随机对）
    by_cluster = {}
    for p in pages:
        by_cluster.setdefault(p.cluster, []).append(p.pid)
    dup_pairs = [tuple(v[:2]) for v in by_cluster.values() if len(v) >= 2]
    pairs = rng.sample(dup_pairs, 150) + [tuple(rng.sample(range(len(pages)), 2)) for _ in range(150)]
    true_j = np.array([jaccard(bodies[i], bodies[j]) for i, j in pairs])
    print("(a) MinHash 估计值和真实 Jaccard 的平均绝对误差（300 个页面对）：")
    print(pad("哈希个数 k", 12) + pad("平均绝对误差", 14, ">") + pad("理论量级 ≈ 平均 sqrt(J(1−J)/k)·0.8", 38, ">"))
    for k in (16, 64, 256):
        a, b = make_hash_params(k, seed=7)
        est = []
        for i, j in pairs:
            si, sj = minhash_signature(bodies[i], a, b), minhash_signature(bodies[j], a, b)
            est.append(float((si == sj).mean()))
        err = np.abs(np.array(est) - true_j).mean()
        # 二项分布的标准差 sqrt(J(1−J)/k)；平均绝对偏差约为标准差的 0.8 倍（正态分布的 sqrt(2/π)）
        theory = np.mean(np.sqrt(true_j * (1 - true_j) / k)) * math.sqrt(2 / math.pi)
        print(pad(k, 12) + pad(f"{err:.3f}", 14, ">") + pad(f"{theory:.3f}", 38, ">"))

    # (b) LSH 的 S 形曲线：理论 vs 实际
    bands, rows = 16, 8
    a, b = make_hash_params(bands * rows, seed=11)
    sigs = np.stack([minhash_signature(s, a, b) for s in bodies])
    cands = lsh_candidates(sigs, bands, rows)
    print(f"\n(b) LSH：签名长 {bands * rows}，切成 {bands} 段 × 每段 {rows} 个。候选对 {len(cands)} 个"
          f"（所有页面对一共 {len(pages) * (len(pages) - 1) // 2:,} 个）。")
    print("    理论：Jaccard 为 s 的页面对成为候选的概率 = 1 − (1 − s^r)^b；阈值大约在 (1/b)^(1/r) = "
          f"{(1 / bands) ** (1 / rows):.2f}。")
    # 实际：随机抽 20000 个页面对 + 全部真副本对，按真实 Jaccard 分箱，看成为候选的比例
    sample = {tuple(sorted(rng.sample(range(len(pages)), 2))) for _ in range(20000)}
    for v in by_cluster.values():
        for x in range(len(v)):
            for y in range(x + 1, len(v)):
                sample.add(tuple(sorted((v[x], v[y]))))
    bins = [(0.0, 0.2), (0.2, 0.4), (0.4, 0.6), (0.6, 0.7), (0.7, 0.8), (0.8, 0.9), (0.9, 1.01)]
    stats = {bn: [0, 0] for bn in bins}
    for i, j in sorted(sample):
        s = jaccard(bodies[i], bodies[j])
        for lo, hi in bins:
            if lo <= s < hi:
                stats[(lo, hi)][0] += 1
                stats[(lo, hi)][1] += (i, j) in cands
    print(pad("真实 Jaccard", 14) + pad("页面对数", 10, ">") + pad("实际成为候选", 14, ">") + pad("理论", 10, ">"))
    for lo, hi in bins:
        n, c = stats[(lo, hi)]
        mid = (lo + min(hi, 1.0)) / 2
        theory = 1 - (1 - mid ** rows) ** bands
        print(pad(f"[{lo:.1f}, {min(hi, 1.0):.1f}{']' if hi > 1 else ')'}", 14) + pad(n, 10, ">")
              + pad(f"{c / n:.2f}" if n else "-", 14, ">") + pad(f"{theory:.2f}", 10, ">"))
    print("""
观察 Observations：
  · MinHash 的误差按 1/sqrt(k) 缩小：哈希个数翻 4 倍，误差大约减半，和理论量级吻合。
    （一半的页面对是真副本，Jaccard 恰好是 1，估计没有误差，所以平均误差看起来很小。）
    Lee et al. (2021) 的 NearDup 用了 9000 个哈希；我们的页面少、要求低，128 个就够了。
  · LSH 把"比较 236 万个页面对"变成了"只检查 1070 个候选对"。成为候选的概率随真实 Jaccard 呈 S 形上升：
    低于 0.4 的页面对从不成为候选，高于 0.8 的总是候选；阈值的位置由 b、r 决定，这里大约 0.71。
    实际比例和理论曲线吻合（理论值按每个箱子的中点算，只是大致对应）。
  · 候选对只是"可能相似"。下一步对候选对算真实的相似度来确认（Lee et al. 用编辑相似度 > 0.8 确认），
    再用并查集把相似的页面连成簇，每簇只留一个——演示 4。""")
    return sigs, cands


# =============================================================================
# 演示 4：近似去重的效果，以及重复子串
# =============================================================================
def demo_dedup_and_substrings(pages, exact_rep, cands):
    """
    演示 4：三种去重方法的精确率 / 召回率；用截断后缀数组找长度 ≥ 50 的重复子串，并按字符类别拆开统计。

    【参数】pages：list[Page]；exact_rep：演示 1 的结果；cands：演示 3 的 LSH 候选对（只看正文）
    【返回】无（只打印）
    【边界】截断后缀数组只按前 L 个字符排序，所以只能找出长度 ≥ L 的重复；要找任意长度的最长重复需要完整的后缀数组。
    """
    print_header("演示 4：MinHash + LSH 去重的效果；用（截断的）后缀数组找重复出现的长子串")
    bodies = [shingles("\n".join(p.content)) for p in pages]
    fulls = [shingles(p.text) for p in pages]
    print(pad("方法", 44) + pad("删掉页面", 10, ">") + pad("精确率", 8, ">") + pad("召回率", 8, ">") + "   删错的页面（按类型）")
    rows = [("精确去重（整页 md5）", exact_rep)]
    for name, sets in (("MinHash+LSH，整页，Jaccard ≥ 0.8", fulls), ("MinHash+LSH，只看正文，Jaccard ≥ 0.8", bodies)):
        if sets is fulls:
            bands, r = 16, 8
            a, b = make_hash_params(bands * r, seed=11)
            sigs = np.stack([minhash_signature(s, a, b) for s in sets])
            cc = lsh_candidates(sigs, bands, r)
        else:
            cc = cands
        confirmed = [(i, j) for i, j in sorted(cc) if jaccard(sets[i], sets[j]) >= 0.8]
        rows.append((name, clusters_from_pairs(len(pages), confirmed)))
    for name, rep in rows:
        r = removal_report(pages, rep)
        wrong = {}
        for i in r["wrong"]:
            wrong[pages[i].group] = wrong.get(pages[i].group, 0) + 1
        w = ", ".join(f"{g} {k}" for g, k in sorted(wrong.items(), key=lambda kv: (-kv[1], kv[0])))
        print(pad(name, 44) + pad(r["removed"], 10, ">") + pad(f"{r['precision']:.0%}", 8, ">")
              + pad(f"{r['recall']:.0%}", 8, ">") + "   " + (w or "无"))

    # 重复子串：Lee et al. 的 ExactSubstr 用后缀数组找出所有出现 ≥ 2 次、长度 ≥ 50 个 token 的子串。
    # 我们按字符做，L = 50 个字符；后缀数组只按每个后缀的前 L 个字符排序（"截断的后缀数组"）——
    # 足够找出长度 ≥ L 的重复，又不用存下每个后缀的全部内容。
    L = 50
    text = "\x00".join(p.text for p in pages)         # \x00 分隔页面，保证重复子串不跨页面
    t0 = time.time()
    sa = sorted(range(len(text) - L + 1), key=lambda i: text[i:i + L])
    covered = bytearray(len(text))                     # covered[i] = 1 表示第 i 个字符落在某个重复子串里
    counts = {}
    for x, y in zip(sa, sa[1:]):                       # 排好序后，相同的前缀一定挨在一起
        if text[x:x + L] == text[y:y + L] and "\x00" not in text[x:x + L]:
            for i in (x, y):
                covered[i:i + L] = b"\x01" * L
            counts[text[x:x + L]] = counts.get(text[x:x + L], 1) + 1
    secs = time.time() - t0
    boiler = set()
    for header, footer in SITES.values():
        boiler.update(header + footer)
    sizes = {}
    for p in pages:
        sizes[p.cluster] = sizes.get(p.cluster, 0) + 1
    # 给每个字符一个类别：0 = 页眉页脚（样板）；1 = 有副本的页面的正文；2 = 没有副本的页面的正文
    category = bytearray(len(text))
    pos = 0
    for p in pages:
        for line in p.text.split("\n"):
            cat = 0 if line in boiler else (1 if sizes[p.cluster] > 1 else 2)
            category[pos:pos + len(line) + 1] = bytes([cat]) * (len(line) + 1)
            pos += len(line) + 1
    n_cov = sum(covered)
    print(f"\n重复子串（长度 ≥ {L} 个字符、出现 ≥ 2 次）：覆盖了 {n_cov / len(text):.1%} 的字符"
          f"（截断后缀数组：{len(sa):,} 个后缀，排序和扫描约 {secs:.1f} 秒）。按字符的类别拆开：")
    names = ["页眉页脚（样板）", "有副本的页面的正文（镜像、转载……）", "没有副本的页面的正文"]
    print(pad("字符类别", 40) + pad("占全部字符", 12, ">") + pad("其中被覆盖", 12, ">"))
    for cat in range(3):
        n = sum(1 for c in category if c == cat)
        k = sum(1 for c, cv in zip(category, covered) if c == cat and cv)
        print(pad(names[cat], 40) + pad(f"{n / len(text):.1%}", 12, ">") + pad(f"{k / max(1, n):.0%}", 12, ">"))
    print("出现次数最多的 6 个 50 字符片段（一个长重复会贡献很多个相互重叠的 50 字符片段，这里每个只列一次）：")
    shown = []
    for sub, c in sorted(counts.items(), key=lambda kv: (-kv[1], kv[0])):
        if any(sub[:20] in s or s[:20] in sub for s, _ in shown):
            continue
        shown.append((sub, c))
        if len(shown) == 6:
            break
    for sub, c in shown:
        print(f"  {c:>4} 次  {sub.replace(chr(10), ' ⏎ ')!r}")
    print("""
观察 Observations：
  · 只看正文的 MinHash + LSH 找回了 84% 的多余副本（转载也找到了），删掉的页面 96% 确实是副本。
    删错的几个是"几乎一样、但不完全一样"的页面：两张导航页只差一行、两个故事页面共用两句话……
    在真相里它们不是同一篇文章，但删掉它们也不太可惜——"近似"本来就意味着要划一条线。
  · 看整页的 MinHash + LSH 精确率和召回率都更差：转载换了页眉页脚，整页 Jaccard 掉到 0.8 以下（漏掉）；
    同一个站点的短故事页面共享页眉页脚，整页 Jaccard 超过 0.8（误删 38 个故事页面）。再一次：先提取正文。
  · 三种方法的取舍：精确去重精确率最高、召回率最低；近似去重用一点精确率换来高得多的召回率。
    Lee et al. (2021) 的 NearDup 在 C4 里删掉了 3.04% 的训练样本，在 RealNews 里删掉了 13.63%。
  · 重复子串：出现次数最多的全是页眉页脚，86% 的样板字符落在重复子串里。Lee et al. 在 C4 里发现一句
    61 个词的句子重复了 61,036 次；ExactSubstr 删掉了 C4 训练集 7.18% 的 token。删掉这些样板，
    模型就不会把"版权声明"背得滚瓜烂熟。
  · 但被覆盖的远不只是样板：没有副本的页面，正文也有 61% 落在重复子串里——我们的世界是模板生成的，
    同一句评论、同一句人物介绍会在不同页面上独立出现。真实网页的多样性高得多（C4 只有 7.18%），
    但"热门句子""引用""法律声明"也会重复。按子串删除会把一部分合法内容一起删掉——
    去重的粒度（文档 / 段落 / 子串）本身是一个需要权衡的设计选择。""")


if __name__ == "__main__":
    pages = build_crawl()
    exact_rep = demo_exact(pages)
    demo_jaccard(pages)
    _, cands = demo_minhash(pages)
    demo_dedup_and_substrings(pages, exact_rep, cands)
