本文追踪 Spine 190ea2e 中 guest/plm.py 的实际变换,围绕一次调用回答:
- 什么样的完整 Python 程序会被准入;
- 一个
tool(...)赋值如何在 AST 中变成prepare/resolve; - 组内依赖如何决定 prepare 的插入位置;
if、try/except、循环和普通语句如何约束控制流;- 参数异常、工具异常和 Future 异常分别在哪里发生;
- 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) 是更窄的参数子集
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>), ...],
)
逐条件对应源码:
- 必须是
ast.Call; func必须是直接的ast.Name,并且id == "tool";所以obj.tool(...)、别名和动态 callable 不算;len(node.args) == 1;位置参数只有工具名;- 唯一位置参数必须是字符串常量;
- 每个 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,有四个不能混淆的点:
- 关键字参数被复制到一个新的
Dict节点;copy.deepcopy避免新树和旧树共享可变子节点。 - 工具名和参数字典被放进无参数 lambda 的二元组。lambda 的 body 此时还没有求值。
prepare(thunk)的返回值存到_spine_future...槽位;0是运行时约定的 fallback sentinel。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"]:
- lambda body 中的一份,给提前 prepare 探测参数;
- 原赋值位置的 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 读取和响应写回
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:
id == 0:直接同步调用原工具;- 找不到 id:返回
unknown or consumed future; - 删除 map 中的 Future,句柄单次消费;
- 比较 prepare 时保存的 tool/args 与当前 resolve request 的字节内容;不匹配就取消旧 Future,改为同步调用当前 request;
- 匹配则等待
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、提交或部署。