"""
=============================================================================
第 1 讲 · 工具模块：一个"写作助手"的玩具世界 —— 8 个候选回答 + 一群模拟标注员
=============================================================================

对应讲义：lectures/lecture01_intro_motivation.md 第 0.4 节（本讲的玩具世界）

-----------------------------------------------------------------------------
【为什么要造一个玩具世界？】
-----------------------------------------------------------------------------
这门课研究的是"怎样从人的偏好里学习"。如果直接用真实的标注数据做实验，我们永远说不清：
    1. 某个回答"真正"有多好（没有标准答案，只有一堆人的意见）；
    2. 标注员的某个选择是"真心觉得更好"，还是被长度、格式、心情带偏了；
    3. 两群人意见不同，是谁"错了"，还是本来就口味不同。

所以我们自己造一个世界，并且**完全掌握**这三件事：
    1. 每个回答有一个"真实质量" quality（相当于一位细心的专家给的分）—— 这是只有模拟实验才有的
       "上帝视角"，算法本身不许偷看，只能用来评估；
    2. 每个模拟标注员的"偏差"是我们设定的参数（喜欢长回答、喜欢花哨格式……）；
    3. 每个标注员的"口味"也是参数（有人喜欢简洁，有人喜欢详尽）—— 口味不同不是错误。

-----------------------------------------------------------------------------
【这个世界里有什么？】
-----------------------------------------------------------------------------
一个提示词（prompt）：  "请向编程初学者解释：什么是递归？"
8 个候选回答（RESPONSES），A~H，每个回答有这些属性：

    quality   真实质量（0~1）。专家眼中这个回答对初学者有多大帮助。
    length    长度（字数）。
    detail    详细程度（0 = 极简，1 = 极其详尽）。口味不同的人对它的偏好不同。
    fancy     "表面功夫"（0~1）：满屏的标题、表情、加粗，以及"您这个问题问得太好了！"之类的客套。
    keywords  回答里出现了几个"专业关键词"（递归、基例、调用栈……）。只给第 01 号文件的"手写规则"用。
    has_code  有没有代码示例。同上。

一群模拟标注员（Annotator）。每个人心里对每个回答有一个"效用"（utility，可以理解成"喜欢的程度"）：

    效用 = 6 × 真实质量 + 长度偏差 × log(字数/200) + 花哨偏差 × fancy + 口味 × (detail − 0.5)

    · 专家（make_expert）：只看真实质量，没有任何偏差，口味中立。
    · 众包标注员（make_crowd）：平均来说喜欢长回答（长度偏差约 0.8）、喜欢花哨格式（花哨偏差约 2），
      口味因人而异。这些数字是为了演示而设的，不是测量结果；但"长度偏差"和"格式偏差"本身
      是真实存在、被反复测量到的现象（讲义第 4.5 节，以及第 14 讲）。

标注员做"二选一"时不是每次都选效用高的那个，而是按 Bradley–Terry 模型的概率选择：

    P(选 a 而不选 b) = σ(效用a − 效用b)，  σ(x) = 1 / (1 + e^(−x))

为什么是这个公式？这是整门课的第一个核心模型，02 号文件会用模拟来"推导"它，第 2、3 讲会详细讲。

-----------------------------------------------------------------------------
【用法】
-----------------------------------------------------------------------------
    import random
    from toy_world import RESPONSES, make_expert, make_crowd, prefers, fit_bradley_terry

    rng = random.Random(0)
    expert = make_expert()
    a, b = RESPONSES[0], RESPONSES[3]
    print(expert.utility(a), expert.utility(b))
    print(prefers(expert, a, b, rng))       # True 表示这一次专家选了 a

直接运行本文件会打印 8 个回答的属性、专家和众包标注员眼中的效用：
    python3 toy_world.py
"""

# 【Python 小知识】
#   import math / import random：导入 Python 自带的"标准库"模块，不用另外安装。
#       math   ：数学函数（exp、log、sqrt……）
#       random ：随机数。本课所有随机数都来自 random.Random(种子)，种子相同，结果就完全相同（可复现）。
#       unicodedata：查询字符的 Unicode 属性。这里只用来判断一个字符是不是"宽字符"（汉字），让表格对齐。
#   from dataclasses import dataclass：dataclass 是一个"装饰器"（写在类定义上方、以 @ 开头）。
#       被它装饰的类会自动生成 __init__（构造函数）等方法，适合定义"主要用来装数据"的类。例如
#           @dataclass
#           class Point:
#               x: int
#               y: int
#       之后就能写 Point(1, 2)，用 p.x、p.y 访问字段。冒号后面的 int 是"类型注解"，只是给人看的说明。
#       @dataclass(frozen=True) 表示对象创建后不能再修改字段（"冻结"），适合"常量"一样的数据。
import math
import random
import unicodedata
from dataclasses import dataclass

# -----------------------------------------------------------------------------
# 全局常量（全大写的变量名是 Python 社区的约定：表示"常量，不要修改"）
# -----------------------------------------------------------------------------
PROMPT = "请向编程初学者解释：什么是递归？"

# 效用里"真实质量"的权重。质量差 0.1 → 效用差 0.6 → 二选一时大约 65% 对 35%。
W_QUALITY = 6.0

# 长度的参照点：200 字。log(字数/200) 在 200 字时为 0，更长为正、更短为负。
# 为什么用 log？因为"从 100 字加到 200 字"和"从 400 字加到 800 字"给人的感觉差不多（都是翻倍），
# 这和心理学里的韦伯–费希纳定律（感觉和刺激的对数成正比）是同一个思路。
LENGTH_REF = 200

# 打分（1~10 分）时的"中心"：效用等于它时，一个尺子标准的人会打 5.5 分左右。
RATING_CENTER = 3.0

# 欧拉–马歇罗尼常数 γ ≈ 0.5772。标准 Gumbel 分布的平均值正好是 γ（02 号文件会用到）。
EULER_GAMMA = 0.5772156649015329


# =============================================================================
# 第一部分：8 个候选回答
# =============================================================================
@dataclass(frozen=True)
class Response:
    """
    一个候选回答。

    【字段】
        key       ：编号 "A"~"H"
        title     ：一句话描述这个回答的样子
        quality   ：真实质量（0~1）。上帝视角，算法不许偷看，只能用来评估。
        length    ：字数
        detail    ：详细程度（0~1）
        fancy     ："表面功夫"（0~1）：格式、表情、客套
        keywords  ：专业关键词的个数（只给手写规则用）
        has_code  ：有没有代码示例（只给手写规则用）

    【边界】真实的回答是一段文字，"质量"也不是一个数。这里把回答简化成一组属性，
        是为了能精确地控制和测量"偏差"—— 这是玩具世界和真实世界最大的差别。
    """
    key: str
    title: str
    quality: float
    length: int
    detail: float
    fancy: float
    keywords: int
    has_code: bool


# 8 个回答。注意几个"陷阱"是刻意设计的：
#   · A 最好，但很短 —— 有长度偏差的人会低估它；
#   · D 内容空泛，但又长又花哨 —— 有偏差的人会高估它（第 03 号文件里的"奖励黑客"就是它）；
#   · E 是"注水"长文，关键词最多 —— 手写规则"关键词越多越好"会把它排第一（01 号文件）；
#   · G 有错误但语气笃定 —— 光看表面很难发现。
RESPONSES = [
    Response("A", "简洁准确：一句话定义 + 阶乘例子 + 提醒“必须有基例”", 0.90, 160, 0.20, 0.0, 3, True),
    Response("B", "详尽教程：定义、套娃比喻、阶乘与斐波那契、调用栈图示、常见错误", 0.84, 480, 0.95, 0.0, 6, True),
    Response("C", "比喻生动：用“排队问前面的人”讲递归，再配一个小例子", 0.80, 260, 0.50, 0.2, 3, False),
    Response("D", "表面功夫：先夸问题问得好，满屏标题、表情和加粗，内容正确但空泛", 0.50, 950, 0.70, 1.0, 7, True),
    Response("E", "注水长文：同一个定义换着说法重复三遍，堆满术语", 0.30, 820, 0.75, 0.3, 9, False),
    Response("F", "只有代码：贴一段代码，一句解释都没有", 0.38, 80, 0.05, 0.0, 1, True),
    Response("G", "自信但有错：基例写错（会无限递归），语气却十分笃定", 0.22, 300, 0.45, 0.1, 4, True),
    Response("H", "答非所问：讲成了 for 循环（迭代）", 0.10, 240, 0.40, 0.1, 1, True),
]

# 【Python 小知识】"字典推导式" {键: 值 for 变量 in 列表}：一行代码建一个字典。
#   BY_KEY["A"] 就能拿到回答 A 对象；KEYS 是 ["A", "B", ..., "H"]。
BY_KEY = {r.key: r for r in RESPONSES}
KEYS = [r.key for r in RESPONSES]

# "参考策略" π_ref：一个"只做过监督微调（SFT）的模型"对这 8 个回答的生成概率。
# 它更常生成平庸或有问题的回答（这正是我们要用偏好去改进它的原因）。03 号文件会用它。
# 概率之和必须是 1（模块末尾有 assert 检查）。
REFERENCE_POLICY = {"A": 0.08, "B": 0.06, "C": 0.10, "D": 0.06,
                    "E": 0.16, "F": 0.16, "G": 0.18, "H": 0.20}


# =============================================================================
# 第二部分：数学小工具
# =============================================================================
def sigmoid(x):
    """
    逻辑斯蒂函数 σ(x) = 1 / (1 + e^(−x))，把任意实数压到 (0, 1) 之间。

    【思路】这是整门课出现最多的函数：Bradley–Terry 模型说"a 胜过 b 的概率 = σ(效用差)"。
        σ(0) = 0.5（效用相同，五五开）；σ(2) ≈ 0.88；σ(−2) ≈ 0.12；σ(x) + σ(−x) = 1。
    【参数】x：任意实数（float）
    【返回】0~1 之间的 float
    【边界】直接算 1/(1+exp(−x)) 在 x 很负（例如 −1000）时，exp(1000) 会"溢出"（OverflowError）。
        所以分两种情况写：x ≥ 0 时用原式；x < 0 时用等价的 e^x / (1 + e^x)，这样 exp 的参数永远 ≤ 0，
        不会溢出。这叫"数值稳定"的写法，真实的深度学习库（PyTorch 的 torch.sigmoid）内部也是这样做的。
    """
    if x >= 0:
        return 1.0 / (1.0 + math.exp(-x))
    z = math.exp(x)
    return z / (1.0 + z)


def sample_gumbel(rng):
    """
    从标准 Gumbel 分布抽一个随机数。

    【思路】"逆变换采样"：如果 U 是 (0,1) 上的均匀随机数，F 是某个分布的累积分布函数（CDF），
        那么 F⁻¹(U) 就服从这个分布。标准 Gumbel 的 CDF 是 F(x) = exp(−exp(−x))，
        解出反函数 F⁻¹(u) = −log(−log(u))。
    【参数】rng：random.Random 对象
    【返回】一个 float。平均值约 0.5772（欧拉常数 γ），右边的尾巴比左边长。
    【边界】rng.random() 可能返回 0.0（概率极小但不为零），log(0) 会报错，所以遇到 0 就重抽。
    【偏好学习联系】02 号文件会验证：如果每个人对每个回答的感受 = 效用 + 独立的 Gumbel 噪声，
        那么"选 a 不选 b"的概率恰好是 σ(效用a − 效用b)。这就是 Bradley–Terry 模型的来历（第 3 讲）。
    """
    u = rng.random()
    while u <= 0.0:
        u = rng.random()
    return -math.log(-math.log(u))


def mean(xs):
    """
    求平均值。

    【参数】xs：数字的列表（不能为空）
    【返回】float
    【边界】空列表会抛出 ZeroDivisionError —— 这是故意的：对空列表求平均没有意义，
        与其悄悄返回 0 掩盖错误，不如让程序直接报错。
    【Python 小知识】math.fsum 是"精确求和"：普通的 sum 把很多小数相加时会累积舍入误差，
        而且不同 Python 版本的 sum 实现不同（3.12 起 sum 也做了补偿），导致打印出来的最后一位可能不一样。
        用 math.fsum 可以保证在任何版本上结果都一样。
    """
    return math.fsum(xs) / len(xs)


def kendall_tau(scores_a, scores_b, keys=None):
    """
    肯德尔 τ（Kendall's tau）：两个排名有多一致。

    【思路】把所有的"一对对"拿出来看：如果两个打分对这一对的先后判断一致，算"一致对"，
        相反算"不一致对"。τ = (一致对 − 不一致对) / 总对数。
        τ = 1：排名完全相同；τ = −1：完全相反；τ ≈ 0：几乎无关。
        例：8 个回答有 28 对；如果只有 1 对顺序颠倒，τ = (27 − 1)/28 ≈ 0.93。
    【参数】
        scores_a, scores_b：字典 {回答编号: 分数}
        keys：要比较的编号列表；默认用 scores_a 的全部键（按字母顺序）
    【返回】−1 ~ 1 之间的 float
    【边界】某一对在任一边分数相同（并列）时，这一对既不算一致也不算不一致（这叫 τ-a 的写法；
        统计软件里常用的 τ-b 会对并列做修正，这里不需要）。少于 2 个键时返回 0。
    【偏好学习联系】排名一致性是评估"学到的偏好"最直观的指标之一；第 5 讲还会讲 AUC、对数似然、校准。
    """
    if keys is None:
        keys = sorted(scores_a)
    n = len(keys)
    if n < 2:
        return 0.0
    total = 0
    for i in range(n):
        for j in range(i + 1, n):
            da = scores_a[keys[i]] - scores_a[keys[j]]
            db = scores_b[keys[i]] - scores_b[keys[j]]
            if da * db > 0:
                total += 1
            elif da * db < 0:
                total -= 1
    return total / (n * (n - 1) / 2)


# =============================================================================
# 第三部分：模拟标注员
# =============================================================================
@dataclass
class Annotator:
    """
    一个模拟标注员。

    【字段】
        name            ：名字（打印用）
        group           ：所属人群，例如 "专家"、"众包"、"简洁派"、"详尽派"
        quality_weight  ：对真实质量的敏感程度（越大越"识货"）。默认 6。
        length_bias     ：长度偏差。> 0 表示喜欢长回答。
        style_bias      ：花哨偏差。> 0 表示喜欢格式、表情、客套。
        taste           ：口味。> 0 喜欢详尽，< 0 喜欢简洁。注意：口味不同**不是**偏差，是合理的差异。
        rating_offset   ：打分时的宽严（只影响 1~10 分的打分，不影响二选一）。> 0 表示打分宽松。
        rating_scale    ：打分时"用不用满量表"（只影响打分）。< 1 表示分数挤在中间，> 1 表示爱打极端分。

    【边界】真实的人还有疲劳、学习、心情、对题目的误解……这里一概没有。每个人的偏差也是固定的。
    """
    name: str
    group: str = "普通"
    quality_weight: float = W_QUALITY
    length_bias: float = 0.0
    style_bias: float = 0.0
    taste: float = 0.0
    rating_offset: float = 0.0
    rating_scale: float = 1.0

    def utility(self, r):
        """
        这个人心里对回答 r 的效用（喜欢的程度），一个实数，没有固定的范围。

        【思路】效用 = 质量项 + 长度项 + 花哨项 + 口味项，四项相加（"线性效用"）。
        【参数】r：Response
        【返回】float。只有**差值**有意义：给所有回答的效用都加 100，这个人的任何选择都不会变
            （讲义第 3.4 节"只有差值可识别"）。
        【边界】r.length 必须 > 0，否则 log 会出错（我们的 8 个回答都满足）。
        """
        return (self.quality_weight * r.quality
                + self.length_bias * math.log(r.length / LENGTH_REF)
                + self.style_bias * r.fancy
                + self.taste * (r.detail - 0.5))


def make_expert(name="专家"):
    """
    创建一位"专家"标注员：只看真实质量，没有偏差，口味中立。

    【返回】Annotator
    【偏好学习联系】真实项目里没有这样一个完美的专家。但在模拟里，专家的判断就是"金标准"，
        用来衡量众包标注和学到的奖励模型偏离了多少（03 号文件里的 gold reward）。
    """
    return Annotator(name=name, group="专家")


def make_crowd(n, rng, length_bias=0.8, style_bias=2.0, taste_sd=1.0,
               rating_offset_sd=0.0, rating_scale_range=(1.0, 1.0)):
    """
    创建 n 个众包标注员，每个人的偏差都在平均值附近随机波动。

    【参数】
        n                  ：人数（正整数）
        rng                ：random.Random 对象
        length_bias        ：长度偏差的**平均值**；每个人在 [0.5×, 1.5×] 平均值之间均匀随机
        style_bias         ：花哨偏差的平均值；同上
        taste_sd           ：口味的标准差（口味 ~ 正态分布，平均值 0）
        rating_offset_sd   ：打分宽严的标准差（只影响打分）
        rating_scale_range ：打分尺度的范围 (最小, 最大)，在其中均匀随机（只影响打分）
    【返回】list[Annotator]
    【边界】偏差设成 0 就得到"只有口味差异、没有系统偏差"的人群。
    【Python 小知识】rng.uniform(a, b)：a~b 之间均匀分布的随机数；rng.gauss(μ, σ)：正态分布的随机数。
        f"众包{i + 1:02d}"：f-字符串，{...} 里的表达式会被替换成它的值，":02d" 表示"整数、宽 2、不足补 0"，
        所以 i = 0 时得到 "众包01"。
    """
    crowd = []
    for i in range(n):
        crowd.append(Annotator(
            name=f"众包{i + 1:02d}",
            group="众包",
            length_bias=length_bias * rng.uniform(0.5, 1.5),
            style_bias=style_bias * rng.uniform(0.5, 1.5),
            taste=rng.gauss(0.0, taste_sd),
            rating_offset=rng.gauss(0.0, rating_offset_sd),
            rating_scale=rng.uniform(*rating_scale_range),
        ))
    return crowd


def prefers(annotator, a, b, rng):
    """
    让标注员在回答 a 和 b 之间二选一。

    【思路】按 Bradley–Terry 模型：P(选 a) = σ(效用a − 效用b)。抽一个 (0,1) 的均匀随机数，
        小于这个概率就选 a。这和"每个回答的感受 = 效用 + Gumbel 噪声，选感受大的那个"完全等价
        （02 号文件演示 2 会用模拟验证），但只需要抽 1 个随机数，更快。
    【参数】annotator：Annotator；a、b：Response；rng：random.Random
    【返回】bool：True 表示选了 a，False 表示选了 b
    【边界】a 和 b 是同一个回答时，概率是 0.5（随机选一个）—— 调用者通常应该避免这样调用。
    """
    p = sigmoid(annotator.utility(a) - annotator.utility(b))
    return rng.random() < p


def perceive(annotator, r, rng):
    """
    标注员"读一遍"回答 r 之后的即时感受 = 效用 + 噪声（噪声是中心化的 Gumbel，平均值为 0）。

    【思路】人每次读同一个回答，感受都会有点不同（心情、注意力、读的顺序……）。
        随机效用模型（第 3 讲）就是把这种波动写成"效用 + 噪声"。
        01 号文件演示 3 用同一次感受，既生成 1~10 分的打分，又生成排序 → 两种数据来自同一份信息，
        比较起来才公平。
    【参数】annotator：Annotator；r：Response；rng：random.Random
    【返回】float
    【边界】减去 EULER_GAMMA 只是让噪声的平均值为 0，方便和打分的中心对齐；
        对"比较大小"没有任何影响（所有回答减去同一个常数）。
    """
    return annotator.utility(r) + sample_gumbel(rng) - EULER_GAMMA


def rating_from_perception(annotator, perceived):
    """
    把一次感受换算成 1~10 的整数打分。

    【思路】打分 = 5.5 + 尺度 × (感受 − 中心) + 宽严偏移，再四舍五入、截断到 [1, 10]。
        这模拟了打分的三个毛病：每个人的"零点"不同（宽严偏移）、"单位"不同（尺度）、
        只能打整数而且有上下限（四舍五入 + 截断会丢信息）。
    【参数】annotator：Annotator；perceived：perceive() 的返回值
    【返回】int，1~10
    【边界】round 用的是 Python 的"银行家舍入"（2.5 → 2，3.5 → 4），对演示没有影响。
    """
    raw = 5.5 + annotator.rating_scale * (perceived - RATING_CENTER) + annotator.rating_offset
    return int(min(10, max(1, round(raw))))


# =============================================================================
# 第四部分：从比较数据里学出分数（Bradley–Terry 的最大似然估计）
# =============================================================================
def fit_bradley_terry(comparisons, keys, l2=1e-3, max_iters=20000, tol=1e-9):
    """
    给定一批"谁胜过谁"的比较结果，学出每个选项的分数（效用的估计）。

    【思路】（第 5 讲会详细推导，这里先当成一个"黑盒"会用就行）
        Bradley–Terry 模型说：P(w 胜过 l) = σ(s_w − s_l)。
        我们要找一组分数 s，让**已经观察到的**比较结果出现的概率最大（"最大似然"）：
            目标 = 平均每条比较的 log σ(s_赢家 − s_输家)  −  (l2/2) × Σ s²
        第二项是"L2 正则化"：如果某个选项从来没输过，不加它的话最优分数是 +∞；加上之后分数有限。
        用"梯度上升"求解：每一步把每个分数往"让目标变大"的方向挪一点，直到几乎不动。
            ∂目标/∂s_i = (1/N) Σ_j [ 胜(i,j)·(1 − p_ij) − 胜(j,i)·p_ij ] − l2·s_i ，  p_ij = σ(s_i − s_j)
        直觉：i 赢了 j，但模型认为 i 赢的概率 p_ij 只有 0.3 → "惊讶" → 把 s_i 往上推，推的幅度 ∝ 0.7。
        这和国际象棋的 Elo 等级分的更新方式是同一个公式（第 5 讲）。
    【参数】
        comparisons：list[(赢家编号, 输家编号)]
        keys       ：全部选项的编号列表（没出现在比较里的选项分数为 0）
        l2         ：正则化强度（≥ 0）。越大，分数越被拉向 0。
        max_iters  ：最多迭代多少步
        tol        ：梯度的最大分量小于它就停止（"已经收敛"）
    【返回】dict {编号: 分数}，分数的平均值为 0
    【边界】
        · 只有分数的**差值**有意义（第 3.4 节）：给所有分数加同一个常数，模型的预测完全不变。
          所以我们把平均值固定为 0。
        · 比较数据太少、或者某些选项之间从来没有直接或间接比较过（比较图不连通）时，
          分数主要由正则化决定，不可信。
        · 步长用"光滑度上界"自动设定（lr = 1/L），保证每一步目标函数都不下降；不需要手动调学习率。
    【Python 小知识】
        [[0] * m for _ in range(m)]：建一个 m×m 的二维列表（列表的列表），全部填 0。
            注意不能写成 [[0] * m] * m —— 那样 m 行是同一个列表对象，改一行等于改了所有行。
        for i, k in enumerate(keys)：同时拿到下标 i 和元素 k。
    """
    m = len(keys)
    idx = {k: i for i, k in enumerate(keys)}
    n = len(comparisons)
    if n == 0:
        return {k: 0.0 for k in keys}

    # wins[i][j] = i 胜过 j 的次数
    wins = [[0] * m for _ in range(m)]
    for w, l in comparisons:
        wins[idx[w]][idx[l]] += 1

    # 光滑度上界 L：目标函数的二阶导数（海森矩阵）最大特征值的上界，
    # 由 Gershgorin 圆盘定理得到：L ≤ max_i (i 参与的比较次数) / (2N) + l2。步长取 1/L。
    appear = [sum(wins[i][j] + wins[j][i] for j in range(m)) for i in range(m)]
    lr = 1.0 / (max(appear) / (2.0 * n) + l2)

    s = [0.0] * m
    for _ in range(max_iters):
        grad = [0.0] * m
        for i in range(m):
            for j in range(i + 1, m):
                nij, nji = wins[i][j], wins[j][i]
                if nij == 0 and nji == 0:
                    continue
                p = sigmoid(s[i] - s[j])
                g = nij * (1.0 - p) - nji * p
                grad[i] += g
                grad[j] -= g
        biggest = 0.0
        for i in range(m):
            grad[i] = grad[i] / n - l2 * s[i]
            biggest = max(biggest, abs(grad[i]))
        if biggest < tol:
            break
        for i in range(m):
            s[i] += lr * grad[i]

    center = mean(s)
    return {k: s[idx[k]] - center for k in keys}


def bt_log_likelihood(comparisons, scores):
    """
    一批比较数据在给定分数下的平均对数似然（越接近 0 越好）。

    【参数】comparisons：list[(赢家, 输家)]；scores：dict {编号: 分数}
    【返回】float（≤ 0）。log(0.5) ≈ −0.693 相当于"每次都五五开地瞎猜"。
    【边界】空列表返回 0。
    """
    if not comparisons:
        return 0.0
    return mean([math.log(sigmoid(scores[w] - scores[l])) for w, l in comparisons])


# =============================================================================
# 第五部分：打印小工具
# =============================================================================
def print_header(title):
    """打印一个带分隔线的标题。"""
    print()
    print("=" * 78)
    print(f"  {title}")
    print("=" * 78)


def pad(text, width, align="left"):
    """
    把字符串补空格到"显示宽度"为 width，让含中文的表格也能对齐。

    【思路】终端里一个汉字占 2 个英文字母的宽度，但 Python 的 f"{s:10s}" 按"字符个数"补空格，
        所以中文表格会歪。这里用 unicodedata.east_asian_width 判断每个字符是"宽字符"（W、F，
        例如汉字和全角标点）还是普通字符，算出真正的显示宽度再补空格。
    【参数】text：字符串；width：目标显示宽度；align："left"（左对齐）或 "right"（右对齐）
    【返回】补好空格的字符串（已经比 width 宽时原样返回）
    【边界】有些字符（例如中文引号“”）在不同终端里宽度不同（"A" 类，ambiguous），这里按 1 处理。
    """
    w = sum(2 if unicodedata.east_asian_width(ch) in ("W", "F") else 1 for ch in text)
    spaces = " " * max(0, width - w)
    return text + spaces if align == "left" else spaces + text


def bar(fraction, width=30):
    """
    用字符画一根横条，例如 bar(0.5, 10) → "█████     "。

    【参数】fraction：0~1 之间的数（超出会被截断）；width：整根条的字符数
    【返回】长度为 width 的字符串
    """
    fraction = min(max(fraction, 0.0), 1.0)
    k = round(fraction * width)
    return "█" * k + " " * (width - k)


# 模块的自我检查：参考策略的概率之和必须是 1。
# 【Python 小知识】assert 条件, "说明"：条件为假时程序立即报错并打印说明。用来捕捉"不该发生"的情况。
assert abs(math.fsum(REFERENCE_POLICY.values()) - 1.0) < 1e-12, "参考策略的概率之和必须为 1"
assert sorted(REFERENCE_POLICY) == KEYS, "参考策略必须覆盖全部 8 个回答"


# =============================================================================
# 直接运行本文件：打印这个玩具世界的概况
# =============================================================================
# 【Python 小知识】if __name__ == "__main__": 下面的代码只在"直接运行这个文件"时执行；
#   被别的文件 import 时不会执行。这是 Python 里区分"脚本"和"模块"的惯用写法。
if __name__ == "__main__":
    print(f"提示词：{PROMPT}\n")
    print(f"  {'编号':4s} {'质量':>5s} {'字数':>5s} {'详细':>5s} {'花哨':>5s}  描述")
    for r in RESPONSES:
        print(f"  {r.key:4s} {r.quality:>5.2f} {r.length:>5d} {r.detail:>5.2f} {r.fancy:>5.2f}  {r.title}")

    rng = random.Random(0)
    expert = make_expert()
    crowd = make_crowd(40, rng)
    avg_crowd = {r.key: mean([a.utility(r) for a in crowd]) for r in RESPONSES}
    print("\n专家 vs 40 个众包标注员（平均）心里的效用，按专家的效用从高到低排列：")
    print(f"  {'编号':4s} {'专家':>6s} {'众包平均':>8s}")
    for r in sorted(RESPONSES, key=lambda r: -expert.utility(r)):
        print(f"  {r.key:4s} {expert.utility(r):>6.2f} {avg_crowd[r.key]:>8.2f}")
    top_crowd = max(KEYS, key=lambda k: avg_crowd[k])
    print(f"\n专家最喜欢 A；众包平均最喜欢 {top_crowd}（{BY_KEY[top_crowd].title}）。")
    print(f"专家选 A 而不选 D 的概率：{sigmoid(expert.utility(BY_KEY['A']) - expert.utility(BY_KEY['D'])):.2f}")
