这一章回答:不启动 Wasm 和 Go worker 时,能否把 Prefix 自己的时序规则单独钉住?
完整因果路径很短:测试用当前格式的 MANIFEST 构造 Prefix,注入一个 prepare(request) 替身;每次 feed 追加源码并尝试扫描完整前缀,替身把 request 记录到 calls,并返回按调用顺序递增的整数 handle。测试随后观察 calls、ready 和 claim:半句不产生准备;补全后只准备一次;首个非候选 statement 阻止后续扫描;非零 handle 才进入 ready;最终只能从队首领取一次。
这里验证的是 guest/prefix.py 的对象逻辑,不是完整运行时。它不启动 Wasm、不创建 Go Future、不观察 context 或 worker 退出;真实 ABI、同一 Guest 两阶段执行和并发握手由 prefix-tests.md 覆盖。以下“应当”均来自测试源码的断言,本任务没有执行这组 unittest。
1. 测试入口与当前 Manifest
guest/test_prefix.py · L1–L6 · 打开完整源码
import json
import unittest
from prefix import Prefix
MANIFEST = [{"name": "lookup", "allow_early_read": True}]
测试导入 json、unittest 和被测的 Prefix。当前清单不是旧版只传工具名的接口,而是一个列表,每项带具名函数和提前读取权限:
MANIFEST = [{"name": "lookup", "allow_early_read": True}]
这个清单直接对应生产路径送入 Guest 的 manifest。Prefix 会由它建立 early_names={"lookup"};如果某个工具存在但 allow_early_read 为假,它仍可能供最终程序调用,却不会进入 prefix 提前队列。
2. make:用可见的整数模拟 handle
guest/test_prefix.py · L9–L15 · 打开完整源码
class PrefixTests(unittest.TestCase):
def make(self, inputs=None):
calls = []
def prepare(request):
calls.append(request)
return len(calls)
return Prefix(inputs, MANIFEST, prepare), calls
PrefixTests 继承 unittest.TestCase,框架会收集名字以 test_ 开头的方法。make 接受可选的 inputs,创建空的 calls 列表,并定义闭包 prepare:它先保存 request,再返回当前长度,因此第一次准备得到 1,第二次得到 2。
这个替身不执行工具,也不返回业务值。它只让测试能回答两个问题:Prefix 是否在当前 feed 调用了 prepare,以及 ready 中的整数是否按准备顺序保存。每次 make 都返回新的 Prefix 和新的 calls,避免不同测试共享 seen、ready 或 claimed。
3. 半句到完整句:source 追加后才产生一次准备
guest/test_prefix.py · L17–L25 · 打开完整源码
def test_split_statement_and_no_duplicate_prepare(self):
p, calls = self.make({"item": "book"})
p.feed("a=lookup(key=inputs['item']")
self.assertEqual(calls, [])
p.feed(")\n")
p.feed("# another chunk\n")
self.assertEqual(len(calls), 1)
self.assertEqual(p.claim(calls[0]), 1)
self.assertIsNone(p.claim(calls[0]))
第一个测试把一条调用拆成两个 chunk:
p.feed("a=lookup(key=inputs['item']")
# calls 仍为空
p.feed(")\n")
首段既没有换行,又没有闭合调用,因此 Prefix 没有可解析的完整文本,不能提前读取。第二段补齐括号并加入换行后,累计 source 才能让 ast.parse 得到顶层候选;inputs['item'] 在这个测试的输入字典中解析为 book,prepare 恰好被调用一次。
第三次 feed 是注释行。它会让 Prefix 再次解析累计 source,但 index 0 已经被 seen 标记,不能重复准备。测试随后用同一个 request claim 得到 handle 1,再次 claim 得到 None,同时观察“候选只准备一次”和“handle 只领取一次”是两个不同的单次规则。
4. 候选门槛和首个停止点
guest/test_prefix.py · L27–L34 · 打开完整源码
def test_no_branch_or_arbitrary_expression(self):
for source in ("if True:\n a=lookup(key='book')\n",
"x='book'\na=lookup(key=x)\n",
"a=lookup(key=compute())\n",
"lookup=lookup(key='book')\n"):
p, calls = self.make()
p.feed(source)
self.assertEqual(calls, [])
这个测试循环构造四种不应在 prefix 阶段准备的源码,并为每种源码新建 Prefix:
if True:包住读取:顶层节点是If,不是允许的普通单目标赋值;- 先把字符串存入
x,再用lookup(key=x):参数不是常量或固定inputs[...]; lookup(key=compute()):参数需要执行任意函数;lookup=lookup(key='book'):目标名覆盖了 Manifest 中允许 early-read 的工具名。
每段 source 一次性送入 feed,断言 calls 为空。尤其第一种不代表最终执行一定不进入分支;它只说明 Prefix 不会跨越当前顶层 barrier 去提前准备。第四种则体现当前实现使用 early_names 排除目标名,而不是依赖一个硬编码的 tool 名称。
如果把一个非 early-read 工具放在队首,当前扫描会同样停止。这个边界由后面的独立测试用带有 buy 的 Manifest 直接验证。
5. 参数读取错误留到真实调用
guest/test_prefix.py · L36–L39 · 打开完整源码
def test_errors_are_not_raised_during_feed(self):
p, calls = self.make({})
p.feed("a=lookup(key=inputs['absent'])\n")
self.assertEqual(calls, [])
测试用空字典创建 Prefix,再送入语法形状合格的:
a=lookup(key=inputs['absent'])\n
literal_or_input 会认可这种固定下标形状,但 argument 取值时发现 absent 不存在。feed 捕获这个异常并不调用替身,所以 calls 仍为空。测试的重点不是“错误消失”,而是错误发生的时间和归属:prefix 不在源码接收阶段交付参数错误,最终真实 Python 调用仍会在原位置重新读取并报告它。
6. claim 不向后搜索
guest/test_prefix.py · L41–L46 · 打开完整源码
def test_match_does_not_search_ahead(self):
p, calls = self.make()
p.feed("a=lookup(key='book')\nb=lookup(key='shipping')\n")
self.assertIsNone(p.claim(calls[1]))
self.assertEqual(p.claim(calls[0]), 1)
self.assertEqual(p.claim(calls[1]), 2)
测试一次送入两条不同 key 的连续候选,因而 calls[0] 和 calls[1] 分别记录两个 request、对应 handle 1 和 2。它先用第二条 request 调 claim,要求得到 None:队首仍是第一条,Prefix 不能为了匹配第二条而跳过第一条。
随后先 claim 第一条得到 1,再 claim 第二条得到 2。这个顺序把源码位置、ready 队列和最终执行位置绑定起来;如果 claim 允许任意搜索,重复 request 或执行顺序变化时就无法仅凭队列位置证明 handle 属于哪个源码调用。
7. 零 handle 和非 early-read 工具
guest/test_prefix.py · L48–L58 · 打开完整源码
def test_no_handle_does_not_keep_request(self):
p = Prefix(None, MANIFEST, lambda request: 0)
p.feed("a=lookup(key='book')\n")
self.assertEqual(p.ready, [])
def test_non_early_tool_stops_prefix(self):
manifest = MANIFEST + [{"name": "buy", "allow_early_read": False}]
p = Prefix(None, manifest, lambda request: 1)
p.feed("order=buy(symbol='ACME')\nprice=lookup(key='book')\n")
self.assertEqual(p.ready, [])
零 handle 测试绕过 make,直接注入一个总返回 0 的 lambda。有效的 lookup 调用仍会经过 prepare,但因为返回值为假,Prefix 不把 (request, 0) 放入 ready,所以断言 p.ready == []。ready 为空不等于没有调用 prepare;这里恰好区分了“尝试提前工作”和“确实有可领取结果”。
最后一个测试扩展当前清单:
manifest = MANIFEST + [{"name": "buy", "allow_early_read": False}]
p.feed("order=buy(symbol='ACME')\nprice=lookup(key='book')\n")
队首 buy 的名称不在 early_names,candidate 返回假并停止扫描;后面的 lookup 即使本身满足字面量规则,也不会跨过停止点进入 ready。这个断言同时验证了 Manifest 权限和 source 停止条件,而不只是单个调用的 AST 形状。
guest/test_prefix.py · L60–L61 · 打开完整源码
if __name__ == "__main__":
unittest.main()
模块直接运行时才执行 unittest.main();被测试框架导入时不会在导入阶段自动开跑。这个入口不改变 Prefix 语义,只决定测试由谁收集。
8. 这组单测与真实路径的对应
prepare返回的 1、2 是 HostrunState.prepare返回的本轮整数 handle 的替身,不是业务结果。calls保存 request 字符串,生产环境会由 Host worker 执行工具;这里没有线程,不能据此证明并发。- 直接调用
p.claim模拟最终 bootstrap 工具代理在原调用处先尝试领取,再进入共同 resolve 或普通 call。 source、seen、ready、claimed分别代表接收文本、发现进度、已启动的提前请求和最终领取游标;测试不能把它们合并为一个“已处理”状态。
下一步回到 prefix-host.md 看 chunk 如何经过 prefix_begin/prefix_feed 到达同一个 Guest,再对照 prefix-tests.md 中的 started/stopped,把这里的局部状态接到真实 worker 生命周期。
读完本章应能回答:
- 为什么补上闭合括号和换行后才准备,注释 chunk 又不会重复准备?
- 当前 Manifest 中
allow_early_read为假、但排在后面的工具调用为什么也不会准备? - 为什么
ready == []可能意味着 prepare 返回了零,而不是 prepare 从未被调用?