"""
code_lab.py —— 第 23 讲的共享模块：一门可以在沙盒里执行的小编程语言，以及 pass@k、填中间（FIM）、编程智能体要用的工具
=====================================================================

【这个文件要回答的问题】
    代码和自然语言最大的不同：**它可以被执行**。程序对不对，跑一遍测试就知道。这件事改变了三件事：
        评估：不用和参考答案比字面（BLEU），而是看测试通过没有（pass@k）；
        推理：可以生成很多份、用测试筛；失败时把报错拿回来再改（迭代自我修正、编程智能体）；
        训练：测试结果就是奖励（RL）。
    要在笔记本上做这些实验，我们需要一门**小**语言：小到 2 分钟能训练出一个会写它的模型，
    又真的能"执行"、能"写错"、能有"写法不同但功能相同"的程序。

【语言一：字符串变换（示例编程，programming by example）】
    输入是 a–h 组成的短字符串（3–5 个字母），程序是 1–2 个操作，从左到右依次作用：
        rev  反转           "abcd" → "dcba"
        rot  每个字母 +1     "abch" → "bcda"（h 之后回到 a）
        dup  每个字母写两遍   "abc"  → "aabbcc"
        drop 去掉第一个字母   "abcd" → "bcd"
    "规格"是 1–3 个输入→输出的例子，模型要写出程序，例如：
        <s> d a c > c a d ; b e g > g e b = rev </s>
    一共 19 个程序（dup 最多用一次）。有的写法不同、功能相同（"rev rot" 和 "rot rev"、"drop rot" 和 "rot drop"），
    有的写法相近、功能不同（"rev rot" 和 "rev drop"）——正好用来说明为什么 BLEU 不适合评估代码。
    【为什么只有 4 个操作】第一版有 6 个操作（还有 sort、swap）、例子最多 4 个：训练 3000 步要 3 分钟，
        贪心准确率只有 0.17–0.19（README 第 1 条）。

【语言二：直线程序（02 号脚本的"填中间"用）】
    每行是 "变量 = 操作 变量"，最后一行 "out 变量"，例如：
        v3 = rot inp ; v7 = rev v3 ; v1 = dup v3 ; out v1
    变量名随机：要补中间缺的一行，必须看后面用到了哪个还没定义的变量——只看前缀的模型做不到。

【本文件提供】
    pass@k：pass_at_k（无偏估计，Codex 论文的数值稳定写法）、pass_at_k_comb（组合数直接算）、naive_pass_at_k
    语言一：OPS、run_program（沙盒解释器）、PROGRAMS、random_task、encode_task、pbe_vocab、batch_fn、
        get_pbe_model、sample_programs、program_logprob、passes、bleu
    语言二：random_straight、check_straight、fim_example、straight_vocab、encode_l2r、encode_fim
    通用：build_gpt、train_steps、cached_model（复用第 2 讲的 GPT）

【边界】
    - 解释器只认识 4 个操作名；任何别的东西都会被当成"语法错误"拒绝——我们从不把模型的输出交给 Python 去执行。
      真实的编程智能体同样要在沙盒（容器、受限的权限）里运行生成的代码。
    - "模型"只有几十万参数，语言只有 4 个操作；结论是机制上的，不是大模型上的定量结果。
"""

import math
import os
import random
import sys

import torch
import torch.nn.functional as F

HERE = os.path.dirname(os.path.abspath(__file__))
_p = os.path.join(HERE, "..", "lecture02")
if _p not in sys.path:
    sys.path.insert(0, _p)

from lm_models import GPT  # noqa: E402

CKPT = os.path.join(HERE, "checkpoints")
IGNORE = -100


# =====================================================================
# 1. pass@k
# =====================================================================

def pass_at_k(n, c, k):
    """
    pass@k 的无偏估计（Chen et al., 2021，Codex 论文）：每道题生成 n ≥ k 个样本、其中 c 个通过测试，
        pass@k ≈ 1 − C(n−c, k) / C(n, k)
    即"从这 n 个里随机抽 k 个，至少有一个正确"的概率。

    【思路】直接算组合数会溢出（n = 200、k = 100 时 C(n, k) 有 59 位数），论文给的写法是连乘：
        C(n−c, k)/C(n, k) = Π_{i = n−c+1}^{n} (1 − k/i)
    【参数】n：样本数；c：正确的样本数（0 ≤ c ≤ n）；k：1 ≤ k ≤ n
    【返回】0 … 1 之间的浮点数
    【边界】n − c < k 时，任意抽 k 个都至少有一个正确，直接返回 1.0。
    """
    if n - c < k:
        return 1.0
    prod = 1.0
    for i in range(n - c + 1, n + 1):
        prod *= 1.0 - k / i
    return 1.0 - prod


def pass_at_k_comb(n, c, k):
    """同一个估计，用 Python 的整数组合数精确地算（只用来核对 pass_at_k）。"""
    return 1.0 - math.comb(n - c, k) / math.comb(n, k)


def naive_pass_at_k(n, c, k):
    """
    "直觉"的估计：先估单次的成功率 p̂ = c/n，再算 1 − (1 − p̂)^k。
    【边界】它是**有偏的**（系统性地偏低）：1 − (1 − p)^k 是 p 的凹函数，由 Jensen 不等式，E[f(p̂)] ≤ f(p)。
    """
    return 1.0 - (1.0 - c / n) ** k


# =====================================================================
# 2. 语言一：字符串变换
# =====================================================================

LETTERS = "abcdefgh"


def _rot(s):
    return "".join(LETTERS[(LETTERS.index(ch) + 1) % len(LETTERS)] for ch in s)


OPS = {"rev": lambda s: s[::-1], "rot": _rot, "dup": lambda s: "".join(ch + ch for ch in s), "drop": lambda s: s[1:]}
OP_NAMES = list(OPS)
# 全部程序：1 个操作或 2 个操作（dup 最多一次，免得输出太长）
PROGRAMS = [(a,) for a in OP_NAMES] + [(a, b) for a in OP_NAMES for b in OP_NAMES if not (a == b == "dup")]
MAX_OUT = 10


def run_program(prog, s):
    """
    沙盒解释器：把程序（操作名的元组 / 列表）依次作用在字符串 s 上。

    【返回】(输出字符串, None) 或 (None, 错误信息)
    【边界】
        - 空程序、未知的操作名（"语法错误"）都返回错误，不会抛异常——调用方（智能体）可以把错误信息当作反馈；
        - 输出超过 MAX_OUT 个字母也算错误（防止"写出无限长的输出"这类失控，相当于真实沙盒的资源限制）；
        - 对空字符串做 drop 得到空字符串，不算错误。
    """
    if not prog:
        return None, "SyntaxError: 空程序"
    for op in prog:
        if op not in OPS:
            return None, f"SyntaxError: 未知的操作 {op!r}"
    for op in prog:
        s = OPS[op](s)
        if len(s) > MAX_OUT:
            return None, "RuntimeError: 输出太长"
    return s, None


def random_input(rng, lo=3, hi=5):
    """一个随机输入：长度 lo … hi，字母可以重复。"""
    return "".join(rng.choice(LETTERS) for _ in range(rng.randint(lo, hi)))


def random_task(rng, n_visible=3, n_hidden=8, program=None):
    """
    一道题：随机程序 + n_visible 个可见例子 + n_hidden 个隐藏测试（输入互不相同）。

    【返回】{"program": 元组, "visible": [(输入, 输出)], "hidden": [(输入, 输出)]}
    【边界】可见例子的输出要能区分出程序的一部分特征：我们不做特别的筛选，所以有的题的可见例子本身就有歧义
        （例如例子里的字符串都是回文，rev 和"什么都不做"分不开）——这正是"过拟合可见测试"会发生的原因。
    """
    program = program or rng.choice(PROGRAMS)
    seen, ins = set(), []
    while len(ins) < n_visible + n_hidden:
        x = random_input(rng)
        if x not in seen:
            seen.add(x)
            ins.append(x)
    pairs = [(x, run_program(program, x)[0]) for x in ins]
    return {"program": tuple(program), "visible": pairs[:n_visible], "hidden": pairs[n_visible:]}


def pbe_vocab():
    """语言一的词表：特殊记号、8 个字母、分隔符、4 个操作名。"""
    words = ["<pad>", "<s>", "</s>", ">", ";", "="] + list(LETTERS) + OP_NAMES
    return {w: i for i, w in enumerate(words)}, words


PBE_STOI, PBE_ITOS = pbe_vocab()
PBE_MAX_LEN = 64


def encode_examples(examples):
    """把若干个 (输入, 输出) 例子写成 token：a b c > c b a ; d e > e d ; …，最后接 "="。"""
    toks = ["<s>"]
    for i, (x, y) in enumerate(examples):
        if i:
            toks.append(";")
        toks += list(x) + [">"] + list(y)
    toks.append("=")
    return [PBE_STOI[t] for t in toks]


def encode_task(examples, program):
    """完整的训练样本：例子 + "=" + 程序 + </s>。返回 (ids, 程序开始的位置)。"""
    prompt = encode_examples(examples)
    return prompt + [PBE_STOI[o] for o in program] + [PBE_STOI["</s>"]], len(prompt)


def batch_fn(seed, batch_size=64, k_range=(1, 3)):
    """
    一批训练数据：每道题随机给 1–3 个例子（智能体要能在"例子越来越多"的提示上工作），只在程序上计损失。
    【返回】(inputs, targets)，形状 (B, T)
    """
    rng = random.Random(seed)
    seqs = []
    for _ in range(batch_size):
        task = random_task(rng, n_visible=rng.randint(*k_range), n_hidden=0)
        seqs.append(encode_task(task["visible"], task["program"]))
    T = max(len(s) for s, _ in seqs) - 1
    inp = torch.zeros(batch_size, T, dtype=torch.long)
    tgt = torch.full((batch_size, T), IGNORE, dtype=torch.long)
    for i, (s, start) in enumerate(seqs):
        n = len(s) - 1
        inp[i, :n] = torch.tensor(s[:-1])
        tgt[i, start - 1:n] = torch.tensor(s[start:])
    return inp, tgt


def build_gpt(n_vocab, d_model, n_layers, n_heads, max_len, seed):
    """第 2 讲的 GPT。构造前先设种子（本机上每个进程的默认种子不同）。"""
    torch.manual_seed(seed)
    return GPT(n_vocab, d_model=d_model, n_layers=n_layers, n_heads=n_heads, max_len=max_len)


def train_steps(model, batches, steps, lr=1e-3, warmup=100):
    """
    在一个"批生成器"上训练固定步数：batches(step) -> (inputs, targets)。Adam、线性预热、梯度裁剪 1.0。
    【返回】每 100 步的平均损失列表
    """
    opt = torch.optim.Adam(model.parameters(), lr=lr)
    model.train()
    curve, run = [], 0.0
    for step in range(1, steps + 1):
        for g in opt.param_groups:
            g["lr"] = lr * min(1.0, step / warmup)
        inp, tgt = batches(step)
        logits, _ = model(inp)
        loss = F.cross_entropy(logits.reshape(-1, logits.size(-1)), tgt.reshape(-1), ignore_index=IGNORE)
        opt.zero_grad()
        loss.backward()
        torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
        opt.step()
        run += loss.item()
        if step % 100 == 0:
            curve.append(run / 100)
            run = 0.0
    model.eval()
    return curve


def cached_model(name, cfg, n_vocab, batches, quick=False, log=print):
    """
    有缓存就读 checkpoints/<name>.pt，没有就训练并保存。cfg 里有 d_model / n_layers / n_heads / max_len / seed / steps / lr。
    【边界】quick=True 时只训练 30 步、不读写缓存；读取时配置不同就重新训练。
    """
    path = os.path.join(CKPT, f"{name}.pt")
    build = lambda: build_gpt(n_vocab, cfg["d_model"], cfg["n_layers"], cfg["n_heads"], cfg["max_len"],  # noqa: E731
                              cfg["seed"])
    if not quick and os.path.exists(path):
        blob = torch.load(path, weights_only=True)
        if blob["cfg"] == cfg:
            model = build()
            model.load_state_dict(blob["state"])
            model.eval()
            if log:
                log(f"  （读取缓存的模型：checkpoints/{name}.pt）")
            return model, blob["curve"]
    model = build()
    curve = train_steps(model, batches, 30 if quick else cfg["steps"], lr=cfg["lr"])
    if not quick:
        os.makedirs(CKPT, exist_ok=True)
        torch.save({"cfg": cfg, "state": model.state_dict(), "curve": curve}, path)
        if log:
            log(f"  （已训练并保存到 checkpoints/{name}.pt）")
    return model, curve


PBE_CFG = {"d_model": 64, "n_layers": 4, "n_heads": 1, "max_len": PBE_MAX_LEN, "seed": 0, "steps": 2000, "lr": 1e-3}
"""
【为什么只训练 2000 步】预实验里准确率在第 1000 步左右就停在 0.3–0.4，训练到 6000 步也没有再涨（README 第 1 条）。
    这个"贪心不太准、但多采样几次常常能写对"的模型，正好用来演示 pass@k 和测试反馈。1 个 64 维的头：
    第 22 讲、第 19 讲都发现 16 维的头学不会按内容查找。
"""


def get_pbe_model(quick=False, log=print):
    """语言一的"代码模型"（01、03、04 号脚本共用同一个缓存）。"""
    return cached_model("pbe", PBE_CFG, len(PBE_ITOS), lambda s: batch_fn(1_000_003 + s), quick, log)[0]


@torch.no_grad()
def sample_programs(model, examples, n, generator, temperature=1.0, max_ops=3):
    """
    给定例子，采样 n 个程序（批量）。temperature = 0 表示贪心。
    【返回】(程序列表（每个是操作名的元组；可能含非法的词，交给解释器去拒绝）, 每个程序的对数概率（按原始分布）)
    【边界】最多生成 max_ops 个 token 就截断；生成 </s> 就结束。
    """
    prompt = encode_examples(examples)
    ids = torch.tensor([prompt] * n)
    done = torch.zeros(n, dtype=torch.bool)
    out = [[] for _ in range(n)]
    logp = torch.zeros(n, dtype=torch.float64)
    eos = PBE_STOI["</s>"]
    for _ in range(max_ops + 1):
        logits = model(ids)[0][:, -1].double()
        lp = F.log_softmax(logits, -1)
        if temperature == 0:
            nxt = lp.argmax(-1)
        else:
            nxt = torch.multinomial(F.softmax(logits / temperature, -1), 1, generator=generator).squeeze(1)
        logp += torch.where(done, torch.zeros(n, dtype=torch.float64), lp[torch.arange(n), nxt])
        for b in range(n):
            if not done[b]:
                if nxt[b].item() == eos:
                    done[b] = True
                else:
                    out[b].append(PBE_ITOS[nxt[b].item()])
        if done.all():
            break
        ids = torch.cat([ids, nxt.unsqueeze(1)], dim=1)
    return [tuple(p) for p in out], logp.tolist()


@torch.no_grad()
def program_logprobs(model, examples, programs=None):
    """
    给定例子，**每一个**候选程序（默认全部 19 个）在模型下的对数概率 log p(程序 + </s> | 例子)。

    【思路】语言只有 19 个程序，可以全部枚举——于是能精确地找出"模型认为最可能的程序"，
        和贪心解码（每一步取最可能的 token）比较（第 3 讲：贪心不等于整体最可能）。
    【返回】(程序列表, 对数概率张量)
    """
    programs = programs or PROGRAMS
    prompt = encode_examples(examples)
    seqs = [prompt + [PBE_STOI[o] for o in p] + [PBE_STOI["</s>"]] for p in programs]
    T = max(map(len, seqs))
    ids = torch.zeros(len(seqs), T, dtype=torch.long)
    for i, s in enumerate(seqs):
        ids[i, :len(s)] = torch.tensor(s)
    lp = F.log_softmax(model(ids[:, :-1])[0].double(), -1)
    out = torch.zeros(len(seqs), dtype=torch.float64)
    for i, s in enumerate(seqs):
        for j in range(len(prompt), len(s)):
            out[i] += lp[i, j - 1, s[j]]
    return list(programs), out


def passes(prog, tests):
    """程序在一组 (输入, 期望输出) 上是否全部通过。"""
    for x, y in tests:
        got, err = run_program(prog, x)
        if err is not None or got != y:
            return False
    return True


def bleu(candidate, reference, max_n=2):
    """
    句子级 BLEU（Papineni et al., 2002），n-gram 最高到 max_n，加 1 平滑：
        BLEU = BP · exp( (1/N) Σ_n log p_n ),  p_n = (重合的 n-gram 个数 + 1) / (候选的 n-gram 个数 + 1)
        BP = 1（候选不短于参考）或 exp(1 − |参考| / |候选|)
    【边界】程序只有 1–2 个 token，所以只用到 2-gram；空候选的 BLEU 记为 0。
    """
    if not candidate:
        return 0.0
    logs = []
    for n in range(1, max_n + 1):
        cand = [tuple(candidate[i:i + n]) for i in range(len(candidate) - n + 1)]
        ref = [tuple(reference[i:i + n]) for i in range(len(reference) - n + 1)]
        overlap = sum(min(cand.count(g), ref.count(g)) for g in set(cand))
        logs.append(math.log((overlap + 1) / (len(cand) + 1)))
    bp = 1.0 if len(candidate) >= len(reference) else math.exp(1 - len(reference) / len(candidate))
    return bp * math.exp(sum(logs) / len(logs))


# =====================================================================
# 3. 语言二：直线程序（填中间）
# =====================================================================

VAR_NAMES = [f"v{i}" for i in range(10)]
STRAIGHT_WORDS = ["<pad>", "<s>", "</s>", "<PRE>", "<SUF>", "<MID>", "<EOT>", "=", ";", "out", "inp"] + OP_NAMES + VAR_NAMES
ST_STOI = {w: i for i, w in enumerate(STRAIGHT_WORDS)}
ST_MAX_LEN = 48


def random_straight(rng, n_lines=None):
    """
    一个随机的直线程序（词列表），例如 v3 = rot inp ; v7 = rev v3 ; v1 = dup v3 ; out v1 ;

    【思路】每一行定义一个**新的**变量（名字从 v0 … v9 里随机挑没用过的），参数从已经定义的变量（含 inp）里随机挑；
        最后 out 最后定义的那个变量。变量名是随机的，所以"下一行定义哪个变量"从前缀里猜不出来。
    【参数】n_lines：赋值语句的行数（默认 3–5 随机）
    """
    n_lines = n_lines or rng.randint(3, 5)
    names = rng.sample(VAR_NAMES, n_lines)
    defined, toks = ["inp"], []
    for v in names:
        toks += [v, "=", rng.choice(OP_NAMES), rng.choice(defined), ";"]
        defined.append(v)
    return toks + ["out", names[-1], ";"]


def check_straight(toks):
    """
    检查一个直线程序能不能运行：每行格式对、每个用到的变量都已定义、没有重复定义、最后有 out。
    【返回】(True, None) 或 (False, 原因)
    """
    lines, cur = [], []
    for t in toks:
        if t == ";":
            lines.append(cur)
            cur = []
        else:
            cur.append(t)
    if cur:
        return False, "最后一行没有以 ; 结束"
    if not lines or lines[-1][:1] != ["out"]:
        return False, "没有 out"
    defined = {"inp"}
    for ln in lines[:-1]:
        if len(ln) != 4 or ln[1] != "=" or ln[2] not in OPS or ln[0] not in VAR_NAMES:
            return False, f"语法错误：{' '.join(ln)}"
        if ln[3] not in defined:
            return False, f"NameError：{ln[3]} 还没有定义"
        if ln[0] in defined:
            return False, f"{ln[0]} 重复定义"
        defined.add(ln[0])
    out = lines[-1]
    if len(out) != 2 or out[1] not in defined:
        return False, "out 的变量没有定义"
    return True, None


def encode_l2r(toks):
    """从左到右的普通写法：<s> 程序 </s>。"""
    return [ST_STOI["<s>"]] + [ST_STOI[t] for t in toks] + [ST_STOI["</s>"]]


def encode_fim(prefix, middle, suffix):
    """
    Bavarian et al. (2022) 的 PSM 格式：<PRE> 前缀 <SUF> 后缀 <MID> 中间 <EOT>。
    【思路】把文档切成三段、交换后两段的顺序：模型仍然是从左到右生成，但生成"中间"时已经看过了"后缀"。
    【返回】(ids, 中间开始的位置)——训练时只在"中间"和 <EOT> 上计损失也可以，这里按论文对整个序列计损失。
    """
    ids = ([ST_STOI["<PRE>"]] + [ST_STOI[t] for t in prefix] + [ST_STOI["<SUF>"]] + [ST_STOI[t] for t in suffix]
           + [ST_STOI["<MID>"]])
    return ids + [ST_STOI[t] for t in middle] + [ST_STOI["<EOT>"]], len(ids)


def split_lines(toks):
    """把直线程序按 ";" 切成行（每行保留结尾的 ";"）。"""
    out, cur = [], []
    for t in toks:
        cur.append(t)
        if t == ";":
            out.append(cur)
            cur = []
    return out


def fim_example(rng, toks, mode="line"):
    """
    训练用的随机切分：前缀 / 中间 / 后缀。
        mode = "token"：在 token 序列里随机挑两个位置 i ≤ j，中间 = [i:j]（论文是在字符级随机切，思路相同）；
        mode = "line" ：在**行边界**上随机挑 a < b，中间 = 第 a … b−1 行（至少一行）。
    【返回】(prefix, middle, suffix)
    【两种切法的取舍】按行切，训练和"补一整行"的测试完全一致；按 token 随机切，模型见过各种切点，
        但很少恰好切在行边界上。Bavarian et al. (2022) 的表 2：按行切的模型补单行最好，但遇到随机切点几乎完全失败；
        字符级随机切的模型在随机切点上好得多，补单行也只差一点。03 号脚本在小模型上比较这两种切法。
    """
    if mode == "token":
        i, j = sorted(rng.randint(0, len(toks)) for _ in range(2))
        return toks[:i], toks[i:j], toks[j:]
    lines = split_lines(toks)
    a = rng.randrange(len(lines))
    b = rng.randint(a + 1, len(lines))
    return sum(lines[:a], []), sum(lines[a:b], []), sum(lines[b:], [])


def straight_true_nll(toks):
    """
    一个直线程序在**生成它的真实分布**下的负对数概率（纳特）和被预测的 token 数（含 </s>）。

    【思路】按 random_straight 的生成过程逐步累加：行数在 3–5 里均匀，所以第 4、5 行开头"继续写还是 out"各有概率；
        每行的变量名在没用过的名字里均匀（第 i 行有 10 − i 种）、操作 1/4、参数在已定义的 i + 1 个变量里均匀；
        "="、";"、out 后面的变量（就是最后定义的那个）和 </s> 都是确定的。
    【用途】任何模型从左到右的平均损失都不可能低于"真实分布的熵"——用它判断模型离最优还有多远。
    """
    lines = split_lines(toks)
    n = len(lines) - 1
    nll = 0.0
    for i in range(n):
        if i == 3:
            nll -= math.log(2 / 3)
        elif i == 4:
            nll -= math.log(1 / 2)
        nll -= math.log(1 / (len(VAR_NAMES) - i)) + math.log(1 / len(OP_NAMES)) + math.log(1 / (i + 1))
    if n == 3:
        nll -= math.log(1 / 3)
    elif n == 4:
        nll -= math.log(1 / 2)
    return nll, len(toks) + 1


def straight_batch(seed, fim_rate, mode="line", batch_size=64):
    """一批语言二的训练数据：每个程序以 fim_rate 的概率写成 FIM 格式（切法见 fim_example），否则写成从左到右的格式；对整个序列计损失。"""
    rng = random.Random(seed)
    seqs = []
    for _ in range(batch_size):
        toks = random_straight(rng)
        if rng.random() < fim_rate:
            seqs.append(encode_fim(*fim_example(rng, toks, mode))[0])
        else:
            seqs.append(encode_l2r(toks))
    T = max(len(s) for s in seqs) - 1
    inp = torch.zeros(batch_size, T, dtype=torch.long)
    tgt = torch.full((batch_size, T), IGNORE, dtype=torch.long)
    for i, s in enumerate(seqs):
        n = len(s) - 1
        inp[i, :n] = torch.tensor(s[:-1])
        tgt[i, :n] = torch.tensor(s[1:])
    return inp, tgt


@torch.no_grad()
def greedy_until(model, prompt, stop_id, max_new=12):
    """从一个提示开始贪心生成，直到生成 stop_id 或达到 max_new 个 token。返回生成的 id 列表（不含 stop_id）。"""
    ids = torch.tensor([prompt])
    out = []
    for _ in range(max_new):
        nxt = model(ids)[0][0, -1].argmax().item()
        if nxt == stop_id:
            break
        out.append(nxt)
        ids = torch.cat([ids, torch.tensor([[nxt]])], dim=1)
    return out
