PPysolate SpineREAD THE CODE · UNDERSTAND THE SYSTEM
课程与章节
DEEP DIVE / 03

PLM:完整 AST 的改写过程

依赖、控制流、参数错误和结果交付。

本章目录

本文追踪 Spine 190ea2e 中 guest/plm.py 的实际变换,围绕一次调用回答:

  1. 什么样的完整 Python 程序会被准入;
  2. 一个 tool(...) 赋值如何在 AST 中变成 prepare/resolve;
  3. 组内依赖如何决定 prepare 的插入位置;
  4. if、try/except、循环和普通语句如何约束控制流;
  5. 参数异常、工具异常和 Future 异常分别在哪里发生;
  6. AST 改写之后,Guest、C ABI 和 Go Future 如何把结果交回原调用点。

0. 先看变换的三步

这个 pass 先检查整段程序,再处理各块中相邻的工具赋值,最后把 resolve 保留在原来的调用语句上:

源码字符串
  │
  ├─ ast.parse
  ├─ 收集名字与数据名字
  ├─ block_ok(整个 Module.body)
  │     ├─ False → 原 tree, None
  │     └─ True
  │          ├─ 找相邻的工具赋值组
  │          ├─ 为每个调用生成一个 Future 槽位
  │          ├─ 将 prepare 插入到依赖允许的组内位置
  │          └─ 将原 tool(...) 替换为 resolve(handle, 原参数)
  └─ ast.fix_missing_locations → 新 tree, (prepare_name, resolve_name)

以两个无依赖调用为例,实际生成代码的形状是:

# 原始源码(真实 case 的目标名是 a、b)
a = tool("lookup", key="book")
b = tool("lookup", key="shipping")
result = a + b
_spine_future = _spine_prepare(lambda: ("lookup", {"key": "book"}))
_spine_future_ = _spine_prepare(lambda: ("lookup", {"key": "shipping"}))
a = _spine_resolve(_spine_future, "lookup", key="book")
b = _spine_resolve(_spine_future_, "lookup", key="shipping")
result = a + b

两个工具的启动可以提前,但 a = ...、b = ... 仍按原语句顺序执行;依赖、异常和分支选择仍由原 Python 控制流决定。

1. 证据范围与可复核产物

1.1 源码边界

本文行号均对应上述 commit:

路径 关注范围 作用
guest/plm.py L10–128 AST 解析、准入检查、命名和改写
guest/bootstrap.py L16–34、L47–66 tool、prepare_call、resolve_call 与 compile/exec
guest/runtime.c L10–15、L17–63 _spine Python 扩展和 Wasm import
runner.go L85–156 Run/RunPLM、Guest 运行生命周期、返回 transformed source
bridge.go L49–86 Host call/prepare/resolve 与 Guest 线性内存边界
future.go L10–83 run-local Future、worker、匹配、取消和等待
guest/test_plm.py L7–61 不依赖 Wasm 的 AST pass 单测
plm_test.go L14–189 真实 Wasm Guest 的 PLM 集成测试源码

1.2 真实 AST 输出

以下文件来自实际调用 guest/plm.py 得到的变换结果:

  • 生成器:learning/deep-probes/ast_transform.py;
  • 汇总:manifest.json;
  • 每个 case 的 before.py、after.py:AST 输入和 ast.unparse 输出;
  • 每个 case 的 before.ast.txt、after.ast.txt:ast.dump(..., indent=2) 的真实树;
  • 每个 case 的 run.json:helper、结果、事件和“是否保持原树”的记录;
  • 准入矩阵:admission.json。

生成时使用的是本机 CPython 3.9.6。生成器调用真实 transform,并用 Python prepare/resolve 回调记录事件。AST 结构与这些回调执行结果可以在本机重现;真正的异步 Host 任务另外通过现有 Wasm artifact 的 PLM 集成测试核对。

可复现命令:

# 在 Spine 仓库根目录执行
python3 learning/deep-probes/ast_transform.py
PYTHONPATH=guest python3 -m unittest guest/test_plm.py -v

2. 真实程序的入口和返回契约

2.1 transform 先解析整个程序

guest/plm.py L10–15 的入口是:

def transform(source):
    tree = ast.parse(source, filename="<spine>")
    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.parse 只建树,不执行源码。filename="<spine>" 只影响诊断名称。
  • names 是整棵树中出现过的标识符,再额外加入 except ... as name 的绑定名。它用于生成不冲突的 helper 和临时槽位。
  • data_names 收集语法上所有 Name(Store),再加上 inputs,用于判断名字是否属于允许的数据表达式;例如“后面才赋值”的名字也可能先通过形状检查,运行时错误仍会在原表达式处出现。

返回值有两种形态:

情况 返回值 bootstrap.execute 的行为
语法不支持或整段不准入 (tree, None) 不向执行 scope 添加 helper;直接 compile/exec 原树
至少改写一个工具赋值 (fixed_tree, (prepare_name, resolve_name)) 把两个名字绑定到 prepare_call、resolve_call,再执行新树

解析错误 SyntaxError 发生在 ast.parse,没有被 transform 捕获;调用方因此不会得到一个“未改写但可执行”的树。准入失败则不同:它是正常的保守回退。

2.2 RunPLM 不等于每个调用都异步

Go 侧 runner.go L85–93 只做薄包装:

func (r *Runner) Run(ctx context.Context, source string, inputs any) (Output, error) {
    return r.run(ctx, source, inputs, false, nil)
}

func (r *Runner) RunPLM(ctx context.Context, source string, inputs any) (Output, error) {
    return r.run(ctx, source, inputs, true, nil)
}

RunPLM 把 plm: true 送入 Guest;真正的工具是否被 Host 提前启动,还要经过 future.go 的 earlyReads 名单和 64 个 pending Future 上限。没有 opt-in 的工具、超过上限的 prepare,都会返回句柄 0,再在原调用处走同步 fallback。也就是说:

PLM AST 改写成功 ≠ 每个 prepare 都产生了真实 Future

3. Admission:程序如何进入这个小语言

全程序 gate 位于 guest/plm.py L49–74。只有 Assign、If、Try、Pass 和常量表达式语句允许出现;所有其它语句都会让整个模块回退。

3.1 data(node) 的允许集合

guest/plm.py L16–35 对表达式递归检查,允许的节点如下:

AST 节点 递归条件 说明
Constant 值属于 str/int/float/bool/None 不执行用户代码
Name id in data_names inputs 或语法上出现过 Store 名
List、Tuple 所有 elts 通过 空容器也通过
Dict 每个 key/value 都通过,且 key 不能为 None 拒绝 **mapping 展开
Subscript value 与 slice 都通过 这是一般数据表达式规则
BinOp left、right 都通过 运算符不在 pass 阶段执行
UnaryOp operand 通过 仍由最终 Python 执行
BoolOp 每个 value 通过 保留 Python 短路语义
Compare left 与所有 comparator 通过 不在 pass 阶段计算比较
其它 False 包括属性、调用、推导式、条件表达式等

这解释了一个容易混淆的点:数据赋值可以使用比工具参数更宽的表达式。例如:

key = 1 + 2 * 3       # data(key) 通过
price = tool("lookup", key=key)  # argument(Name("key")) 通过

但下面的工具参数不是 argument 允许的形状:

price = tool("lookup", key=1 + 2)  # argument(BinOp(...)) 不通过

后者会让整段程序使用原树。

3.2 argument(node) 是更窄的参数子集

guest/plm.py L37–42:

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)))

因此工具关键字参数允许三类形状:

key="book"             # Constant
key=key                 # Name,且 key 在 data_names
key=inputs["item"]     # Subscript,底座递归为 argument,slice 必须是 str/int 常量

而这些形状会拒绝:

key=inputs[inputs["index"]]  # slice 不是 Constant
key=obj.real                 # Attribute 不在 data/argument
key=f()                      # Call 不在 data/argument
**inputs                     # keyword.arg is None

注意 argument 只验证 AST 形状,不读取 inputs,也不访问对象。inputs["missing"] 是否存在要到执行时才知道;这正是后面的异常位置分析的关键。

3.3 tool_call(node) 的完整形状

guest/plm.py L43–48 要求右值恰好满足:

Call(
    func=Name(id="tool"),
    args=[Constant(value=<str>)],
    keywords=[keyword(arg=<named>, value=<argument>), ...],
)

逐条件对应源码:

  1. 必须是 ast.Call;
  2. func 必须是直接的 ast.Name,并且 id == "tool";所以 obj.tool(...)、别名和动态 callable 不算;
  3. len(node.args) == 1;位置参数只有工具名;
  4. 唯一位置参数必须是字符串常量;
  5. 每个 keyword 都必须有名字,并且值通过 argument;所以 **kwargs 被拒绝。

它只做形状识别,不检查工具名是否真的在 Go registry 中。registry 检查发生在 Host Future/调用路径,而不是 AST pass。

3.4 block_ok 是全程序的保守闸门

guest/plm.py L49–69 的逻辑可写成下面的判定表:

语句 必须满足
Assign 恰好一个目标、目标是 Name、目标不是 inputs/tool、右值是 tool_call 或 data
If test 是 data,body 和 orelse 递归通过
Try body/orelse/finalbody 都递归通过;每个 handler 的绑定名不能是 inputs/tool;handler body 通过;异常类型为空或是 Name
Pass 直接通过
常量表达式语句 Expr.value 必须是 Constant
其它 立即 False

于是以下任一语句都会阻止整个程序优化:

for x in values: ...     # 没有循环专用依赖证明
import os                 # 不准入
f = lambda: 1             # Function/Lambda 赋值不是 data
inputs["x"] = 1           # 不是单一 Name 目标
x = y = 1                 # 多目标赋值
x = obj.value             # Attribute 不在 data

关键是这段 gate 位于正式改写之前:

if not block_ok(tree.body):
    return tree, None

所以它不会出现“安全部分已经变了,不安全部分保持原样”的混合 AST。guest/test_plm.py L51–55 通过比较 ast.dump 验证了循环、任意调用、输入变异、重绑 tool、import 等输入的树没有改变。

3.5 准入矩阵的真实结果

生成器实际跑出的 admission.json 给出了 13 个样本:

样本 结果 原因
constant_argument 改写 常量参数
input_subscript_argument 改写 inputs["item"]
data_assignment_then_tool 改写 1 + 2 * 3 先作为 data 赋值,再用 Name 参数
dynamic_subscript_rejected 原树 下标不是 str/int 常量
attribute_rejected 原树 Attribute 未证明
call_argument_rejected 原树 参数是 Call
kwargs_unpack_rejected 原树 keyword.arg is None
extra_positional_rejected 原树 位置参数超过一个
multi_target_rejected 原树 目标超过一个
loop_rejected 原树 For 是 barrier
import_rejected 原树 Import 是 barrier
inputs_rebind_rejected 原树 禁止重绑 inputs
tool_rebind_rejected 原树 禁止重绑 tool

4. Rewrite:相邻工具组怎样被改写

4.1 helper 和槽位命名

guest/plm.py L75–83 的 fresh 是简单的后缀策略:

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

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

它避免三类碰撞:

  • 用户源码已有 _spine_prepare 或 _spine_resolve;
  • 用户源码已有 _spine_future;
  • 同一次 transform 中已经生成过同名临时槽位。

它只保证变量名不重合,不保证工具权限,也不保证 Future 有效。

4.2 只收集相邻的工具赋值

guest/plm.py L84–93 的扫描器维护 body 游标:

result, i = [], 0
while i < len(body):
    s = body[i]
    if isinstance(s, ast.Assign) and tool_call(s.value):
        group = []
        while i < len(body) and isinstance(body[i], ast.Assign) and tool_call(body[i].value):
            group.append(body[i])
            i += 1
        # 分析 group,生成 prepare 和 resolve
        ...
        changed = True
        continue
    ...
    result.append(s)
    i += 1

这里的“相邻”是 AST body 列表相邻,不是文本行相邻。注释和空行通常不会成为 AST 语句,因此不会阻断组;下面这些会阻断组:

a = tool("lookup", key="book")
key = 1                  # 普通数据赋值是下一组的屏障
b = tool("lookup", key="shipping")

b 不会跨过 key = 1 被放到 a 的组里。if 和 try 也不会被跨越;它们的内部 body 由递归调用单独处理。

4.3 start:当前实现的依赖规则

每个组先建立三个局部结构:

before = [[] for _ in group]
definitions = {}
replacements = []

接着 guest/plm.py L97–112 对组内调用按原顺序处理:

for index, statement in enumerate(group):
    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)

含义是:

  • loaded 只看关键字参数值中的 Name;工具名是固定字符串,位置参数已经由 tool_call 限定;
  • 如果没有读取当前组内已定义的名字,max 默认 -1,所以 start == 0;
  • 如果读取的名字在组内第 j 条工具赋值中定义,start >= j + 1;
  • definitions 只记录当前组里已经扫描到的工具赋值:
definitions[statement.targets[0].id] = index

这张定义表只服务于当前相邻工具组。循环、属性访问和任意调用由前面的整段检查拒绝;普通数据赋值则保留在组外,成为重排的屏障。

4.4 新 AST 的两个组成部分

对每个调用,代码生成:

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]),
)
thunk = ast.Lambda(
    args=ast.arguments(posonlyargs=[], args=[], kwonlyargs=[], kw_defaults=[], defaults=[]),
    body=ast.Tuple(elts=[copy.deepcopy(call.args[0]), args], ctx=ast.Load()),
)
early = ast.Assign(
    targets=[ast.Name(slot, ast.Store())],
    value=ast.Call(ast.Name(prepare, ast.Load()), [thunk], []),
)

这段对应 guest/plm.py L102–107,有四个不能混淆的点:

  1. 关键字参数被复制到一个新的 Dict 节点;copy.deepcopy 避免新树和旧树共享可变子节点。
  2. 工具名和参数字典被放进无参数 lambda 的二元组。lambda 的 body 此时还没有求值。
  3. prepare(thunk) 的返回值存到 _spine_future... 槽位;0 是运行时约定的 fallback sentinel。
  4. ast.copy_location(early, statement) 把新 prepare 的位置标到原赋值语句附近,便于诊断。

原 Call 被改成:

statement.value = ast.copy_location(
    ast.Call(
        ast.Name(resolve, ast.Load()),
        [ast.Name(slot, ast.Load()), *call.args],
        call.keywords,
    ),
    call,
)

这对应 guest/plm.py L108–112。目标变量、原赋值语句、工具名位置参数和关键字表达式都保留;只是在最前面增加一个 handle,并把函数名从 tool 换成 resolve helper。

最后:

for early, statement in zip(before, replacements):
    result.extend(early)
    result.append(statement)

before[0] 是组开头,before[1] 是组中第一条原赋值之后,以此类推。然后 guest/plm.py L127–128 递归完成整个模块并调用 ast.fix_missing_locations。

5. Case A:两个独立调用的真实前后 AST

这是 .local/deep/ast/independent/before.py 的原始输入:

a = tool("lookup", key="book")
b = tool("lookup", key="shipping")
result = a + b

对应的真实输入树完整保存在 before.ast.txt。核心结构是:

Module.body = [
  Assign(targets=[Name("a", Store)],
         value=Call(Name("tool"), [Constant("lookup")], key=Constant("book"))),
  Assign(targets=[Name("b", Store)],
         value=Call(Name("tool"), [Constant("lookup")], key=Constant("shipping"))),
  Assign(targets=[Name("result", Store)],
         value=BinOp(Name("a"), Add, Name("b"))),
]

对这组调用:

组内索引 loaded definitions 查询 start 生成槽位
0 {} 无 0 _spine_future
1 {} 无 0 _spine_future_

两个 early 节点都进入 before[0],所以真实 ast.unparse 结果(after.py)是:

_spine_future = _spine_prepare(lambda : ('lookup', {'key': 'book'}))
_spine_future_ = _spine_prepare(lambda : ('lookup', {'key': 'shipping'}))
a = _spine_resolve(_spine_future, 'lookup', key='book')
b = _spine_resolve(_spine_future_, 'lookup', key='shipping')
result = a + b

ast.unparse 会规范化引号、空格和 lambda 的显示形式;上面显示的是实际 AST 的反向文本表示。完整变换后树见 after.ast.txt。它的前三个顶层节点已经从“两个 tool 赋值”变为:

Assign(_spine_future = Call(_spine_prepare, [Lambda(Tuple("lookup", Dict(...)))]))
Assign(_spine_future_ = Call(_spine_prepare, [Lambda(Tuple("lookup", Dict(...)))]))
Assign(a = Call(_spine_resolve, [Name(_spine_future), Constant("lookup")], ...))

结果赋值 result = a + b 没有被动过。

5.1 Python 执行顺序

在生成代码中,Python 先执行两条 helper 赋值,再执行两个原位置的 resolve:

1. 创建第一个 lambda;调用 _spine_prepare;保存 handle
2. 创建第二个 lambda;调用 _spine_prepare;保存 handle
3. 计算 a 的 resolve 参数;调用 _spine_resolve;得到 21
4. 计算 b 的 resolve 参数;调用 _spine_resolve;得到 5
5. 计算 a + b;result = 26

生成器的真实运行记录在 run.json:

[
  ["prepare", "book"],
  ["prepare", "shipping"],
  ["resolve", "book"],
  ["resolve", "shipping"]
]

这个 Python 桩的事件顺序用于验证 AST 形状和原位交付;在真实 Go 运行时,worker 的完成先后不要求与 prepare 顺序相同,但 resolve 仍按源程序位置领取匹配的 Future。

6. Case B:依赖调用为什么不能一起放在开头

真实输入(dependency/before.py):

key = tool("lookup", key="choose")
price = tool("lookup", key=key)
shipping = tool("lookup", key="shipping")
result = price + shipping

这三条工具赋值是一个相邻 group,索引和依赖状态是:

索引 目标 参数中读取的名字 此刻 definitions start
0 key {} {} 0
1 price {key} {"key": 0} 1
2 shipping {} {"key": 0, "price": 1} 0

因此:

before[0] = prepare(choose), prepare(shipping)
before[1] = prepare(key)

真实变换后源码(dependency/after.py)是:

_spine_future = _spine_prepare(lambda : ('lookup', {'key': 'choose'}))
_spine_future__ = _spine_prepare(lambda : ('lookup', {'key': 'shipping'}))
key = _spine_resolve(_spine_future, 'lookup', key='choose')
_spine_future_ = _spine_prepare(lambda : ('lookup', {'key': key}))
price = _spine_resolve(_spine_future_, 'lookup', key=key)
shipping = _spine_resolve(_spine_future__, 'lookup', key='shipping')
result = price + shipping

这里的下划线顺序来自生成顺序,而不是依赖优先级:

  • 第 0 条先拿到 _spine_future;
  • 第 1 条虽然先分配到 _spine_future_,但它的 prepare 被放到 key = resolve(...) 之后;
  • 第 2 条随后分配到 _spine_future__,但它没有组内依赖,因此 prepare 与第一个一起在组开头。

生成器的事件是:

prepare choose
prepare shipping
resolve choose
prepare book
resolve book
resolve shipping

prepare book 不能出现在 key resolve 之前,因为 lambda body 中的 key 必须等第一个原赋值执行后才有正确值。这个实现只用 definitions 的组内最近定义规则,不构造跨组或跨控制流的全局 DAG。

7. Case C:分支是控制流屏障,不做分支外 hoist

真实输入(branch_false/before.py):

if inputs["yes"]:
    a = tool("lookup", key="book")
else:
    a = tool("lookup", key="shipping")
result = a

block_ok 先验证:

  • inputs["yes"] 是 data(Subscript(inputs, Constant("yes")));
  • body 和 orelse 都是允许的单一工具赋值。

正式 rewrite 时,顶层当前节点是 If,不是 Assign 工具组,因此走 guest/plm.py L117–120 的递归:

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)

真实输出(branch_false/after.py):

if inputs['yes']:
    _spine_future = _spine_prepare(lambda : ('lookup', {'key': 'book'}))
    a = _spine_resolve(_spine_future, 'lookup', key='book')
else:
    _spine_future_ = _spine_prepare(lambda : ('lookup', {'key': 'shipping'}))
    a = _spine_resolve(_spine_future_, 'lookup', key='shipping')
result = a

没有生成:

# 这是错误的想象:会错误地提前执行未选中的分支
_spine_prepare(...book...)
if inputs["yes"]: ...

运行时 inputs={"yes": False} 的真实记录只有:

prepare shipping
resolve shipping
result = 5

因此“AST 中两个分支都被递归改写”不等于“两个分支都会 prepare”;实际选择仍由原 If 节点控制。

8. Case D:异常究竟发生在 prepare 还是原调用

真实输入(exception/before.py):

try:
    a = tool("lookup", key="book")
    b = tool("lookup", key=inputs["absent"])
except KeyError:
    result = a

变换后(exception/after.py):

try:
    _spine_future = _spine_prepare(lambda : ('lookup', {'key': 'book'}))
    _spine_future_ = _spine_prepare(lambda : ('lookup', {'key': inputs['absent']}))
    a = _spine_resolve(_spine_future, 'lookup', key='book')
    b = _spine_resolve(_spine_future_, 'lookup', key=inputs['absent'])
except KeyError:
    result = a

8.1 两次求值是有意的

对第二个调用,生成树中存在两份 inputs["absent"]:

  1. lambda body 中的一份,给提前 prepare 探测参数;
  2. 原赋值位置的 resolve keyword 中的一份,保持原调用的 Python 求值顺序。

Guest bootstrap 的 prepare_call 是:

def prepare_call(thunk):
    try:
        name, args = thunk()
        request = json.dumps({"tool": name, "args": args})
        handle = _prefix.claim(request) if _prefix is not None else None
        return prepare(request) if handle is None else handle
    except Exception:
        # Argument lookup/encoding errors belong at the original Python call, not here.
        return 0

所以执行步骤是:

1. 创建第一个 thunk;prepare_call 调 thunk;得到 ("lookup", {"key": "book"});启动 book
2. 创建第二个 thunk;调 thunk 时 inputs["absent"] 抛 KeyError
3. prepare_call 捕获 Exception,返回 0;不会把 KeyError 提前暴露给外层 try
4. 继续执行 a = resolve(...book...);resolve 等到 book 结果并绑定 a = 21
5. 执行 b = resolve(..., key=inputs["absent"])
6. Python 在进入 resolve_call 之前先计算 keyword 参数,第二次 inputs["absent"] 在原 b 赋值处抛 KeyError
7. 原有 except KeyError 捕获;result = a,即 21

这正是“异常位置保持原调用”的实现方式:提前探测失败只产生 sentinel 0;原始参数表达式仍在原语句处重新求值。生成器记录了 prepare-error 作为测试桩诊断,但生产 prepare_call 对这个异常只返回 0,不会产生这个额外事件。真实 case 的结果和事件见 exception/run.json。

8.2 工具自身失败的位置

参数没有异常时,Host 工具仍可能失败。仓库 plm_test.go L99–106 使用真实 Wasm Guest 验证:第二个工具 Future 返回 RuntimeError,而异常在第二条原位置 resolve 交付时出现,原有 except RuntimeError 可以捕获。future.go L77–80 明确把工具错误编码进 Future 响应并交付,不自动重试。

两类异常不要混为一谈:

异常 首次发生处 原调用处的行为
参数查找/JSON 构造异常 prepare_call 调 thunk 的提前探测 被捕获,返回 0;原参数随后在原语句重新求值
参数在原 resolve 调用前再次求值失败 原赋值的 Python keyword/arg 求值 直接跳到原 try/except
Host 工具返回错误 runState worker 或同步 invoke decode 抛 RuntimeError,发生在原 resolve 语句的函数体路径
AST 语法错误 ast.parse transform 不返回回退 tree;错误向上传播
未支持语句 block_ok 不是异常;返回原 AST,普通执行

prepare_call 捕获的是 Exception,不是 BaseException;KeyboardInterrupt、SystemExit 等不在这个 fallback 范围内。

9. Case E:循环不改写,保证普通执行

真实输入(unsupported_loop/before.py):

result = 0
for i in range(2):
    result += tool("lookup", key="book")

block_ok(tree.body) 看到 For 后返回 False。因此:

helpers = None
ast.dump(before) == ast.dump(after)  # True

unsupported_loop/run.json 的真实结果是:

{
  "helpers": null,
  "changed": false,
  "result": 42,
  "dump_equal": true,
  "events": [["call", "book"], ["call", "book"]]
}

这里的 tool 是原始同步桩;循环每次都直接调用它。PLM 没有试图猜测 i 的生命周期,也没有把循环内的调用变成一个不适用的单一 Future。

10. Case F:fresh name 的实际碰撞结果

真实输入(freshname/before.py):

_spine_prepare = "user value"
a = tool("lookup", key="book")
result = _spine_prepare

names 已包含 _spine_prepare,所以 helper 生成结果(freshname/after.py)是:

_spine_prepare = 'user value'
_spine_future = _spine_prepare_(lambda : ('lookup', {'key': 'book'}))
a = _spine_resolve(_spine_future, 'lookup', key='book')
result = _spine_prepare

返回的 helper tuple 是:

('_spine_prepare_', '_spine_resolve')

bootstrap.execute 随后按这个 tuple 把 prepare_call 绑定给 _spine_prepare_,不会覆盖用户自己的 _spine_prepare。这也是为什么 helper 名必须从 AST 全局名字集合中生成,而不能在 bootstrap 中硬编码后直接写进树。

11. 从 AST 到真实 Future:完整控制流

AST pass 本身只产生 Python 节点;完整 PLM 路径如下:

Runner.RunPLM
  │ runner.go:91–92, plm=true
  ▼
Guest bootstrap.execute
  │ bootstrap.py:51–61
  ├─ ast.parse(source)
  ├─ plm.transform(source)
  ├─ scope[prepare_name] = prepare_call
  ├─ scope[resolve_name] = resolve_call
  └─ exec(compile(tree))
       │
       ├─ _spine_prepare(lambda)
       │    └─ bootstrap.prepare_call
       │         ├─ 在 Guest Python 中执行 thunk
       │         ├─ json.dumps({tool,args})
       │         └─ _spine.prepare(request)
       │              └─ runtime.c python_prepare
       │                   └─ Host hostPrepare
       │                        └─ runState.prepare
       │                             └─ goroutine 执行允许的 Tool
       │
       └─ _spine_resolve(handle, name, **args)
            └─ bootstrap.resolve_call
                 ├─ json.dumps({tool,args})
                 └─ _spine.resolve(handle, request)
                      └─ runtime.c python_resolve
                           └─ Host hostResolve
                                └─ runState.resolve
                                     ├─ id=0 → 同步 invoke
                                     ├─ handle 不匹配 → 取消并同步 invoke
                                     ├─ handle 匹配 → 等 done
                                     └─ response/error 回到 Guest

11.1 Python/C 边界

guest/runtime.c L10–15 把 call、prepare、resolve 声明为 spine module import;L33–54 把它们暴露为 _spine.prepare 和 _spine.resolve。C 侧只处理字符串、长度、返回的 uint32 handle 和响应 buffer,不执行 AST 调度。

11.2 Host 的 request 读取和响应写回

bridge.go L69–85:

  • hostPrepare 先从 Guest 线性内存读取 JSON;读取失败返回 0,因此 prepare 仍是“不能提前就回退”的接口;
  • hostResolve 读取当前调用的 JSON;读取失败时编码错误,否则调用本轮 runState.resolve;
  • resolve 最后统一把 JSON 响应写回 Guest buffer。

11.3 Future 的匹配、单次消费和取消

future.go L41–83 是 AST 之后的语义守门:

if !s.enabled || !s.runner.earlyReads[request.Tool] || len(s.futures) >= 64 {
    return 0
}

有效 prepare 会创建 run-local Future 和 worker。resolve:

  1. id == 0:直接同步调用原工具;
  2. 找不到 id:返回 unknown or consumed future;
  3. 删除 map 中的 Future,句柄单次消费;
  4. 比较 prepare 时保存的 tool/args 与当前 resolve request 的字节内容;不匹配就取消旧 Future,改为同步调用当前 request;
  5. 匹配则等待 done 或本轮 context 取消;工具错误作为响应交付,不重试。

这解释了为什么 AST 同时保留“prepare 的参数字典”和“原 resolve 的参数表达式”:Future 使用最终的 JSON 请求核对预先启动的操作。

11.4 异常跳转后的未使用 Future

如果第一个原位置 resolve 抛错,Python 可能直接跳到 except,从而跳过后续 resolve。后续 Future 不会泄漏:

  • Runner.run 用 defer state.close();
  • runState.close 取消本轮 context 并 workers.Wait();
  • 每个 worker 都在 defer 中 Done、取消自身并关闭 f.done。

仓库 plm_test.go L129–141 的 “unused future cancelled and joined” 用例验证了:即使后续 prepare 已启动,Run 返回前也会把被丢弃的 worker 协作取消并等待结束。

12. AST 变换的语义边界

做了什么

  • 对完整模块做一次语法准入;
  • 只在允许的小语言内改写;
  • 只收集 body 中相邻的 tool 赋值;
  • 把可提前请求封装成 Guest Python lambda;
  • 按组内已知定义推迟有依赖的 prepare;
  • 将交付点保留为原赋值位置的 resolve;
  • 递归进入 if 分支和 try 的 body/else/finally/handler body,但不搬走控制结构;
  • 用 fresh name 防止 helper 和槽位覆盖用户变量;
  • 给新节点补位置元数据。

没有做什么

  • 没有把任意 Python 编译成 Go;
  • 没有在 AST pass 阶段执行工具;
  • 没有让 Go worker 执行 Python lambda;lambda 在 Guest 的 prepare_call 中执行;
  • 没有建立跨整个程序的通用依赖 DAG;
  • 没有跨过普通语句、循环、分支或异常控制结构;
  • 没有保证 Host 一定创建 Future;opt-in、pending 上限、request 错误都可返回 0;
  • 没有把提前参数探测失败当成用户可见异常;原参数在原调用处重新求值;
  • 没有为工具失败自动重试;失败响应在原 resolve 路径交付。

最准确的心智模型是:

保守的 AST admission
  + 相邻 Assign 的局部调度
  + 原位 resolve
  + run-local、可取消、可匹配的 Host Future

13. 已执行验证

本次在该 commit 上实际执行了:

python3 learning/deep-probes/ast_transform.py
→ 6 个运行 case 均得到预期结果;独立、依赖、分支、异常、freshname 改写,循环保持原树

PYTHONPATH=guest python3 -m unittest guest/test_plm.py -v
→ Ran 6 tests in 0.002s
→ OK

生成器还验证了 unsupported_loop 的 before/after ast.dump 相等,以及准入矩阵中所有拒绝样本的 helpers is None。本次没有修改 runtime/、站点或原有源码,也没有 build、提交或部署。