"""
=============================================================================
第 2 讲 · 代码 5：网格世界——奖励与折扣如何塑造最优策略
=============================================================================

对应讲义：lectures/lecture02_mdp_planning.md 第 10 节

-----------------------------------------------------------------------------
【这个文件要解决什么问题？】
-----------------------------------------------------------------------------
火星车只有一条直线，最优策略无非"向左"或"向右"。这个文件换一个二维的经典例子——
Russell & Norvig《人工智能：一种现代方法》中的 4×3 网格世界：

         第0列  第1列  第2列  第3列
    第0行  .      .      .     +1      ← +1：到达即结束，奖励 +1（好出口）
    第1行  .     墙      .     -1      ← -1：到达即结束，奖励 -1（坏出口）
    第2行  起点   .      .      .

    - 动作：上、右、下、左
    - 动作有噪声：80% 按意图走，10% 向意图的左侧偏，10% 向右侧偏（比如想向上，可能滑到左或右）
    - 撞墙或出界：原地不动
    - 每在普通格子停留一步，获得"生存奖励" step_reward（通常是个小负数，代表时间成本）

我们用价值迭代求最优策略，然后**只改 step_reward 或 γ**，观察最优策略如何剧烈变化。
这是本讲最重要的实践教训之一：**奖励和折扣定义了"最优"的含义。**

-----------------------------------------------------------------------------
【新概念：终止状态与吸收状态】
-----------------------------------------------------------------------------
火星车永远走不完；这里的 +1 / -1 格子是"终止状态"，到达后回合结束。
在 MDP 的矩阵框架里怎么表示"结束"？一个标准技巧是增加一个额外的**吸收状态 END**：
    - 在 +1 / -1 格子执行任何动作：获得 ±1，然后以概率 1 进入 END
    - 在 END 执行任何动作：奖励 0，永远留在 END
这样"回合结束"就被表示成"进入一个再也不产生奖励的状态"，所有算法都不需要改。

有了终止状态，只要策略保证最终会到达出口，**γ = 1 也是可以的**（回报是有限项之和）。
Russell & Norvig 的原书例子就用 γ = 1。

-----------------------------------------------------------------------------
【运行方式】
-----------------------------------------------------------------------------
    python 05_gridworld.py
"""

import numpy as np

# =============================================================================
# 第 1 部分：网格布局与动作
# =============================================================================
# 【思路】用字符串画地图，比手写坐标直观得多，也方便你自己改地图做实验。
#   '.' 普通格子    'S' 起点（也是普通格子）    '#' 墙（不是状态）
#   '+' 好出口（+1） '-' 坏出口（-1）
LAYOUT = [
    "...+",
    ".#.-",
    "S...",
]

UP, RIGHT, DOWN, LEFT = 0, 1, 2, 3
ACTION_ARROWS = ["↑", "→", "↓", "←"]
# 每个动作对应的 (行变化, 列变化)。注意"行号向下增大"，所以向上是 行 - 1。
ACTION_DELTAS = [(-1, 0), (0, 1), (1, 0), (0, -1)]


def move(cell, action, layout):
    """
    计算从格子 cell 朝 action 方向**确定性地**走一步后到达的格子（不考虑噪声）。

    【参数】
        cell   : (行, 列) 元组。
        action : int，0~3。
        layout : 地图（字符串列表）。

    【返回】
        (行, 列) 元组。如果撞墙或出界，返回原格子。

    【边界】
        - 出界判断：行必须在 [0, 行数)，列必须在 [0, 列数)。
        - 撞墙判断：目标格子字符是 '#'。
    """
    r, c = cell
    dr, dc = ACTION_DELTAS[action]
    nr, nc = r + dr, c + dc
    n_rows, n_cols = len(layout), len(layout[0])
    if not (0 <= nr < n_rows and 0 <= nc < n_cols):
        return cell                     # 出界 → 原地不动
    if layout[nr][nc] == "#":
        return cell                     # 撞墙 → 原地不动
    return (nr, nc)


# =============================================================================
# 第 2 部分：把网格世界变成 MDP 的 (P, R)
# =============================================================================
def build_gridworld(layout=LAYOUT, step_reward=-0.04, noise=0.2):
    """
    把网格地图转换成标准的 MDP 数组 (P, R)，并返回一些辅助信息。

    【思路】
        1. 给每个非墙格子分配一个状态编号；最后再加一个吸收状态 END。
        2. 对每个 (状态, 动作)：
           - 出口格子：任何动作 → 以概率 1 进入 END，奖励 ±1。
           - END：任何动作 → 留在 END，奖励 0。
           - 普通格子：奖励 step_reward；
             以 1-noise 的概率按意图走，以 noise/2 的概率各向两个垂直方向偏。

    【参数】
        layout      : 地图。
        step_reward : float，在普通格子每走一步的奖励。负数表示"时间成本"。
        noise       : float，[0, 1]，动作偏离意图的总概率。0 表示确定性世界。

    【返回】
        (P, R, info)：
        - P    : (N, 4, N)，其中 N = 非墙格子数 + 1（END）
        - R    : (N, 4)
        - info : dict，包含
                 "cells"    : list，cells[s] = 状态 s 对应的 (行, 列)；END 对应 None
                 "index"    : dict，index[(行, 列)] = 状态编号
                 "end"      : END 的状态编号
                 "start"    : 起点的状态编号
                 "terminal" : dict，{出口状态编号: 奖励}

    【边界】
        - noise 不在 [0, 1] → ValueError。
        - 地图各行长度不一致 → ValueError。
        - "垂直方向"的计算：动作按 上0 右1 下2 左3 顺时针编号，
          所以 (a+1)%4 是顺时针转 90°，(a+3)%4 是逆时针转 90°。
          【Python 小知识】% 是取余数：(3+1)%4 = 0，于是"左"顺时针转一下就是"上"，首尾相接。
    """
    if not (0.0 <= noise <= 1.0):
        raise ValueError(f"noise 必须在 [0, 1] 内，但收到了 {noise}")
    if len({len(row) for row in layout}) != 1:
        # {…} 是集合推导式：收集所有行的长度，去重后如果不止一种长度，说明地图不是矩形
        raise ValueError("地图每一行的长度必须相同")

    # ---- 第 1 步：给格子编号 ----
    cells, index, terminal = [], {}, {}
    start = None
    for r, row in enumerate(layout):
        for c, ch in enumerate(row):
            if ch == "#":
                continue
            s = len(cells)
            cells.append((r, c))
            index[(r, c)] = s
            if ch == "+":
                terminal[s] = +1.0
            elif ch == "-":
                terminal[s] = -1.0
            elif ch == "S":
                start = s
    end = len(cells)
    cells.append(None)          # END 不对应任何格子
    n = len(cells)

    # ---- 第 2 步：填 P 和 R ----
    P = np.zeros((n, 4, n))
    R = np.zeros((n, 4))
    for s in range(n):
        for a in range(4):
            if s == end:
                P[s, a, end] = 1.0                 # 吸收状态：永远留在 END，奖励 0
            elif s in terminal:
                P[s, a, end] = 1.0                 # 出口：拿到 ±1 后进入 END
                R[s, a] = terminal[s]
            else:
                R[s, a] = step_reward
                # 三种可能的实际方向及其概率：意图方向、顺时针偏、逆时针偏
                outcomes = [(a, 1.0 - noise), ((a + 1) % 4, noise / 2), ((a + 3) % 4, noise / 2)]
                for actual, prob in outcomes:
                    s_next = index[move(cells[s], actual, layout)]
                    P[s, a, s_next] += prob        # 用 +=：不同方向可能撞墙后落到同一格

    info = {"cells": cells, "index": index, "end": end, "start": start, "terminal": terminal}
    return P, R, info


# =============================================================================
# 第 3 部分：价值迭代（允许 γ = 1）
# =============================================================================
def value_iteration(P, R, gamma, tol=1e-8, max_iters=10_000):
    """
    价值迭代。与 04 文件的区别：允许 γ = 1（因为有终止状态），停止准则改为简单的"变化量 < tol"。

    【γ = 1 什么时候能收敛？】
        当"最优策略会以概率 1 到达终止状态"，而"永远不终止"的策略代价足够大（比如 step_reward < 0）时，
        γ = 1 的价值迭代会收敛。这类问题叫"随机最短路径问题"。
        反过来，如果 step_reward > 0，永远在场地里闲逛能拿无穷多奖励，价值会无限增长，
        不会收敛——函数会返回 converged=False（演示 4 会看到）。

    【参数】
        gamma     : float，[0, 1]。
        tol       : float，相邻两次迭代的最大变化量小于它时停止。
        max_iters : int，最多迭代次数。

    【返回】
        dict：{"V", "Q", "policy", "n_iters", "converged", "history"}
        history[k] 是第 k 次迭代后的 V（history[0] 是全 0 的初始值）。

    【边界】
        - γ = 1 时，变化量 < tol 并**不能**保证与 V* 的误差很小（没有 ε-最优保证），
          这里只作为一个实用的停止条件。
    """
    if not (0.0 <= gamma <= 1.0):
        raise ValueError(f"gamma 必须在 [0, 1] 内，但收到了 {gamma}")
    n = P.shape[0]
    V = np.zeros(n)
    history = [V.copy()]
    converged = False
    for k in range(1, max_iters + 1):
        Q = R + gamma * np.einsum("sat,t->sa", P, V)
        V_new = Q.max(axis=1)
        delta = np.abs(V_new - V).max()
        V = V_new
        history.append(V.copy())
        if delta < tol:
            converged = True
            break
    Q = R + gamma * np.einsum("sat,t->sa", P, V)
    policy = np.array([int(np.flatnonzero(q >= q.max() - 1e-9)[0]) for q in Q])
    return {"V": V, "Q": Q, "policy": policy, "n_iters": k, "converged": converged, "history": history}


# =============================================================================
# 第 4 部分：把价值和策略画成网格
# =============================================================================
def render_values(V, info, layout=LAYOUT):
    """
    把价值函数画成网格。墙显示为 #####，每个格子显示一个保留两位小数的数。

    【返回】多行字符串。
    """
    lines = []
    for r, row in enumerate(layout):
        parts = []
        for c, ch in enumerate(row):
            if ch == "#":
                parts.append("  ####")
            else:
                parts.append(f"{V[info['index'][(r, c)]]:6.2f}")
        lines.append("    " + "  ".join(parts))
    return "\n".join(lines)


def render_policy(policy, info, layout=LAYOUT):
    """
    把策略画成箭头网格。墙显示为 #，出口显示为 +1 / -1。

    【返回】多行字符串。
    """
    lines = []
    for r, row in enumerate(layout):
        parts = []
        for c, ch in enumerate(row):
            if ch == "#":
                parts.append(" #")
            elif ch == "+":
                parts.append("+1")
            elif ch == "-":
                parts.append("-1")
            else:
                parts.append(" " + ACTION_ARROWS[policy[info["index"][(r, c)]]])
        lines.append("    " + "  ".join(parts))
    return "\n".join(lines)


def side_by_side(blocks, titles, gap=6):
    """
    把几个多行字符串块左右并排打印，方便对比不同设置下的策略。

    【思路】把每个块按行拆开，第 i 行拼在一起。标题单独一行。
    【边界】各块行数不同时，短的块用空字符串补齐。
    """
    split = [b.split("\n") for b in blocks]
    width = max(len(line) for b in split for line in b)
    height = max(len(b) for b in split)
    out = ["".join(t.ljust(width + gap) for t in titles)]
    for i in range(height):
        out.append("".join((b[i] if i < len(b) else "").ljust(width + gap) for b in split))
    return "\n".join(out)


# =============================================================================
# 第 5 部分：演示
# =============================================================================
def main():
    # ---------------------------------------------------------------------
    print("=" * 78)
    print("演示 1：网格世界与它的 MDP 表示")
    print("=" * 78)
    for row in LAYOUT:
        print("    " + "  ".join(row))
    P, R, info = build_gridworld(step_reward=-0.04, noise=0.2)
    n = P.shape[0]
    print(f"\n  非墙格子 {n - 1} 个 + 吸收状态 END 1 个 = {n} 个状态；4 个动作")
    print(f"  P 的形状 {P.shape}，每个 (状态, 动作) 的概率和都为 1？{np.allclose(P.sum(axis=2), 1.0)}")
    s_start = info["start"]
    print(f"  例：在起点 {info['cells'][s_start]} 向上走的结果分布：")
    for s_next in np.flatnonzero(P[s_start, UP]):
        print(f"      到达 {info['cells'][s_next]} 的概率 {P[s_start, UP, s_next]:.1f}")
    print("      （80% 向上成功；10% 偏向右；10% 偏向左但撞到边界，原地不动）")

    # ---------------------------------------------------------------------
    print("\n" + "=" * 78)
    print("演示 2：价值迭代——看价值如何从出口\"扩散\"开来（step_reward=-0.04，γ=1）")
    print("=" * 78)
    res = value_iteration(P, R, gamma=1.0)
    for k in [1, 2, 3, 4, 6]:
        print(f"\n  第 {k} 次迭代后：")
        print(render_values(res["history"][k], info))
    print(f"\n  收敛后（共 {res['n_iters']} 次迭代）：")
    print(render_values(res["V"], info))
    print("\n  最优策略：")
    print(render_policy(res["policy"], info))
    print("\n  观察：")
    print("  - 第 1 次迭代后只有出口格子有 ±1，其他格子都是 -0.04（走一步的成本）。")
    print("  - 之后每次迭代，出口的\"好消息\"和\"坏消息\"向外多传一格。")
    print("  - 看下排第三格 (2,2)：它可以 ↑ 走近路（经过 -1 左边的 (1,2)，再到 +1），")
    print("    但最优动作是 ←，宁可绕左边一大圈的远路！原因是噪声：在 (1,2) 向上走时，")
    print("    有 10% 的概率滑向右边、直接掉进 -1。走左边的路则完全不会碰到 -1。")
    print("  - -1 左边的 (1,2) 如果已经身处险地，最优动作是 ↑（尽快离开），")
    print("    代价是承担那 10% 的风险；→ 会 80% 直接掉进 -1。")

    # ---------------------------------------------------------------------
    print("\n" + "=" * 78)
    print("演示 3：只改\"生存奖励\"，最优策略大变样（γ = 1）")
    print("=" * 78)
    settings = [-0.01, -0.04, -0.4, -2.0]
    blocks, titles = [], []
    for r_step in settings:
        P_, R_, info_ = build_gridworld(step_reward=r_step, noise=0.2)
        res_ = value_iteration(P_, R_, gamma=1.0)
        blocks.append(render_policy(res_["policy"], info_))
        titles.append(f"  step={r_step}")
    print(side_by_side(blocks, titles))
    print("\n  解读：")
    print("  - step = -0.01（时间几乎不值钱）：极度谨慎。(1,2) 选择 ←，宁可朝墙撞也不冒险靠近 -1；")
    print("    因为撞墙原地不动几乎没有代价，慢慢来总比掉进 -1 强。")
    print("  - step = -0.04：标准策略，谨慎但不过分。")
    print("  - step = -0.4 （时间很贵）：开始抄近路，(2,1)、(2,2) 改为 → 再 ↑，冒险经过 -1 旁边。")
    print("  - step = -2.0 （活着就是受罪）：连 -1 都比继续待着好，(1,2) 和 (2,3) 直接冲进 -1 结束痛苦！")
    print("  → 我们没有改任何\"规则\"，只改了一个数字。奖励设计决定了智能体会学成什么样。")

    # ---------------------------------------------------------------------
    print("\n" + "=" * 78)
    print("演示 4：正的生存奖励与 γ——智能体\"不想结束游戏\"")
    print("=" * 78)
    P_pos, R_pos, info_pos = build_gridworld(step_reward=+0.1, noise=0.2)
    res_div = value_iteration(P_pos, R_pos, gamma=1.0, max_iters=500)
    print(f"  step = +0.1，γ = 1   ：迭代 {res_div['n_iters']} 次后 converged = {res_div['converged']}，"
          f"起点价值已涨到 {res_div['V'][info_pos['start']]:.1f}（无限增长）")
    res_pos = value_iteration(P_pos, R_pos, gamma=0.9)
    print(f"  step = +0.1，γ = 0.9 ：迭代 {res_pos['n_iters']} 次收敛，策略为：")
    print(render_policy(res_pos["policy"], info_pos))
    print("  → 待在场地里每步 +0.1，永远不离开的价值是 0.1/(1-0.9) = 1，")
    print("    跟 +1 出口差不多、又比 -1 好得多，所以智能体会刻意远离出口，")
    print("    尤其在 -1 附近会朝墙走来避免滑进去。这是一种\"奖励投机\"：")
    print("    设计者想让它到 +1，它却发现\"一直不结束\"更划算。")

    # ---------------------------------------------------------------------
    print("\n" + "=" * 78)
    print("演示 5：噪声的影响（step_reward=-0.04，γ=1）")
    print("=" * 78)
    blocks, titles = [], []
    for nz in [0.0, 0.2, 0.5]:
        P_, R_, info_ = build_gridworld(step_reward=-0.04, noise=nz)
        res_ = value_iteration(P_, R_, gamma=1.0)
        blocks.append(render_policy(res_["policy"], info_))
        titles.append(f"  noise={nz}  V(起点)={res_['V'][info_['start']]:.2f}")
    print(side_by_side(blocks, titles, gap=4))
    print("\n  解读：")
    print("  - noise=0（确定性世界）：走在 -1 旁边毫无风险，所以 (2,1)、(2,2) 走 → 再 ↑ 的近路。")
    print("  - noise=0.2：绕远路避开 -1（演示 2 的标准策略）。")
    print("  - noise=0.5：更加保守。连 +1 左边的 (0,2) 都不直接向右了——向右有 25% 会滑到下面的 (1,2)，")
    print("    而改为向上（撞顶原地不动）仍有 25% 滑进 +1，又不会靠近 -1。")
    print("  - 噪声越大，世界越难控制，起点的价值越低。")

    # ---------------------------------------------------------------------
    print("\n" + "=" * 78)
    print("演示 6：边界情况")
    print("=" * 78)
    try:
        build_gridworld(noise=1.5)
    except ValueError as e:
        print(f"  [noise 越界]     ValueError: {e}")
    try:
        build_gridworld(layout=["...", ".."])
    except ValueError as e:
        print(f"  [地图不规则]     ValueError: {e}")
    print(f"  [END 的价值]     V(END) = {res['V'][info['end']]:.1f}（吸收状态永远没有奖励）")
    print(f"  [出口的价值]     V(+1) = {res['V'][info['index'][(0, 3)]]:.1f}，"
          f"V(-1) = {res['V'][info['index'][(1, 3)]]:.1f}（拿到奖励后立即结束）")


if __name__ == "__main__":
    main()
