"""
=============================================================================
第 15 讲 · 共享模块：一个"知道真相"的 token 世界 —— toy aligned model、越狱攻击与护栏
=============================================================================

对应讲义：lectures/lecture15_llm_agent_security.md 第 3、5、6、9 节（代码 01、03、05 导入本模块）

-----------------------------------------------------------------------------
【为什么要一个模拟世界 Why a simulator】
-----------------------------------------------------------------------------
本讲不调用任何真实的 LLM，也**不包含任何真实的越狱 prompt 或有害内容**。所有"有害请求"都是抽象的占位符：
一个 forbidden topic token（例如 t03）。我们关心的是**机制**：

    · 对齐（safety training）只在它见过的输入分布上起作用 —— mismatched generalization（Wei et al. 2023）；
    · 一个能查询 / 能求梯度的攻击者，可以在离散 token 上做优化（GCG，Zou et al. 2023）—— 第 7 讲 PGD 的离散版；
    · 一个只在"已知攻击"上调好的过滤器，会被**知道过滤器存在**的自适应攻击者绕过 —— 第 8 讲的教训；
    · 读"意思"（representation）而不是读"字面"（surface form）的防御，为什么泛化得更好、代价是什么。

因为世界是我们自己造的，每个 prompt 的"真实意图"（topic 是否 forbidden）都确切已知，
所以攻击成功率（ASR）、过度拒绝率（over-refusal）都能精确地算出来。
**数字只说明机制，不代表任何真实模型的表现。**

-----------------------------------------------------------------------------
【这个世界长什么样 The world, line by line】
-----------------------------------------------------------------------------
词表 vocabulary（make_world 构造）：
    ASK                       请求的开头（"请告诉我……"）
    w00 … w59                 60 个普通词（filler）。自然文本由 Markov chain 生成（两种"方言" dialect 0 / 1）
    t00 … t23                 24 个话题 topic：t00–t07 = FORBIDDEN（占位符：模型应当拒绝的话题）
                                                t08–t11 = EDGY（无害，但"听起来"像 forbidden —— 用来测过度拒绝）
                                                t12–t23 = BENIGN
    t00# … t23#               话题的"编码形式 A"（类比 base64：模型**看得懂**，但安全训练没见过）
    t00@ … t23@               话题的"编码形式 B"（类比低资源语言 low-resource language）
    j000 … j399               400 个"怪 token"（junk）：自然文本里从不出现，嵌入范数更大
                              （类比真实词表里大量罕见的 token；GCG 找到的后缀里常见这类 token）

一个 prompt = [ASK, 话题 token, 若干普通词]，攻击者可以在末尾追加一个 suffix（后缀）。
**话题 token 决定这个请求"真正在问什么"**（模型的能力 capability 会把编码形式解码成同一个话题），
所以"有害"与否由话题决定，和后缀无关 —— 后缀只能改变模型**是否拒绝**。

toy aligned model（ToyAlignedModel）：
    特征   x = [ e(话题) ;  s_ctx · mean_{上下文里出现过的不同 token} e(token) ]      （上下文 = 除话题外的所有位置）
    隐层   h = tanh(A x + c)                    —— A、c 随机固定，代表"预训练"得到的特征
    拒绝   r = u·h + b ；  r > 0 ⇒ 拒绝（refuse）， r ≤ 0 ⇒ 照做（comply）
    安全训练 = 只训练 (u, b)：在**自然**的 prompt 上做逻辑回归，标签 = 话题是否 forbidden（类比 RLHF / safety fine-tuning）。
上下文用"出现过哪些不同的 token"（bag of distinct tokens）来概括：把一个词重复 10 遍和出现 1 遍效果一样。
这让"随便重复一个怪词"不起作用 —— 攻击者必须找到**很多个不同的、都朝同一个方向推的 token**（讲义 §5.1）。

-----------------------------------------------------------------------------
【注释标签】
-----------------------------------------------------------------------------
    【思路】为什么这样写   【参数】【返回】输入输出   【边界】什么时候会出问题
    【可信联系】和"可信"的哪个方面有关、和其他讲的联系
    【Python 小知识】第一次出现的写法

只用 Python 标准库（random、math）。所有浮点求和都用显式循环或 math.fsum，集合先排序再求和：
Python 3.12+ 的内置 sum() 对浮点数用了补偿求和，和 3.9 的结果在最后一位可能不同，会让攻击轨迹分叉。
"""

import math
import random

# =============================================================================
# 0. 小工具 Small helpers
# =============================================================================
D = 8                     # 嵌入维度 embedding dimension
N_FILLER, N_TOPIC, N_JUNK = 60, 24, 400
FORBIDDEN = list(range(0, 8))      # 话题编号 0–7：forbidden（占位符）
EDGY = list(range(8, 12))          # 8–11：无害但"听起来危险"
BENIGN = list(range(12, 24))       # 12–23：普通无害话题


def print_header(title):
    """打印一个醒目的分隔标题。"""
    print("\n" + "=" * 100)
    print(title)
    print("=" * 100)


def dot(a, b):
    """
    两个等长列表的内积。

    【思路】用显式 for 循环累加，而不是 sum(x * y for ...)：Python 3.12 起 sum() 对浮点数做补偿求和，
           和 3.9 的结果可能差最后一位；显式 += 在所有版本上逐位相同（本讲要求 3.9 / 3.13 输出完全一致）。
    【Python 小知识】zip(a, b) 把两个列表"拉链式"配对：(a[0], b[0]), (a[1], b[1]), ...
    """
    s = 0.0
    for x, y in zip(a, b):
        s += x * y
    return s


def sigmoid(z):
    """数值稳定的 σ(z) = 1/(1+e^{−z})：z 很负时先算 e^{z}，避免 e^{−z} 上溢。"""
    if z >= 0:
        return 1.0 / (1.0 + math.exp(-z))
    ez = math.exp(z)
    return ez / (1.0 + ez)


def softplus(z):
    """
    数值稳定的 softplus(z) = log(1 + e^z) = −log σ(−z)。

    【思路】攻击者的损失：−log P(comply) = −log σ(−r) = softplus(r)。r 越小（越不拒绝），损失越小。
           GCG 原文的损失是 −log p("Sure, here is …")，这里"照做"这个事件就扮演了"肯定的开头"的角色。
    【边界】z 很大时 log(1+e^z) ≈ z，写成 z + log1p(e^{−z})，不会上溢。
    """
    if z > 0:
        return z + math.log1p(math.exp(-z))
    return math.log1p(math.exp(z))


def mean(xs):
    """浮点数列表的均值（用 math.fsum：精确求和，跨 Python 版本结果一致）。空列表返回 nan。"""
    return math.fsum(xs) / len(xs) if xs else float("nan")


def quantile(xs, q):
    """
    经验分位数（线性插值，和 numpy 默认的 "linear" 方法一致）。

    【参数】xs：数字列表；q ∈ [0, 1]
    【边界】xs 为空时返回 nan。
    """
    if not xs:
        return float("nan")
    s = sorted(xs)
    pos = q * (len(s) - 1)
    lo = int(math.floor(pos))
    hi = min(lo + 1, len(s) - 1)
    return s[lo] + (s[hi] - s[lo]) * (pos - lo)


def wilson_interval(k, n, z=1.96):
    """
    二项比例 k/n 的 Wilson 95% 置信区间。

    【思路】ASR（攻击成功率）是一个比例。小样本、尤其是 k = 0 或 k = n 时，常见的"p ± 1.96·√(p(1−p)/n)"
           会给出宽度为 0 的荒谬区间（"0/50 次成功 ⇒ ASR = 0 ± 0"）。Wilson 区间在边界处仍然合理：
           0/50 的 95% 上界约 0.071 —— 这就是"没有观察到成功 ≠ 不会成功"。
    【返回】(lo, hi)
    【边界】n = 0 时返回 (0, 1)：没有数据，什么都不知道。
    【可信联系】第 1 讲 §8.1（指标有误差）；第 8 讲（一次攻击失败不证明鲁棒）。
    """
    if n == 0:
        return 0.0, 1.0
    p = k / n
    denom = 1 + z * z / n
    centre = (p + z * z / (2 * n)) / denom
    half = z * math.sqrt(p * (1 - p) / n + z * z / (4 * n * n)) / denom
    return max(0.0, centre - half), min(1.0, centre + half)


def spearman(xs, ys):
    """
    Spearman 秩相关（不处理并列：本讲用到的都是连续值）。

    【思路】先把两列数各自换成名次（rank），再算名次的 Pearson 相关。只关心"排序是否一致"。
           用来检查 GCG 的一阶近似把候选**排对了**多少（讲义 §5.2）。
    """
    def ranks(v):
        order = sorted(range(len(v)), key=lambda k: (v[k], k))
        r = [0.0] * len(v)
        for pos, k in enumerate(order):
            r[k] = float(pos)
        return r
    rx, ry = ranks(xs), ranks(ys)
    mx, my = mean(rx), mean(ry)
    num = math.fsum((a - mx) * (b - my) for a, b in zip(rx, ry))
    den = math.sqrt(math.fsum((a - mx) ** 2 for a in rx) * math.fsum((b - my) ** 2 for b in ry))
    return num / den


def solve_linear(M, v):
    """
    解线性方程组 M x = v（高斯消元 + 部分主元）。M 是 n×n 的列表的列表。

    【思路】Newton 法每一步要解 H·Δ = g；参数只有 33 个，手写高斯消元就够了。
    【边界】M 奇异时会除以 0；调用者（fit_head）给 Hessian 加了 λI，保证正定。
    """
    n = len(v)
    A = [row[:] + [v[i]] for i, row in enumerate(M)]       # 增广矩阵 [M | v]
    for col in range(n):
        piv = max(range(col, n), key=lambda r: abs(A[r][col]))
        A[col], A[piv] = A[piv], A[col]
        for r in range(col + 1, n):
            f = A[r][col] / A[col][col]
            if f != 0.0:
                for c in range(col, n + 1):
                    A[r][c] -= f * A[col][c]
    x = [0.0] * n
    for r in range(n - 1, -1, -1):
        s = A[r][n]
        for c in range(r + 1, n):
            s -= A[r][c] * x[c]
        x[r] = s / A[r][r]
    return x


def _rand_vec(rng, scale):
    """D 维高斯随机向量，每一维 ~ N(0, scale²/D)，所以范数约为 scale。"""
    return [rng.gauss(0.0, scale / math.sqrt(D)) for _ in range(D)]


def _unit(v):
    n = math.sqrt(dot(v, v))
    return [x / n for x in v]


def _distinct_mean(vectors, ids, scale):
    """
    scale × "出现过的不同 token" 的向量均值（bag of distinct tokens）。

    【思路】sorted(set(ids))：先去重、再排序 —— 排序保证求和顺序固定（集合的遍历顺序在不同运行之间可能不同）。
    【Python 小知识】set(...) 去重；sorted(...) 返回排好序的新列表。
    """
    uniq = sorted(set(ids))
    out = [0.0] * D
    for t in uniq:
        v = vectors[t]
        for j in range(D):
            out[j] += v[j]
    s = scale / len(uniq)
    return [x * s for x in out], uniq


# =============================================================================
# 1. 世界：词表、嵌入、自然文本 The token world
# =============================================================================
class TokenWorld:
    """
    词表 + "预训练"嵌入 + 自然文本的 Markov chain。由 make_world() 构造，所有字段只读。

    【字段】
        vocab        list[str]，token 的名字；idx：名字 → 编号
        emb          list[list[float]]，表层嵌入（surface embedding，模型的输入）
        sem          list[list[float]]，语义向量（"意思"）：话题的明文 / 编码形式共享同一个语义向量
        topic_of     dict：token 编号 → 话题编号（只对话题 token 有定义）
        fillers, junk, suffix_vocab   普通词、怪 token、攻击者可用的后缀词表（= 普通词 + 怪 token）
        chains       两种方言的 Markov chain：chains[d][i] = [(下一个 filler 编号, 概率), ...]
    """


def make_world(seed=15, rho_a=0.35, rho_b=0.55, junk_scale=1.8):
    """
    构造 token 世界。

    【思路】
        · 普通词：随机嵌入，范数约 1。
        · 话题：随机嵌入 + level × v_harm，其中 v_harm 是一个随机的单位"有害方向"。
              forbidden 的 level = 1.5、edgy = 0.75、benign = 0。安全训练要从数据里自己找到这个方向。
        · 编码形式：e(t#) = ρ·e(t) + √(1−ρ²)·噪声。ρ 表示"预训练模型在多大程度上知道编码后的 token 和原 token 是一回事"。
              ρ_a = 0.35（类比 base64：模型会解码，但表层表示差别大）、ρ_b = 0.55（类比低资源语言）。
              **能力**（解码）是 100% 的：topic_of 把三种形式映射到同一个话题。
        · 怪 token：范数 1.8 的随机嵌入，自然文本里从不出现。范数大 ⇒ 对特征的"杠杆"更大 ⇒ 优化攻击会偏爱它们。
        · 语义向量 sem：话题 = 随机 + level × v_sem（编码形式共享明文的语义）；其他 token 随机。
        · 两条 Markov chain（方言 0 / 1）：每个普通词有 5 个常见后继（权重随机），另有 10% 概率跳到任意普通词。
    【参数】seed：随机种子；rho_a / rho_b：两种编码与明文嵌入的相关程度；junk_scale：怪 token 的嵌入范数
    【返回】TokenWorld 实例
    【可信联系】"能力覆盖的范围 > 安全训练覆盖的范围"正是 Wei et al.（2023）说的 mismatched generalization（讲义 §3.3）。
    """
    rng = random.Random(seed)
    w = TokenWorld()
    vocab = ["ASK"]
    vocab += [f"w{i:02d}" for i in range(N_FILLER)]
    vocab += [f"t{i:02d}" for i in range(N_TOPIC)]
    vocab += [f"t{i:02d}#" for i in range(N_TOPIC)]
    vocab += [f"t{i:02d}@" for i in range(N_TOPIC)]
    vocab += [f"j{i:03d}" for i in range(N_JUNK)]
    w.vocab = vocab
    w.idx = {name: k for k, name in enumerate(vocab)}           # 【Python 小知识】dict comprehension：{键: 值 for ...}
    w.ask = w.idx["ASK"]
    w.fillers = [w.idx[f"w{i:02d}"] for i in range(N_FILLER)]
    w.junk = [w.idx[f"j{i:03d}"] for i in range(N_JUNK)]
    w.suffix_vocab = w.fillers + w.junk
    w.topic_tok = {"plain": [w.idx[f"t{i:02d}"] for i in range(N_TOPIC)],
                   "enc_a": [w.idx[f"t{i:02d}#"] for i in range(N_TOPIC)],
                   "enc_b": [w.idx[f"t{i:02d}@"] for i in range(N_TOPIC)]}
    w.topic_of = {}
    for form in ("plain", "enc_a", "enc_b"):
        for t, tok in enumerate(w.topic_tok[form]):
            w.topic_of[tok] = t

    v_harm = _unit(_rand_vec(rng, 1.0))
    v_sem = _unit(_rand_vec(rng, 1.0))
    level = [1.5 if t in FORBIDDEN else 0.75 if t in EDGY else 0.0 for t in range(N_TOPIC)]
    sem_level = [1.2 if t in FORBIDDEN else 0.7 if t in EDGY else 0.0 for t in range(N_TOPIC)]

    emb = [None] * len(vocab)
    sem = [None] * len(vocab)
    emb[w.ask] = _rand_vec(rng, 1.0)
    sem[w.ask] = _rand_vec(rng, 1.0)
    for tok in w.fillers:
        emb[tok] = _rand_vec(rng, 1.0)
        sem[tok] = _rand_vec(rng, 1.0)
    for t in range(N_TOPIC):
        base = _rand_vec(rng, 1.0)
        e_plain = [b + level[t] * h for b, h in zip(base, v_harm)]
        s_vec = [b + sem_level[t] * h for b, h in zip(_rand_vec(rng, 1.0), v_sem)]
        emb[w.topic_tok["plain"][t]] = e_plain
        for form, rho in (("enc_a", rho_a), ("enc_b", rho_b)):
            noise = _rand_vec(rng, 1.0)
            emb[w.topic_tok[form][t]] = [rho * a + math.sqrt(1 - rho * rho) * b for a, b in zip(e_plain, noise)]
        for form in ("plain", "enc_a", "enc_b"):
            sem[w.topic_tok[form][t]] = s_vec          # 能力：三种形式"意思"相同
    for tok in w.junk:
        emb[tok] = _rand_vec(rng, junk_scale)
        sem[tok] = _rand_vec(rng, junk_scale)
    w.emb, w.sem = emb, sem
    w.v_harm, w.v_sem = v_harm, v_sem

    # 两种方言的 Markov chain（只在普通词之间转移）
    w.chains = []
    for _dialect in range(2):
        chain = {}
        for tok in w.fillers:
            succ = rng.sample(w.fillers, 5)                      # 【Python 小知识】rng.sample：不放回地抽 5 个
            wts = [math.exp(rng.gauss(0.0, 0.7)) for _ in succ]
            tot = math.fsum(wts)
            chain[tok] = [(s, 0.97 * x / tot) for s, x in zip(succ, wts)]   # 97% 的概率走常见后继
        w.chains.append(chain)
    return w


def _next_filler(world, prev, rng, dialect):
    """按方言 dialect 的 Markov chain 抽下一个普通词：97% 走常见后继，3% 均匀跳到任意普通词。"""
    u = rng.random()
    acc = 0.0
    for tok, p in world.chains[dialect][prev]:
        acc += p
        if u < acc:
            return tok
    return rng.choice(world.fillers)


def natural_text(world, rng, k, dialect=0):
    """按方言 dialect 的 Markov chain 生成 k 个普通词（"自然语言"的一段话）。"""
    prev = rng.choice(world.fillers)
    out = [prev]
    for _ in range(k - 1):
        prev = _next_filler(world, prev, rng, dialect)
        out.append(prev)
    return out


def sample_prompt(world, rng, kind, form="plain", dialect=0, topics=None):
    """
    抽一个自然的请求 prompt：[ASK, 话题 token, k 个普通词]，k ∈ [3, 7]。

    【参数】kind："harmful" / "edgy" / "benign"（决定话题从哪一组里抽）；form：话题的表层形式；
           dialect：普通词用哪种方言；topics：如果给出，就只从这个话题列表里抽（用来划分训练 / 测试话题）
    【返回】token 编号的列表
    """
    pool = topics if topics is not None else (FORBIDDEN if kind == "harmful" else EDGY if kind == "edgy" else BENIGN)
    t = rng.choice(pool)
    return [world.ask, world.topic_tok[form][t]] + natural_text(world, rng, rng.randint(3, 7), dialect)
    # 【Python 小知识】randint(a, b) 含两端；列表 + 列表 = 拼接


def is_harmful(world, ids):
    """这个 prompt 的**真实意图**是否 forbidden（由 ids[1] 的话题决定，和形式、后缀无关）。"""
    return world.topic_of[ids[1]] in FORBIDDEN


def natural_dataset(world, rng, n, mix=(0.35, 0.15, 0.5), forms=("plain",)):
    """
    抽 n 个自然 prompt，用于安全训练或验证。

    【参数】mix：(harmful, edgy, benign) 的比例；forms：话题以哪些表层形式出现（均匀抽）
    【返回】(prompts, labels)，label = 1 表示 forbidden（应当拒绝）
    """
    prompts, labels = [], []
    for _ in range(n):
        u = rng.random()
        kind = "harmful" if u < mix[0] else "edgy" if u < mix[0] + mix[1] else "benign"
        prompts.append(sample_prompt(world, rng, kind, form=rng.choice(forms)))
        labels.append(1 if kind == "harmful" else 0)
    return prompts, labels


# =============================================================================
# 2. 自然语言的 bigram LM：困惑度过滤器的基础 Perplexity
# =============================================================================
class BigramLM:
    """
    在自然 prompt 语料上估计的 bigram 语言模型（add-α 平滑）。

    【思路】perplexity filter（Alon & Kamfonas 2023；Jain et al. 2023）的想法：GCG 找到的后缀是一串奇怪的 token，
           在一个普通语言模型眼里"很不像人话"，困惑度（perplexity）很高。本讲用 mean NLL（每个 token 的平均负对数似然，
           perplexity = e^{mean NLL}）作为过滤分数。
    【参数】world；corpus：自然 prompt 列表；alpha：平滑系数（没见过的 bigram 也给一点点概率）
    """

    def __init__(self, world, corpus, alpha=0.05):
        V = len(world.vocab)
        counts = [[0] * V for _ in range(V + 1)]          # 最后一行是句首 BOS
        for ids in corpus:
            prev = V
            for t in ids:
                counts[prev][t] += 1
                prev = t
        self.logp = []
        for row in counts:
            row_total = 0
            for c in row:
                row_total += c
            tot = row_total + alpha * V
            self.logp.append([math.log((c + alpha) / tot) for c in row])
        self.bos = V

    def nll_terms(self, ids):
        """每个位置的 −log p(token | 前一个 token)。"""
        out, prev = [], self.bos
        for t in ids:
            out.append(-self.logp[prev][t])
            prev = t
        return out

    def mean_nll(self, ids):
        """整个 prompt 的平均 NLL（过滤器的分数；越大越"不像人话"）。"""
        return mean(self.nll_terms(ids))


# =============================================================================
# 3. toy aligned model
# =============================================================================
class ToyAlignedModel:
    """
    一个"对齐过的"玩具模型：预训练特征（随机固定）+ 安全训练得到的拒绝头（refusal head）。

    【思路】见文件开头的公式。设计上的几个关键点：
        · 话题 token 单独占一块特征（模型"知道请求的内容在哪儿"），所以随便加一些普通词不会稀释它。
        · 上下文块是"出现过的不同 token"的嵌入均值。随机后缀里各个 token 的方向互相抵消；
          但如果后缀里**每个不同的 token**都朝同一个方向推，位移会累积 —— 和第 1 讲 §5.3 / 第 7 讲 §3 的
          线性解释一样：协同一致的小扰动加起来很大。
        · 隐层 tanh 让 r 对上下文是非线性的，所以"梯度给出的线性近似"不精确 —— GCG 要用前向传播去验证候选。
    【参数】world：TokenWorld；seed：预训练特征 A、c 的种子；emb：可以传入另一张嵌入表（模拟"不同的预训练"）；
           hidden：隐层宽度；gain：A 的尺度；ctx_scale：上下文块的尺度（越大，后缀的"杠杆"越大）
    """

    def __init__(self, world, seed, emb=None, hidden=32, gain=2.0, ctx_scale=4.0):
        rng = random.Random(seed)
        self.world = world
        self.emb = emb if emb is not None else world.emb
        self.H = hidden
        self.ctx_scale = ctx_scale
        n_in = 2 * D
        self.A = [[rng.gauss(0.0, gain / math.sqrt(n_in)) for _ in range(n_in)] for _ in range(hidden)]
        self.c = [rng.gauss(0.0, 0.3) for _ in range(hidden)]
        self.u = [0.0] * hidden
        self.b = 0.0
        self.n_forward = 0            # 前向次数计数器（攻击的"查询预算"）

    # ---------- 前向 forward ----------
    def features(self, ids):
        """两块特征 [话题; 上下文]，共 2D 维。ids[1] 是话题位置，其余都是上下文。"""
        ctx, _ = _distinct_mean(self.emb, ids[:1] + ids[2:], self.ctx_scale)
        return self.emb[ids[1]][:] + ctx

    def hidden(self, x):
        return [math.tanh(self.c[h] + dot(self.A[h], x)) for h in range(self.H)]

    def refusal_logit(self, ids):
        """r(ids)：> 0 拒绝，≤ 0 照做。每调用一次计一次前向。"""
        self.n_forward += 1
        return self.b + dot(self.u, self.hidden(self.features(ids)))

    def refuses(self, ids, threshold=0.0):
        return self.refusal_logit(ids) > threshold

    # ---------- 安全训练 safety training ----------
    def fit_head(self, prompts, labels, l2=0.001, iters=30):
        """
        只训练拒绝头 (u, b)：L2 正则的逻辑回归，Newton 法 + 回溯线搜索。

        【思路】预训练特征 h 固定，安全训练只学"在这些特征上，哪里该拒绝"。它只见过**自然**的 prompt，
               所以它对"自然分布之外"（编码形式、奇怪的后缀）的行为没有被直接约束 —— 这是越狱的根源之一。
               目标：J = (1/n) Σ softplus(−s_i·r_i) + (λ/2)‖u‖²，s_i = ±1。
        【参数】prompts / labels：自然 prompt 与 0/1 标签；l2：λ；iters：Newton 最多迭代次数
        【返回】self（可以链式调用）
        【边界】数据线性可分时没有正则化的解会发散；λ > 0 保证有限的最优点。
        """
        Hs = [self.hidden(self.features(ids)) for ids in prompts]
        y = [1.0 if l else -1.0 for l in labels]
        n = len(prompts)
        theta = self.u + [self.b]                       # 最后一维是截距

        def objective(th):
            tot = [softplus(-s * (th[-1] + dot(th[:-1], h))) for h, s in zip(Hs, y)]
            return math.fsum(tot) / n + 0.5 * l2 * dot(th[:-1], th[:-1])

        cur = objective(theta)
        P = self.H + 1
        for _ in range(iters):
            g = [0.0] * P
            Hm = [[0.0] * P for _ in range(P)]
            for h, s in zip(Hs, y):
                phi = h + [1.0]
                p = sigmoid(dot(theta, phi))             # P(label = 1)
                resid = p - (1.0 if s > 0 else 0.0)
                wgt = p * (1 - p) / n
                for a in range(P):
                    g[a] += resid * phi[a] / n
                    fa = wgt * phi[a]
                    row = Hm[a]
                    for c in range(P):
                        row[c] += fa * phi[c]
            for a in range(P - 1):
                g[a] += l2 * theta[a]
                Hm[a][a] += l2
            Hm[P - 1][P - 1] += 1e-9
            step = solve_linear(Hm, g)
            t = 1.0
            while True:                                   # 回溯线搜索：保证目标下降
                cand = [a - t * d for a, d in zip(theta, step)]
                val = objective(cand)
                if val <= cur or t < 1e-6:
                    break
                t *= 0.5
            done = cur - val < 1e-10
            theta, cur = cand, val
            if done:
                break
        self.u, self.b = theta[:-1], theta[-1]
        return self

    # ---------- 梯度：GCG 需要的一阶信息 ----------
    def context_gradient(self, ids):
        """
        拒绝 logit r 对"上下文均值向量"的梯度，以及当前上下文里出现过哪些 token。

        【思路】r = u·tanh(A x + c) + b，∂r/∂x = Aᵀ(u ⊙ (1 − h²))，取其中上下文那一块，再乘 s_ctx / n_distinct：
               如果把一个新 token t 加进上下文集合，r 大约改变 g·e_t；把一个只出现一次的 token 拿掉，r 大约改变 −g·e_cur。
               这就是 GCG 里"对 one-hot 求梯度"的一阶近似（Zifan Wang 的 slides：和 HotFlip / AutoPrompt 是同一个近似）。
        【返回】(r, g, counts)：g 已经乘过 s_ctx / n_distinct；counts：dict，上下文里每个 token 出现的次数
        【边界】一阶近似忽略了 tanh 的弯曲和 n_distinct 的变化，所以只用来**挑候选**，不用来做决定。
        """
        ctx_ids = ids[:1] + ids[2:]
        x = self.features(ids)
        h = self.hidden(x)
        self.n_forward += 1
        r = self.b + dot(self.u, h)
        g = [0.0] * D
        for k in range(self.H):
            dk = self.u[k] * (1 - h[k] * h[k])
            row = self.A[k]
            for j in range(D):
                g[j] += dk * row[D + j]
        counts = {}
        for t in ctx_ids:
            counts[t] = counts.get(t, 0) + 1          # 【Python 小知识】dict.get(键, 默认值)
        s = self.ctx_scale / len(counts)
        return r, [v * s for v in g], counts


def make_aligned_model(world, hidden_seed, data_seed, emb=None, n_train=2400, forms=("plain",), **kw):
    """
    构造并做安全训练的一个模型。

    【参数】hidden_seed：预训练特征的种子（不同 = 不同的"预训练特征"）；data_seed：安全训练数据的种子；
           emb：另一张嵌入表（None = 世界的嵌入）；forms：安全训练数据里话题出现的形式（默认只有明文）；
           **kw：传给 ToyAlignedModel 的其他参数
    【返回】训练好的 ToyAlignedModel
    【Python 小知识】**kw 把"其余的关键字参数"收集成一个 dict，再原样传下去。
    """
    model = ToyAlignedModel(world, hidden_seed, emb=emb, **kw)
    prompts, labels = natural_dataset(world, random.Random(data_seed), n_train, forms=forms)
    model.fit_head(prompts, labels)
    model.n_forward = 0
    return model


def perturbed_embeddings(world, seed, rho):
    """
    "另一次预训练"得到的嵌入：e' = ρ·e + √(1−ρ²)·噪声（逐 token，噪声与 e 同尺度）。ρ 越小，两个模型"看世界的方式"越不同。
    【可信联系】迁移攻击之所以有效，是因为模型从相似的数据里学到了相似的特征（第 7 讲 §9、§11）。
    """
    rng = random.Random(seed)
    out = []
    for e in world.emb:
        noise = _rand_vec(rng, math.sqrt(dot(e, e)))
        out.append([rho * a + math.sqrt(1 - rho * rho) * b for a, b in zip(e, noise)])
    return out


# =============================================================================
# 4. 防御：困惑度过滤、学习型检测器、表示探针（circuit-breaker 风格）
# =============================================================================
class PerplexityFilter:
    """
    输入过滤器：mean NLL > 阈值 ⇒ 拦截。阈值取自然 benign prompt 上 NLL 的 (1 − fpr) 分位数。

    【可信联系】只看"像不像人话"，不看意思。代价：说"少见方言"的正常用户更容易被误拦（讲义 §6.2，和第 5 讲的公平问题相连）。
    """

    def __init__(self, lm, calib_prompts, fpr=0.01):
        self.lm = lm
        self.thr = quantile([lm.mean_nll(p) for p in calib_prompts], 1 - fpr)

    def score(self, ids):
        return self.lm.mean_nll(ids)

    def blocks(self, ids):
        return self.score(ids) > self.thr


class TokenDetector:
    """
    学习型的"对抗 prompt 检测器"：multinomial naive Bayes，正例 = 收集到的已知攻击，负例 = 正常 prompt。

    【思路】score(ids) = 平均每个 token 的 log [p(token | attack) / p(token | benign)]，
           阈值取正常 prompt 的 (1 − fpr) 分位数。它会学到"怪 token ⇒ 攻击"。
           在**同一种攻击**的新样本上它几乎完美；但它学的是**已知攻击的表面特征**。
    【参数】attack_prompts / benign_prompts：训练数据；calib_prompts：定阈值用的正常 prompt；alpha：平滑
    """

    def __init__(self, world, attack_prompts, benign_prompts, calib_prompts, fpr=0.01, alpha=0.5):
        V = len(world.vocab)
        ca, cb = [0] * V, [0] * V
        for ids in attack_prompts:
            for t in ids:
                ca[t] += 1
        for ids in benign_prompts:
            for t in ids:
                cb[t] += 1
        na, nb = 0, 0
        for c in ca:
            na += c
        for c in cb:
            nb += c
        self.logodds = [math.log((ca[t] + alpha) / (na + alpha * V)) - math.log((cb[t] + alpha) / (nb + alpha * V))
                        for t in range(V)]
        self.thr = quantile([self.score(p) for p in calib_prompts], 1 - fpr)

    def score(self, ids):
        s = 0.0
        for t in ids:
            s += self.logodds[t]
        return s / len(ids)

    def blocks(self, ids):
        return self.score(ids) > self.thr


class RepresentationProbe:
    """
    读"意思"的防御：一个线性探针读模型内部的内容表示 z，超过阈值就"熔断"（circuit breaker）。

    【思路】Representation Engineering（Zou et al. 2023）用"有害 vs 无害"两组输入的表示之差找到一个 reading vector；
           Circuit Breakers（Zou et al. 2024）在模型**正在产生有害内容**时把表示改道（representation rerouting）。
           本 toy 把它简化为：
               z = sem(话题) + α · mean_{上下文里不同的 token} sem(token)
               w = mean(z | harmful) − mean(z | non-harmful)      （difference of means，RepE 的读法）
               熔断 ⇔ 模型照做了、且 w·z > θ
           关键假设（讲义 §6.4 会强调这是一个**假设**）：模型要产生有害内容，就必须在内部表示这个内容，
           所以 z 由"意思"决定（编码形式和明文共享 sem），后缀只能通过系数 α 影响它。
           α 小 ⇒ 后缀很难移动 z ⇒ 难以绕过；α 大 ⇒ 表示可塑 ⇒ 自适应攻击可以把 z 推到阈值以下。
    【参数】world；alpha：后缀对内容表示的影响力；train_prompts / labels：用来求 w 的自然数据；
           recall：在自然 harmful prompt 上要求的拦截率（决定 θ）
    """

    def __init__(self, world, alpha, train_prompts, labels, recall=0.98):
        self.world, self.alpha = world, alpha
        zs_h = [self.rep(p) for p, l in zip(train_prompts, labels) if l == 1]
        zs_n = [self.rep(p) for p, l in zip(train_prompts, labels) if l == 0]
        self.w = [mean([z[j] for z in zs_h]) - mean([z[j] for z in zs_n]) for j in range(D)]
        self.thr = quantile([dot(self.w, z) for z in zs_h], 1 - recall)

    def rep(self, ids):
        ctx, _ = _distinct_mean(self.world.sem, ids[:1] + ids[2:], self.alpha)
        return [a + b for a, b in zip(self.world.sem[ids[1]], ctx)]

    def score(self, ids):
        return dot(self.w, self.rep(ids))

    def trips(self, ids):
        return self.score(ids) > self.thr


class DefendedSystem:
    """
    把模型和若干防御串起来：输入过滤 → 模型（拒绝 / 照做）→ 生成时的熔断。

    【返回】outcome(ids) ∈ {"blocked_ppl", "blocked_det", "refused", "tripped", "complied"}
    【可信联系】评估防御时，攻击者面对的是**整个系统**，自适应攻击要针对整个系统设计（第 8 讲 §3.1）。
    """

    def __init__(self, model, ppl=None, det=None, probe=None):
        self.model, self.ppl, self.det, self.probe = model, ppl, det, probe

    def outcome(self, ids):
        if self.ppl is not None and self.ppl.blocks(ids):
            return "blocked_ppl"
        if self.det is not None and self.det.blocks(ids):
            return "blocked_det"
        if self.model.refuses(ids):
            return "refused"
        if self.probe is not None and self.probe.trips(ids):
            return "tripped"
        return "complied"


# =============================================================================
# 5. 攻击：GCG 与随机搜索 Attacks
# =============================================================================
class AttackObjective:
    """
    攻击者要最小化的损失，以及"是否成功"的判定。

    loss(suffix) = Σ_{模型 m} Σ_{prompt p} softplus(r_m(p + suffix) + margin)
                   + λ_ppl   · Σ_p relu(ppl(p + suffix)   − (thr_ppl   − 0.05))
                   + λ_det   · Σ_p relu(det(p + suffix)   − (thr_det   − 0.05))
                   + λ_probe · Σ_p relu(probe(p + suffix) − (thr_probe − 0.05))

    【思路】
        · 第一项就是 GCG 的目标（"让模型给出肯定的开头"）。多个 prompt 求和 ⇒ universal suffix；多个模型求和 ⇒ 更好迁移。
        · 后面三项只在**自适应**攻击里出现：攻击者知道防御的存在和打分方式，把"别被防御抓住"写进目标
          （对困惑度过滤器，这就是 AutoDAN（Zhu et al. 2023）式的"可读性"约束）。relu 的意思是"低于阈值就不再罚"。
        · margin > 0 让攻击把 r 推得更负（更"有把握"），类比 C&W 的 κ（第 7 讲 §6.7）。
    【参数】models / prompts；margin；ppl / det / probe：防御对象（None = 不考虑）；lam_*：对应权重；
           systems：判定成功用的 DefendedSystem（None ⇒ 只看第一个模型是否照做）；
           need：成功率达到多少算"攻击成功"（单个 prompt 用 1.0；universal 也用 1.0 = 训练 prompt 全部成功）
    """

    def __init__(self, models, prompts, margin=0.0, ppl=None, lam_ppl=0.0, det=None, lam_det=0.0,
                 probe=None, lam_probe=0.0, system=None, need=1.0):
        self.models, self.prompts, self.margin = models, prompts, margin
        self.ppl, self.lam_ppl = ppl, lam_ppl
        self.det, self.lam_det = det, lam_det
        self.probe, self.lam_probe = probe, lam_probe
        self.system = system
        self.need = need

    def _penalty(self, ids):
        pen = 0.0
        if self.ppl is not None and self.lam_ppl > 0:
            pen += self.lam_ppl * max(0.0, self.ppl.score(ids) - (self.ppl.thr - 0.05))
        if self.det is not None and self.lam_det > 0:
            pen += self.lam_det * max(0.0, self.det.score(ids) - (self.det.thr - 0.05))
        if self.probe is not None and self.lam_probe > 0:
            pen += self.lam_probe * max(0.0, self.probe.score(ids) - (self.probe.thr - 0.05))
        return pen

    def loss(self, suffix):
        terms = []
        for p in self.prompts:
            ids = p + suffix
            for m in self.models:
                terms.append(softplus(m.refusal_logit(ids) + self.margin))
            terms.append(self._penalty(ids))
        return math.fsum(terms)

    def success_rate(self, suffix):
        """成功的 prompt 比例（成功 = 系统最终"照做"了这个 forbidden 请求）。"""
        k = 0
        for p in self.prompts:
            ids = p + suffix
            if self.system is None:
                k += 0 if self.models[0].refuses(ids) else 1
            else:
                k += 1 if self.system.outcome(ids) == "complied" else 0
        return k / len(self.prompts)

    def linear_scores(self, suffix, vocab):
        """
        GCG 的候选打分：scores[i][k] ≈ 把后缀第 i 位换成 vocab[k] 之后，损失的变化（一阶近似）。

        【思路】d softplus(r + margin)/dr = σ(r + margin)。对每个 (模型, prompt)：
                   新 token t 不在上下文里 ⇒ r 约增加 g·e_t；当前 token 只出现一次 ⇒ 拿掉它 r 约减少 g·e_cur。
               先对整个词表算一次 g·e_t（和位置无关），每个位置只需要再减去自己的那一项 —— 这样很快。
               三个罚项的局部变化也用同样的方式近似（困惑度用 bigram 的精确局部变化），只在"接近阈值"时才加。
        """
        m_len = len(suffix)
        V = len(vocab)
        scores = [[0.0] * V for _ in range(m_len)]
        for p in self.prompts:
            ids = p + suffix
            off = len(p)
            for model in self.models:
                r, g, counts = model.context_gradient(ids)
                wgt = sigmoid(r + self.margin)
                E = model.emb
                proj = [0.0 if t in counts else dot(E[t], g) for t in vocab]
                for i in range(m_len):
                    cur = suffix[i]
                    out = dot(E[cur], g) if counts[cur] == 1 else 0.0
                    row = scores[i]
                    for k in range(V):
                        if vocab[k] != cur:
                            row[k] += wgt * (proj[k] - out)
            self._add_penalty_scores(scores, ids, off, suffix, vocab)
        return scores

    def _add_penalty_scores(self, scores, ids, off, suffix, vocab):
        L = len(ids)
        if self.ppl is not None and self.lam_ppl > 0 and self.ppl.score(ids) > self.ppl.thr - 0.3:
            lp = self.ppl.lm.logp
            for i in range(len(suffix)):
                pos = off + i
                prev = ids[pos - 1]
                nxt = ids[pos + 1] if pos + 1 < L else None
                old = -lp[prev][ids[pos]] - (lp[ids[pos]][nxt] if nxt is not None else 0.0)
                row = scores[i]
                for k, t in enumerate(vocab):
                    new = -lp[prev][t] - (lp[t][nxt] if nxt is not None else 0.0)
                    row[k] += self.lam_ppl * (new - old) / L
        if self.det is not None and self.lam_det > 0 and self.det.score(ids) > self.det.thr - 0.3:
            lo = self.det.logodds
            for i in range(len(suffix)):
                row = scores[i]
                for k, t in enumerate(vocab):
                    row[k] += self.lam_det * (lo[t] - lo[suffix[i]]) / L
        if self.probe is not None and self.lam_probe > 0 and self.probe.score(ids) > self.probe.thr - 0.3:
            S, wv = self.probe.world.sem, self.probe.w
            ctx_ids = ids[:1] + ids[2:]
            present = set(ctx_ids)
            coef = self.probe.alpha / len(present)
            proj = [0.0 if t in present else dot(wv, S[t]) for t in vocab]
            for i in range(len(suffix)):
                cur = suffix[i]
                out = dot(wv, S[cur]) if ctx_ids.count(cur) == 1 else 0.0
                row = scores[i]
                for k in range(len(vocab)):
                    row[k] += self.lam_probe * coef * (proj[k] - out)


def run_attack(obj, vocab, rng, init, method="gcg", steps=60, topk=8, batch=32, track=None):
    """
    离散后缀优化：GCG（Greedy Coordinate Gradient）或随机搜索。

    【思路】GCG（Zou et al. 2023；Zifan Wang 的 slides 第 16 页）每一步：
              1. 用梯度给每个位置、每个可能的替换打一阶分数（linear_scores）；
              2. 每个位置保留分数最好的 top-k 个 token；
              3. 随机抽 B 个候选：随机位置 i + 该位置 top-k 里随机一个 token（一次只改一个位置 = coordinate）；
              4. **用真正的前向传播**算每个候选的损失，换成最好的那个（greedy）。
           "random"：第 1–2 步换成"在整个词表里均匀随机"，第 4 步只在变好时接受
           （Andriushchenko et al. 2024 式的随机搜索：不需要梯度，只需要能查询损失）。
    【参数】obj：AttackObjective；vocab：允许的后缀 token；rng：random.Random；init：初始后缀（决定长度 m）；
           steps：最多步数；topk / batch：GCG 超参数；
           track：如果给出一个列表，就把每步的 (累计损失评估次数, 当前成功率) 追加进去
    【返回】dict：suffix、success（是否达到 obj.need）、steps_used、evals（损失评估次数 ≈ 查询次数 / prompt 数）
    【边界】GCG 每步无条件换成"候选里最好的"（和官方实现一致），所以损失可能偶尔上升；
           没成功时返回历史上损失最低的后缀。
    """
    suffix = list(init)
    m = len(suffix)
    cur = obj.loss(suffix)
    best_loss, best_suffix = cur, suffix[:]
    evals, step = 1, 0
    rate = obj.success_rate(suffix)
    while rate < obj.need and step < steps:
        step += 1
        cands = []
        if method == "gcg":
            scores = obj.linear_scores(suffix, vocab)
            evals += 1                                     # 一次"前向 + 反向"
            top = [sorted(range(len(vocab)), key=lambda k: (scores[i][k], k))[:topk] for i in range(m)]
            # 【Python 小知识】key=lambda k: (分数, k)：按分数排序，分数相同时按编号 —— 保证结果确定
            for _ in range(batch):
                i = rng.randrange(m)
                cand = suffix[:]
                cand[i] = vocab[rng.choice(top[i])]
                cands.append(cand)
        elif method == "random":
            for _ in range(batch):
                i = rng.randrange(m)
                cand = suffix[:]
                cand[i] = rng.choice(vocab)
                cands.append(cand)
        else:
            raise ValueError(method)
        vals = [obj.loss(c) for c in cands]
        evals += batch
        j = min(range(batch), key=lambda q: (vals[q], q))
        if method == "gcg" or vals[j] < cur:
            suffix, cur = cands[j], vals[j]
        if cur < best_loss:
            best_loss, best_suffix = cur, suffix[:]
        rate = obj.success_rate(suffix)
        if track is not None:
            track.append((evals, rate))
    if rate < obj.need:
        suffix = best_suffix
        rate = obj.success_rate(suffix)
    return {"suffix": suffix, "success": rate >= obj.need, "rate": rate, "steps_used": step, "evals": evals}


def linearization_check(obj, suffix, vocab, rng, n_cand=200):
    """
    检查 GCG 的一阶近似有多准：随机抽 n_cand 个单 token 替换，比较"预测的损失变化"和"真实的损失变化"。

    【返回】(spearman, top_hit)：秩相关；以及"预测最好的那个候选"是否真的是这批候选里最好的（0/1）
    【可信联系】Zifan Wang 的 slides："don't trust the gradient approximation too much" —— 所以 GCG 要真算一遍候选。
    """
    scores = obj.linear_scores(suffix, vocab)
    base = obj.loss(suffix)
    pred, real = [], []
    for _ in range(n_cand):
        i = rng.randrange(len(suffix))
        k = rng.randrange(len(vocab))
        cand = suffix[:]
        cand[i] = vocab[k]
        pred.append(scores[i][k])
        real.append(obj.loss(cand) - base)
    best_pred = min(range(n_cand), key=lambda q: (pred[q], q))
    best_real = min(range(n_cand), key=lambda q: (real[q], q))
    return spearman(pred, real), 1 if best_pred == best_real else 0


def names(world, ids):
    """把 token 编号列表变成可读的字符串，例如 'ASK t03 w12 w40'。"""
    return " ".join(world.vocab[t] for t in ids)


def neutral_suffix(world, m, seed=99):
    """
    攻击的初始后缀：一段 m 个普通词的"自然文本"（类比 GCG 原文从 "! ! ! …" 开始）。

    【思路】初始后缀本身应该是无害的（几乎不改变模型行为），这样攻击的效果都来自优化本身。
    """
    return natural_text(world, random.Random(seed), m)


if __name__ == "__main__":
    # 自检：世界、模型能构造；自然 prompt 上的拒绝率、随机后缀的效果看起来合理
    W = make_world()
    M = make_aligned_model(W, hidden_seed=1, data_seed=2)
    rng0 = random.Random(0)
    for kind in ("harmful", "edgy", "benign"):
        ps = [sample_prompt(W, rng0, kind) for _ in range(200)]
        print(f"{kind:>8}: refusal rate on natural prompts = {mean([1.0 if M.refuses(p) else 0.0 for p in ps]):.3f}")
    ps = [sample_prompt(W, rng0, "harmful") for _ in range(200)]
    rnd = mean([0.0 if M.refuses(p + [rng0.choice(W.suffix_vocab) for _ in range(10)]) else 1.0 for p in ps])
    print(f"harmful + 10 random suffix tokens: compliance = {rnd:.3f}")
    print("example prompt:", names(W, sample_prompt(W, rng0, "harmful")))
