PPysolate SpineREAD THE CODE · UNDERSTAND THE SYSTEM
课程与章节
CODE READING / 13

PLM:沿 AST 找安全提前点

guest/plm.py

本章目录
配套饭点图解 · 1 节(按需展开)

本章回答:面对一段真实 Python 源码,PLM 怎样决定“哪些具名工具赋值可以先 prepare”,又怎样保证值、错误和控制流仍在原调用处交付?

调用者是 Guest 的 execute:它在 plm 模式下调用 transform(source, request["manifest"]),随后把返回 AST 交给 compile/exec。输入不是一棵预先信任的树,而是源码字符串和 Runner 投影进请求的具名 Manifest;输出是原 AST 或改写后的 AST,以及可选的两个 helper 名。transform 只重排树,不执行工具,也不调用 Go。

这里要区分两道门。AST 侧先用 Manifest 中的名字识别工具,并用 allow_early_read 形成 Guest 侧的 early_names,决定哪些调用值得改写;Go 侧的 runState.prepare 随后还会独立检查本轮是否启用、Go 登记板是否有该工具、Host 是否允许提前读取以及 pending 上限。前者决定树的形状,后者决定一个实际 prepare 请求是否真的启动 worker;不能把 AST 改写当成 Host 已批准执行的证明。

主线采用这样的输入:两个相邻的 lookup 赋值,参数分别是常量或 inputs[...],最后在原位置使用两个结果。先做整段语法筛选,再在局部工具组中插入 prepare、把原调用换成 resolve。source 标记覆盖 guest/plm.py 的全部 133 行;正文按职责块解释,不按物理行号逐条罗列。

1. 入口先解析源码,并建立名字集合

这一块准备后面所有语法判断需要的上下文。名字集合服务于生成 helper 时的避名;data_names 只是语法白名单中的“可作为数据来源的名字”,不是对运行时对象类型的完整证明。

guest/plm.py · L1–L16 · 打开完整源码

"""Small whole-program pass. Unsupported programs keep their original AST.

Only adjacent tool assignments are reordered: prepare after argument definitions,
resolve at the original statement. Other statements and control regions are barriers.
"""
import ast
import copy


def transform(source, manifest):
    tree = ast.parse(source, filename="<spine>")
    tool_names = {spec["name"] for spec in manifest}
    early_names = {spec["name"] for spec in manifest if spec["allow_early_read"]}
    names = {n.id for n in ast.walk(tree) if isinstance(n, ast.Name)}
    names |= {n.name for n in ast.walk(tree) if isinstance(n, ast.ExceptHandler) and n.name}
    data_names = {"inputs"} | {n.id for n in ast.walk(tree) if isinstance(n, ast.Name) and isinstance(n.ctx, ast.Store)}

模块说明先给出边界:不支持的程序保留原 AST;只处理相邻工具赋值;prepare 可移动到参数定义之后,resolve 留在原语句。transform 现在接收两个输入:源码和具名 manifest。

tool_names 收集 Manifest 中所有工具名,供 tool_call 识别直接的具名调用;early_names 只收集 allow_early_read 为真的名字,供 early_call 决定是否真的进入 prepare/resolve 改写。Manifest 来自当前 Runner,而不是旧的 tools, "lookup" 位置参数协议。

ast.walk 遍历整棵树。普通 ast.Name 和异常处理器的绑定名都加入 names,防止生成的 _spine_prepare、_spine_resolve 或 _spine_future 覆盖用户名字。data_names 固定允许 inputs,并加入所有语法上处于 Store 上下文的变量;它因此是小型形状检查的一部分,不是按执行顺序建立的全程序数据流图。

2. data:定义可以出现在受限参数中的数据形状

AST pass 不读取这些节点的运行时值,只问节点是否落在一个不需要任意用户调用的小语言里。这个判断越窄,越少把属性、调用或用户对象行为搬到 prepare 阶段。

guest/plm.py · L18–L37 · 打开完整源码

    def data(node):
        if isinstance(node, ast.Constant):
            return isinstance(node.value, (str, int, float, bool, type(None)))
        if isinstance(node, ast.Name):
            return node.id in data_names
        if isinstance(node, (ast.List, ast.Tuple)):
            return all(data(n) for n in node.elts)
        if isinstance(node, ast.Dict):
            return all(k is not None and data(k) and data(v) for k, v in zip(node.keys, node.values))
        if isinstance(node, ast.Subscript):
            return data(node.value) and data(node.slice)
        if isinstance(node, ast.BinOp):
            return data(node.left) and data(node.right)
        if isinstance(node, ast.UnaryOp):
            return data(node.operand)
        if isinstance(node, ast.BoolOp):
            return all(data(n) for n in node.values)
        if isinstance(node, ast.Compare):
            return data(node.left) and all(data(n) for n in node.comparators)
        return False

Constant 只接受字符串、整数、浮点数、布尔值和 None。名字必须来自 data_names;列表、元组和字典则要求所有子节点递归通过检查,字典也拒绝 **mapping 这种动态展开。

下标要求对象和下标都满足 data;二元、 一元、布尔运算和比较也逐层检查操作数。代码不在 transform 阶段计算这些表达式,所以短路、异常和最终 Python 运算规则仍由 exec 中的 Python 保留。

没有专门列出的节点一律返回 False。属性访问、函数调用、推导式、条件表达式、格式化字符串以及用户自定义对象都不会凭外观获得提前资格。这里证明的是语法形状,不是对象没有副作用,也不是 Host 快照承诺。

3. argument、具名 tool_call 与 early_call

工具参数需要比普通 data 更窄的规则,因为它们可能在原调用之前被 thunk 读取并编码成 Go 请求。

guest/plm.py · L39–L52 · 打开完整源码

    def argument(node):
        if isinstance(node, (ast.Constant, ast.Name)):
            return data(node)
        return (isinstance(node, ast.Subscript) and argument(node.value)
                and isinstance(node.slice, ast.Constant) and isinstance(node.slice.value, (str, int)))

    def tool_call(node):
        return (isinstance(node, ast.Call) and isinstance(node.func, ast.Name)
                and node.func.id in tool_names and not node.args
                and all(k.arg is not None and argument(k.value) for k in node.keywords))

    def early_call(node):
        return tool_call(node) and node.func.id in early_names

argument 接受已经通过 data 的常量或名字;另一种形状是下标链,但每个下标必须是字符串或整数常量,基对象还要递归满足 argument。因此 inputs["item"] 可以进入 prepare,而动态 key、属性读取和函数调用不行。

tool_call 只识别 Manifest 中出现的直接函数名,要求没有位置参数,并要求每个关键字都有名字、值都通过 argument。它不接受别名调用、属性形式、动态 callable 或 **kwargs。这个函数只判断调用形状,不查 Go 的 map,也不执行工具。

early_call 再加一道名字筛选:只有 tool_call 成立且函数名在 early_names 中,才是会被 rewrite 收集的提前调用。当前实现确实把 Runner 传来的 Manifest 投影用于 AST 选择;但这不取消 Go 侧的运行时检查。即使树里有 prepare,实际 runState.prepare 仍可能返回 0;反过来,没有 AST 改写的普通工具调用也会按普通路径执行。

4. block_ok:为什么先检查整段,再决定是否改写

只检查某一行会留下一个问题:前面的任意 Python 语句可能改变后面参数的含义,例如让 inputs 或某个变量指向有自定义行为的对象。因此当前 pass 采用全段白名单;有一个区域不符合,就整段回退,不做部分改写。

guest/plm.py · L53–L73 · 打开完整源码

    def block_ok(body):
        for s in body:
            if isinstance(s, ast.Assign):
                if (len(s.targets) != 1 or not isinstance(s.targets[0], ast.Name)
                        or s.targets[0].id == "inputs" or s.targets[0].id in tool_names
                        or not (tool_call(s.value) or data(s.value))):
                    return False
            elif isinstance(s, ast.If):
                if not (data(s.test) and block_ok(s.body) and block_ok(s.orelse)):
                    return False
            elif isinstance(s, ast.Try):
                if not all(block_ok(b) for b in (s.body, s.orelse, s.finalbody)):
                    return False
                for h in s.handlers:
                    if h.name == "inputs" or h.name in tool_names or not block_ok(h.body):
                        return False
                    if h.type is not None and not isinstance(h.type, ast.Name):
                        return False
            elif not (isinstance(s, ast.Pass) or isinstance(s, ast.Expr) and isinstance(s.value, ast.Constant)):
                return False
        return True

普通赋值必须只有一个普通名字目标,不能写入 inputs,也不能把 Manifest 中的工具名重绑。右侧只能是任意已识别的 tool_call,或通过 data 的表达式。注意:非 early 工具可以通过这道整段检查,却不会被 rewrite 搬动;它会成为局部扫描中的屏障。

if 要求条件是受限数据,并且两个分支都递归通过;这只是检查两条可能路径,不代表两边都会执行。try 的 body、else、finally 以及每个 handler body 都要通过;异常绑定名不能覆盖 inputs 或工具名,异常类型若存在也只能是普通名字。

除了这些结构,只有 pass 和常量表达式语句允许。循环、函数定义、import、任意调用、属性操作、对象变异和其他语句都让 block_ok 返回 False。

失败时直接返回刚解析出的 tree, None。因此不支持输入仍然可以由普通 Python 运行;它只是不用这次优化,不会留下“半棵树已优化、半棵树未优化”的混合状态。只有整段通过,才会继续生成 helper 名并扫描工具组。

5. 生成不冲突的 helper,并按相邻组扫描

这一步从“程序是否落在小语言内”进入“哪些具体语句要重排”。扫描边界很窄:只跨连续的 early 工具赋值,不跨普通赋值、非 early 工具、分支、try 或其他语句。

guest/plm.py · L75–L101 · 打开完整源码

    # No arbitrary calls, alias mutation, loops or user objects in an optimized program.
    if not block_ok(tree.body):
        return tree, None

    def fresh(base):
        while base in names:
            base += "_"
        names.add(base)
        return base

    prepare, resolve = fresh("_spine_prepare"), fresh("_spine_resolve")
    changed = False

    def rewrite(body):
        nonlocal changed
        result, i = [], 0
        while i < len(body):
            s = body[i]
            if isinstance(s, ast.Assign) and early_call(s.value):
                group = []
                while i < len(body) and isinstance(body[i], ast.Assign) and early_call(body[i].value):
                    group.append(body[i])
                    i += 1
                before = [[] for _ in group]
                definitions = {}
                replacements = []
                for index, statement in enumerate(group):

fresh 在已有名字集合中逐步追加下划线,再把选中的名字放回集合。它保护的是 Python 变量绑定,不是安全边界。即使用户已经用了 _spine_prepare,新 helper 也会换名。

rewrite(body) 用游标走过一段语句。遇到一个 early_call 赋值后,内层循环收集从当前位置开始的连续工具组;普通语句会停止收集。before 为每个组内位置保存要插入的 prepare,definitions 记录组内变量最近一次工具赋值的位置,replacements 保存改写后的原语句。

对组内每个调用,loaded 收集关键字参数表达式中的名字。start 是这些名字在当前组内最近定义位置之后的索引;没有已知的组内依赖时为 0。于是两个独立读取都可在组开头准备;如果第二个调用的参数读取第一个调用赋出的 a,第二个 prepare 至少要等第一个原赋值完成。这里只维护当前相邻组的小字典,不构造全程序依赖图。

6. 插入 thunk,但保留原赋值和原调用位置

这块是语义交接的核心:生成的 prepare 只负责把参数读取包进一个可延迟的小函数;原调用不被搬到组开头。

guest/plm.py · L102–L121 · 打开完整源码

                    call = statement.value
                    loaded = {n.id for k in call.keywords for n in ast.walk(k.value) if isinstance(n, ast.Name)}
                    start = 1 + max((definitions.get(n, -1) for n in loaded), default=-1)
                    slot = fresh("_spine_future")
                    args = ast.Dict(keys=[ast.Constant(k.arg) for k in call.keywords], values=copy.deepcopy([k.value for k in call.keywords]))
                    tool_name = ast.Constant(call.func.id)
                    thunk = ast.Lambda(args=ast.arguments(posonlyargs=[], args=[], kwonlyargs=[], kw_defaults=[], defaults=[]),
                                       body=ast.Tuple(elts=[tool_name, args], ctx=ast.Load()))
                    early = ast.Assign(targets=[ast.Name(slot, ast.Store())],
                                       value=ast.Call(ast.Name(prepare, ast.Load()), [thunk], []))
                    before[start].append(ast.copy_location(early, statement))
                    statement.value = ast.copy_location(ast.Call(ast.Name(resolve, ast.Load()),
                        [ast.Name(slot, ast.Load()), ast.Constant(call.func.id)], call.keywords), call)
                    replacements.append(statement)
                    definitions[statement.targets[0].id] = index
                for early, statement in zip(before, replacements):
                    result.extend(early)
                    result.append(statement)
                changed = True
                continue

每个调用分配一个不冲突的 Future 槽位名。关键字参数表达式的 AST 节点被深复制到新建的字典节点;此时还没有求出参数值。工具名和参数字典再组成无参数 lambda 的返回值。这个 lambda 在 Guest 的 prepare_call 中立即执行,用于构造 JSON 请求;Go worker 只执行收到的 Host 请求。

随后生成一条类似“slot = prepare_call(thunk)”的赋值,按 before[start] 插到相应位置。若参数没有组内依赖,它在工具组开头;若依赖第一个工具的结果,它会在第一个原语句之后。插入的位置使用原语句的 location,便于编译诊断定位。

原 statement.value 被换成 resolve helper 调用:第一个参数是 slot,第二个是固定工具名,关键字参数仍保留原 AST。赋值目标、赋值语句和语句在 block 中的位置不动;因此 Python 仍在原位置求值当前参数、领取 JSON response,并执行变量绑定或异常跳转。

definitions 在处理完当前语句后记录其目标名和索引,供组内后续调用计算依赖位置。最后按每个组内语句把对应的 prepare 列表和原语句依次追加到 result,并标记 changed。

7. 控制流内部递归,最后交给 compile/exec

顶层组的规则不能把未选分支中的工作拉到 if 之前,也不能把异常区域外的工作提前。因此递归只进入已经由 block_ok 接受的控制结构内部。

guest/plm.py · L122–L133 · 打开完整源码

            if isinstance(s, ast.If):
                s.body, s.orelse = rewrite(s.body), rewrite(s.orelse)
            elif isinstance(s, ast.Try):
                s.body, s.orelse, s.finalbody = rewrite(s.body), rewrite(s.orelse), rewrite(s.finalbody)
                for h in s.handlers:
                    h.body = rewrite(h.body)
            result.append(s)
            i += 1
        return result

    tree.body = rewrite(tree.body)
    return ast.fix_missing_locations(tree), (prepare, resolve) if changed else None

遇到 if,分别重写 body 和 orelse,If 节点本身保留;遇到 try,分别重写 body、orelse、finalbody 和每个 handler body。这样 Python 运行时仍决定走哪条分支,prepare 只会在实际进入的分支内部发生。

其他允许语句原样追加。顶层 tree.body 完成重写后,ast.fix_missing_locations 为新建节点补齐编译所需的位置元数据。若至少改写过一个调用,返回 (prepare_name, resolve_name);否则返回 None,调用者不会把 helper 放入 scope。

bootstrap.execute 随后把这两个名字绑定到 prepare_call、resolve_call,再执行编译后的树。prepare_call 捕获参数读取/JSON 编码异常并返回 0;原语句中的 resolve 参数仍会重新求值,所以缺失键等错误仍在原 Python 调用处发生。Go 侧收到成功 handle 后,值或工具错误也要等 resolve 才回到 Python。

8. AST 选择与 Host 许可如何合在一次运行里

可以把一次 RunPLM 看成以下顺序,而不要把任意一层的结果冒充另一层的证明:

  1. Runner 用当前 Manifest 同时保留 Go 的 ToolSpec 表,并向 Guest 发送具名工具及 allow_early_read 标志。
  2. transform 先用 block_ok 做整段语法门控,再用 early_call 选择相邻的 early 工具赋值进行改写。
  3. Guest 执行插入的 prepare_call,只把简单参数编码成请求;没有成功 handle 时保留 0/普通回退。
  4. Go runState.prepare 再查本轮启用状态、Go Manifest、AllowEarlyRead 和 pending 数,成功才登记 Future 并启动 worker。
  5. Python 在原赋值位置调用 resolve_call,Go 按 handle 和当前请求匹配后返回已编码值/错误,bootstrap 决定返回值或抛 Python 异常。

因此 AST 的职责是控制“可以安全地表达成 prepare/resolve 形状”的源码范围,Go 登记板的职责是控制“这次实际请求是否有获准的 Host 实现”。两者都保守失败:AST 不通过就执行原 AST,Host 不通过就让 handle 为 0 并在原路径调用。

下一章读 plm-tests.md,看真实 Wasm 如何证明启动重叠、原位错误和 Future 边界;再读 plm-python-tests.md,看替身如何隔离验证这棵 AST。Go 状态机的完整交付路径见 futures.md,JSON bridge 见 bridge.md。

读完本章应能回答:

  1. 为什么 block_ok 失败时必须返回原 AST,而不能只跳过一个危险语句?
  2. 为什么依赖结果的 prepare 要移动,而依赖调用的 resolve 不移动?
  3. 为什么 AST 中出现 helper,不等于 Go worker 一定已经获准启动?