设计决策记录

记录基石语言每次语法/架构取舍的来龙去脉,避免后人重复踩坑。

D1. 为什么 Python 前端 + C 内核(技术栈透明)

D2. 分词消歧:关键字后必须有分隔

D3. 全角标点接受,全角空格在缩进中拒绝

D4. 执行模型演进路线

D5. 标准库策略:薄封装,不重造轮子

D6. 缩进实现

D7. 对象方法分派(M1)

D8. REPL 回显与多行输入(M1)

(M27 起统一到 runtime.jishi_repr 一处,打印/文本()/插值/REPL 回显/ 容器元素共用;顶层文本不带引号、容器内带引号——与 Python 的 print/repr 分工一致。)

D9. Python 桥接采用「薄桥接」而非对象包装器(M3)

D10. 关键字参数:调用内 名字 = 值 的歧义处理(M3)

D11. 异常处理的对象模型:异常即 JishiError 实例(M5a)

JishiError 实例。这样运行时抛出的中文错误与用户 抛出 的对象是同一种东西, 捕获 天然能接住内建错误(除零、索引越界、文件找不到…)。

索引错误/键错误/除零错误/文件错误…),捕获 类型错误 为 e: 就按 title 匹配(实现为 isinstance 检查,因此基类能抓子类)。 异常 对应 JishiError 基类,抓得住一切。

原值留在 e.消息 里。这样 抛出 "余额不足" 也能被 捕获 异常 为 e 接住, 规则只有一条,不用解释「哪些东西能抛」。

打印(e) 输出与未捕获错误一致的带位置渲染(__str__ = render()), 因此对拍测试比较 str(e) 时行列号仍在检验范围内 —— 这点刻意保留。

中断/继续 穿透,都执行;最终 里的 返回/中断 会覆盖原有的离开原因。

维护「异常处理器栈」,出错时逐层弹帧匹配(与 Python 的传播顺序一致)。 这是 tests/test_cvm.py 对拍覆盖的场景之一。

D12. 语法用中文助词而非括号(M5a)

继承/为 关键字,不引入 狗(动物) 这类括号形式。

全中文助词让句子能朗读出来,也避免中文输入状态下频繁切括号。

与 Python 的 self/__init__ 地位相同但可读。

D13. 跨语言字节码用 JSON 而非扁平二进制(M13.1)

附 format 标记 + version 号 + sha256 校验和 + opcode_names 指令表。

BUILD_LIST/BUILD_DICT 构造的,不在常量池里——这让字节码纯数据化成为可能, 任意语言(Node/Rust)都能轻松解析,无需处理 Python 任意对象序列化。

延续项目「零依赖 + 可调试」气质;二进制优化(更小更快)留作后续可选。

D14. Node/Rust 宿主走「纯解释器」而非「ffi 加载 jsvm.dll」(M13.2/13.3)

jsvm.dll 是宿主驱动架构——不链接 libpython,字符串/列表/字典/打印/异常/导入 全部通过约 40 个函数的 JsHost 回调表回传宿主实现。ffi 方案意味着用 JS 重新 实现全部语义回调,工作量巨大且极易错(C 结构体对齐、句柄代际号防悬垂)。

指令分发 + 内建函数。工作量约 1/3,真零依赖,且更贴合「字节码是中间格式、 各语言有自己的执行引擎」的本意。

Rust 版「最小可运行」。已写入 docs/embed.md 诚实记录。

D15. Rust 值语义 vs 基石引用语义的边界(M13.3)

列表.追加(x) push 到副本上,不反映回原变量——与基石(同 Python)的 可变引用语义冲突。

语义的 Rc<RefCell> 重构留作后续迭代。Node 版无此问题(JS 对象天然引用语义), 这正是「先做 Node 再做 Rust」顺序的价值。

Instance 都换成 Rc<RefCell<…>>(实例也必须共享,否则 自身.字段 = 值 改的是副本、静默失效),迭代器/解包改成显式快照。M29 又补齐了方法调用、 高阶方法、集合、文件与上下文、精确小数(零依赖手写 BigInt)。 教训:当时的「最小可运行」其实是夸大的——测试 里没有 Rust 的对拍, 真正跑起来才发现连函数调用都 panic、异常机制根本没接上。 新增宿主必须同时加对拍测试,否则「支持」二字是空的。

D16. 包管理分两步走:先本地 + 静态索引,后远端拉包(M10.2 / M14)

远端索引格式定义(jishi-package-index JSON,先静态文件托管、不建站);

注入到模块命名空间(依赖名.成员 免重复导入)。

的闭环,避免在生态尚小时过度建设。

D21. 发行版版本号定稿 0.1.0(M16.1)

语言规格已收敛 v1.0、功能相当完整,再标 0.0.1 反而低估现状。

CHANGELOG.md 列清变更与迁移指引。

从 pyproject.toml 读版本;jishi --version、语言卡、MCP Server、构建脚本 共用同一值;PyInstaller 打包后无元数据时回退内置常量 __FALLBACK__。 消除「cli.py 硬编码 0.1.0、pyproject 写 0.1.0」这类多处不一致的历史隐患。

D22. 发行包用 PyInstaller onedir 而非 onefile(M16.2)

适中(实测 Windows x64 发行包 16MB)。

三级回退;标准库源码用 _stdlib_dir() 兼容打包(源文件在 _MEIPASS/jishi/stdlib)。

(from .errors import ...)报「no known parent package」。解法:用包外的 入口脚本 tools/entry_cli.py 绝对导入 from oracle.jishi.cli import main。

D23. 安装默认用户目录 + jishi 医生 自检(M16.3)

安装脚本把 bin 加入用户 PATH(Windows 改用户环境变量,Unix 追加 shell rc 并提示 source);卸载脚本包含 PATH 还原,不留残余。

MCP 可用性),异常给中文修复建议,延续「中文报错」体验。

UnicodeEncodeError 崩溃。解法:入口脚本 reconfigure(encoding='utf-8') 强制 UTF-8 输出 + 医生/安装脚本里的 emoji 一律换纯文本 [OK]/[警告]。

D24. 大整数与整数溢出的取舍(M32)

背景:Python 的 int 是任意精度,所以「对标 Python」就意味着 2 64 该给 18446744073709551616。但三个宿主的整数表示各不相同, 探测时发现三边在超大整数上全都静默给错值**(不是报错):

宿主表示原来的表现
Python VM / 树遍历原生 int✅ 任意精度
Node 宿主number(双精度)2 ** 64 → 18446744073709552000
Rust 宿主i64i64_MAX + 1 → 负数(回绕)
C 虚拟机int64_t2 64 → 0**(ctypes 边界截断)

决策:

  1. 能到任意精度的就到。JS 用 BigInt(原生任意精度);Python 本来就有。

但不「一律用 BigInt」——那会让每个整数加法都往返于 BigInt 与 number 之间,JS 宿主的整数性能掉一个量级。改为按需切:任一边已经是 BigInt, 或 number 算出来的整数结果超出 Number.isSafeInteger。 代价是判断分支多两个 typeof,普通路径零额外开销。

  1. 到不了任意精度的,就明确报错。Rust 提到 i128(覆盖 262/264

这些常见写法)+ checked_*;C 虚拟机保持 int64_t(JVal 的 16 字节 布局不能动,M20 的批量转换快路径依赖它),在 ctypes 边界检查范围。 两者越界都是中文报错,不是静默截断。

  1. 字节码里的 int 常量按需换成字符串。JSON 的数字在 JS 的 JSON.parse

里就已经丢精度了(reviver 拿到的是已经解析过的值),所以超出宿主安全 整数范围的 int 要写成十进制字符串,由宿主按需还原。旧产物(v 是数字) 继续能读——不升版本号,因为新格式是旧格式的超集。

边界:C 虚拟机与 Rust 宿主在超大整数上是「明确报错」而不是「给值」。 这属于「宿主能力子集」(与「精确小数的 只支持整数指数」同一类), 在 docs/embed.md 的支持程度表里逐条标注。宁可报错,也不给一个错的数**。

D25. 「剩余边界」清单要按实测维护,不能只靠读(M30/M32)

两次教训方向相反:

实测发现根本没实现(CMPOP 只映射了 0–5,语义比较码 6–9 落成 undefined)。

(字节码里的小数常量带 t: "float" 标记,decodeConst 早按它包了 FloatBox)——这条被当成「表示层固有限制」写了好几轮。

决策:把「跑一遍横向探测」变成改边界清单前的固定动作,而不是出了事 再查。具体做法:写一个脚本让所有宿主跑同一段代码、逐条比对输出, 输出三类结果——

  1. 不一致且都不报错 → 静默算错,最高优先级;
  2. 一边报错一边给值 → 明确分歧,次优先(要么补齐,要么如实标注);
  3. 都不报错且一致 → 一致,从清单里删掉。

修完复跑,直到只剩「刻意的设计差异」(如精确小数的非整数次幂)。 这比读文档可靠,也比「凭印象判断哪些还缺」省时间。

D26. D 档四类特性的 ROI 结论(M34)

路线图对 D 档的定调是「重特性,先写最小原型测 ROI,不提前承诺」。 M34 给四个候选各做了一轮评估(两个真做了原型落地,两个只做纸面评估后就搁置)。

做掉的两个:模式匹配、枚举

两者都选「解析期脱糖」实现,理由是同一个:它们能表达成既有语句的组合, 于是三个执行器 + 两个跨语言宿主一行都不用改。这是本项目第四次、第五次 用这个手法(前三次:M23 超()、M24 集合字面量、M26 用 … 为)。

特性脱糖成实际改动
模式匹配__匹配_N__ = 待匹配值 + 如果/否则如果/否则 链只动 parser.py(约 200 行)
枚举类 X:(初始化/文本)+ X.成员 = 新建 X(…) + X.全部 = […]只动 parser.py(约 120 行)

代价也是同一类:脱糖能表达的语义上限就是既有语法的上限。 所以这两个特性都只做到「够用」:

那需要新的绑定语义,脱糖做不出来(== 不能同时绑定)。容器模式只能按值相等匹配。

要那些就直接用「类」——枚举是「一组有名字的值」,不是「简化的类」。

这个取舍是明确的:宁可要一个「三执行器行为必然一致、实现只有两百行」 的够用版本,也不要一个「语义更全但要在三处各写一遍、还可能对不上」的完整版本。 将来真有需求,再单独评估要不要升级成原生实现。

搁置的两个(附理由)

生成器 产出(惰性序列)——成本高,且脱糖路线走不通:

实现方式完全不同(树遍历要 CPS 变换、Python VM 要把帧存起来、C VM 要 改 JVal 布局并新增协程状态)。参考项目历史上新增一个 Val 变体就要 在四处 match 里各接一次(M33 的 Date/Table/Slice),而生成器要动的 是整个调用协议,不是加一个分支。

惰性序列;它们要的是「读大文件不炸内存」,而这个已经由 文件对象按行迭代 (M26)解决了——那条路径不需要生成器。

再开一档专门做,届时优先选「只在 Python VM 做、另两个执行器明确报错」 的分期路线(与集合 M24 的做法一致)。

多继承——MRO 复杂度与语言定位冲突:

语义(Python 的零参数 super() 依赖 MRO 里的「下一个」)。M23.4 的 超() 脱糖成 超(自身, "定义类名"),那个设计建立在单继承上, 多继承要重新设计。

三个真实项目没有一个用到多继承。

明确不做(成本低但需求弱)

只有「写」的问题。Python 要 global 是因为赋值默认创建局部变量; 基石的函数体如果没有 令,赋值就写外层——用 非局部 那样的显式声明 在中文语境里反而绕。真需要时用可变容器(列表/字典)当载体就够了。

和「删字典键」,前者对脚本语言意义不大,后者已有 字典.移除()。 加一个只能删名字的语句,收益看不清。

一条方法论沉淀

M34 再次验证了「先写最小原型」的价值,但方式与预期不同:真正的收获不在 「原型证明了可行」,而在原型撞出了三处既有缺陷(宿主缺类变量、 Rust 继续 后无法 中断、Rust 类型() 给错名字)。这些都不是 M34 引入的, 是「一直没被真实代码走到」的路径。

结论:D 档特性要连着真实用法一起验。写个语法规格就收工, 只会留下一堆「语法能用但一用就撞坑」的半成品。


D27. 类体校验放「解析期」一处拦下(M35)

背景:一个真实的「三执行器语义分歧」

写 M35 的收尾时顺手做了个对照测试(同一个类体在各执行器里跑一遍), 结果发现同一段代码,三个执行器给的答案互不相同,而且一个比一个糟:

类体里写了树遍历Python VM / C VM
打印("…")报「不支持 ExprStmt」静默忽略(副作用没发生)
x += 5报「不支持 Assign」当成 x = 5 静默算错
x[0] = 1报「不支持 Assign」抛英文内部错 'Subscript' object has no attribute 'id'
"说明"报「不支持 ExprStmt」静默忽略

这违反了项目的第 1 条硬约束(三执行器语义必须一致)。而其中 最危险的不是「报错」,是「静默算错」——x += 5 被悄悄当成 x = 5, 用户看到的是「程序跑通了,但结果不对」。

决策:在 parser 里校验,而不是在三个执行器里各挡一次

理由与前几次「解析期脱糖」同一个逻辑:三个执行器都要过 parser, 所以在 parser 一处校验,三处自动一致,且字节码/树都不用改。

规则定得尽量窄——类体只允许:

  1. 方法(函数 …)——OOP 的主体;
  2. 类变量(名字 = 值,只认一次性的 =)——注意 +=/下标/属性赋值

都不算「声明」,要写进方法里;

  1. 空类占位(单独一行 :)。

其余一律在解析期报「类体里只能写什么」并给出正确写法示例。错误定位用 _err_at(按 AST 节点的行列合成 token),因为块解析完之后 self._peek() 已经指到块后面去了,直接用会把下划线画到下一行。

顺带澄清一条被误记的「缺陷」

M32 的文档里记着一条「树遍历不支持类体里写 通过」。M35 查清了: 这是误判——通过 从来不是关键字(词法器里根本没有它),它是个普通名字; 如果 真: 通过 在三个执行器里都报「找不到这个名字」。 所以「类体里的 通过」不是「树遍历少支持了一个特性」,而是 「一个未定义的名字被字节码执行器静默忽略了」。

处理:不把 通过 升级成关键字(空块已经有 :;为它加一个关键字 要动三执行器 + 两宿主 + 关键字表 + 粘连检查表,收益不值)。 但报错时点名——因为从 Python 过来的人确实会写 通过:

错误 E0201:这里出现了不能写的内容(类体里不能写「通过」——它不是关键字,
只是个普通名字)
  2 │     通过
    │     ~^
  提示:空类用「单独一行冒号」占位:
类 空类:
    :

结论:「静默」比「报错」危险。凡是发现「某个写法在一个执行器报错、 在另一个执行器被悄悄忽略」的地方,统一到解析期是最省事也最一致的修法; 顺带把错误信息写成「说清原因 + 给出正确写法」,比单纯拒绝有价值。


D28. 调试器只做树遍历执行器 + 钩子只读(M37)

决策一:只在树遍历执行器上做,不追求「三执行器对称」

路线图 B1 的目标是「断点 / 单步 / 看变量 / 看调用栈」,而基石有三个执行器 (树遍历 / Python VM / C VM)与两个宿主。要不要一次做全?不做。

树遍历里「一个语句 = 一次 exec_stmt」是天然的暂停点:在 exec_stmt 开头挂 一个 before_stmt 钩子,断点、单步(进/过/出)、局部变量与调用栈全都由它推出; 三种单步只是「帧深」比较(下一步 = 深度 ≤ 暂停深度、跳出 = 深度 <), 实现里不需要第二处特判。

字节码执行器没有这种「语句边界」事件:栈机只知道指令,要么每步比对行号表 (慢且脆),要么加新指令;C VM 还要先让宿主回调能把帧传出来。同一份功能在 字节码侧的成本高一个量级。

于是 B1 只做树遍历,docs/调试器.md 与路线图如实标注「Python VM / C VM 暂不支持」,并把「字节码执行器的调试器」列为 B5 —— 等真实需求说话再做。 为「三个执行器对称」而做的功能,往往是最贵的那类:本项目已经在 M29/M33 为「补宿主」付过一轮长期投入,工具链不该再走一遍。

对比另一条路:用 Python 的 sys.settrace 做通用调试,连 C VM 都能顺带被调试到, 但它会侵入用户程序、性能不可控、行为依赖 CPython 实现细节。自己的语言自己插桩, 才谈得上「语义可控」(与 D1「技术栈透明」一脉相承)。

决策二:钩子只读 —— 调试器不许改变语义

钩子只读语句行号与环境,暂停时也不动环境。因此「带调试器跑」与「不带」的程序 输出必须逐字节相同,由 tests/test_m37_debugger.py 钉住(含 中断/返回/尝试 混在一起的控制流)。这条纪律比功能多少更重要——调试器要是能改变被调试程序的 行为,它给出的所有信息都不可信。

决策三:「退出」用 BaseException(DebugQuit),并且照跑 最终

DebugQuit 刻意不继承 JishiError:否则用户程序里一句 捕获: 就能把「退出」 吞掉(_do_try 里那句 except BaseException 是为把 Python 异常翻译成中文写的)。 配套在 _do_try 的控制信号元组里(与 中断/继续/返回 并列)显式放行它, 于是「退出」会照常执行 最终 块——像一次正常的控制流离开,而不是硬杀。

决策四:出错现场用「语句级快照」

错误是在语句执行到一半时冒出来的;等它传到 run(),_eval_call 的 finally 已经把帧弹干净了,此时再问「当前帧」只会得到模块顶层、变量还是上一帧的 (实现时真实踩到:错误出在函数「主」里,快照却写着「模块顶层」)。 所以 before_stmt 每执行一条语句就复制一份现场(行号 / 帧标签 / 环境引用 / 栈链),出错时用这份快照。

结论:工具链功能先在最划算的一处做扎实,把成本高的部分明确列成待办, 而不是硬凑对称;同时用测试把「不影响语义」这条纪律钉死。


D29. 增量同步:先量后做,「只做一半」要如实标注(M38)

背景:路线图写着「大文件有卡顿」——到底卡不卡?

先测再改(项目纪律:别猜瓶颈)。实测 LSP 每收到一次编辑(整文件 tokenize + parse):

行数2531253500320003
每键成本9.9 ms44 ms182 ms759 ms

一千行以上就有可感卡顿——路线图的说法成立,值得动手。

决策一:只做「分词」的增量,parse 保持全量,并在文档里写明

编辑只影响改动行之后的内容,所以前缀的 token 与词法状态可以复用 (依据是 tokenizer.LineAnchor 的七个值:缩进栈 / 括号深度 / 跨行三引号累积内容 与起点 / 是否在多行注释里)。实测改末行 18001 行从 359.7ms → 8.5ms(40 倍以上)。

但 parse 仍是全量的(5000 行约 78ms)。增量解析要维护「每个语法节点的子树 缓存 + 编辑后的失效范围」,复杂度高一个量级,而当前收益只有几十毫秒,路线图也没 要求。所以:做成一半,并把另一半明确标出来(docs/工具链.md 与路线图都写了)。 半成品的危害在于「看起来都优化了」,而不是「只优化了一半」。

决策二:前缀由自己算,不听客户端说的改动范围

客户端给的 range 只对它自己的文本成立;我们这边的文本可能因为版本乱序之类 的原因已经不一样了。所以增量起点用「按行文本比较求共同前缀」——这是自证的: 前缀相同的行,分词结果必然相同。代价是一次行级比较(远低于分词本身)。

决策三:位置单位按 LSP 默认的 UTF-16 码元

中文在 BMP 内一个字符算 1 个码元,emoji 这类天文平面字符算 2。要是按 Python 字符下标去应用编辑范围,光标在 emoji 之后就会改在错位置,而文档一旦被改坏, 后续所有诊断都是错的。所以入向(应用编辑)与出向(诊断/悬停位置)都换算, 并且专门钉住「emoji 前后两个边界」。

决策四:REPL 的行编辑核心与平台解耦,按键可注入

Windows 没有 readline,要自己用 msvcrt 逐键读;POSIX 可以交给 readline; 非终端必须退回 input()(脚本与 CI 不能被行编辑打扰)。三套路径如果混在一起写, 就变成「只能在真终端里手测」的代码。

做法:把 History / Completer / LineEditor 写成纯逻辑(按键序列与输出都 可注入),平台差异收在最外层 read_line 一个函数里。于是「上键取历史、Tab 补全、 退格删汉字、Ctrl-C 放弃当前输入」都能在单测里断言,Windows 那条真路径也能用 假 msvcrt 喂按键跑一遍。

决策五:格式化器只依赖词法层,不做完整语法分析

如果 真(漏冒号)这种半成品照样排版——用户可能正写到一半,格式化器不该因为 「还没写完」拒绝服务。相应地,「处理不了」只对应词法错误(未闭合字符串、 非法字符),报告时也分开说(「没格式化」要跑 --write,「处理不了」得先改代码)。

结论:性能类改动先量后做;做不完整就明确标注缺口; 凡是「只能在特定环境手测」的逻辑,都要想办法把核心抽成可注入、可单测的纯逻辑。

D30. 调试器扩到字节码执行器:靠「语句起始标记」,不靠猜行号(M39)

背景:D28 把调试器先落在树遍历上,理由很硬——那里「一个语句 = 一次 exec_stmt」是天然的暂停点。字节码执行器(Python VM / C VM)是栈机, 没有这种事件。于是问题变成:怎么让栈机也有「语句边界」?

候选:

  1. 每步比对行号表:指令带 line,行号一变就当语句边界。问题是行号一变

的地方远多于语句边界(一条语句跨多行、表达式内部换行),会停在不该停的地方; 循环回边(遍历 跳回 FOR_ITER、当 跳回条件)会让循环头每轮都停一次, 而树遍历只停一次——同一个断点在两个执行器里行为不同,正是最该避免的事。

  1. 加一条「语句」指令:要动 C VM 的指令集与 ABI,代价立刻上一个台阶。
  2. 在指令上打标:编译器本来就知道「这条指令是某个语句的第一条」,

把它记在指令上(Instr.stmt_line),VM 主循环一次属性读取就够。

决策:选 3。并且只标语句的首指令——循环回边落在语句中部,不落在首指令上, 于是循环头只停一次、循环体每轮都停,与树遍历的暂停点严格对齐。

配套的两个小决定:

指令,不补的话「树遍历能停、字节码停不住」。补完之后的不变式是 「打标行的集合 == AST 语句行的集合」,并拿 examples/ + tests/cases/ 全量对拍。 这条不变式在实现时抓出两个真问题:类体里的方法定义行、空块行,原先两个执行器 都停不住(statement_lines 却说它们可停 → 一个静默失效的断点)。

它天然就是执行器真正停得下来的那些行。

验收方式选的是「逐字对拍」:同一脚本、同一组断点、同一串命令, 两个执行器的会话记录必须一字不差(含变量表、调用栈、出错现场、退出 行为)。 这条测试比「各自看起来对」强得多——它把「两个执行器行为一致」变成了可执行的断言。

C VM 的判定:暂不做。它的帧与局部槽都在 C 侧,要做等于把「一个只读钩子 + 一张标记表」换成「结构体字段 + 宿主回调 + 中断约定」;而收益只是「用同一套 调试体验去调 C VM」。按「别为对称而做」,列为 B 线待办(细节见 docs/调试器.md)。

D31. M39 顺手挖出的四个潜伏缺陷:都不报错,只是「你以为有」(M39)

做 B5 的过程里,被「对拍」和「拿真实项目试」逼出来四个长期存在、谁都没发现 的问题。它们的共同点是:文档没写、测试没跑、一写就踩。

问题后果修法
跨行三引号字符串收尾引号独占一行时不补 NEWLINE多行字符串整个特性不可用(解析器把下一行并进同一句,报「这一行没写完」)。语料里没人真跑过它,只有格式化的用例碰到过tokenizer 去掉一个多出来的 and rest(M39)
类体里的方法定义行两个执行器都停不住语句行 说它可停、断点加在上面却从不停——静默失效解析/编译期补「空操作」+ 树遍历在类体循环里也调 before_stmt(M39)
类变量求值顺序与源码顺序不一致字节码执行器先造类、再按源码顺序求值;树遍历按源码顺序求值。带副作用的类变量在两个执行器里执行顺序不同(都不报错)字节码侧改成「在源码位置求值、存临时槽,最后挂上」(M39)
导入 文本 会打断 f-stringf-string 插值脱糖成 文本(值);模块「文本」正好遮蔽这个内建,于是任何 f-string 都报「模块「文本」不能直接调用」。文本 是本项目最常用的标准库名尚未修,先用 导入 文本 为 文本库 绕开;正解是给插值一个不会被遮蔽的内部转换(要动三个宿主),列入路线图

另外,一条「明确分歧」被对拍抓出来并如实标注(按 D25 的分类属于第 2 类 「一边报错一边给值」):函数里给外层同名变量赋值时,字节码执行器按 「赋值即局部」报「找不到这个名字」,树遍历却按「先向外找、找到就用」静默算出 另一个结果。修树遍历要给它加一遍「赋值名收集」(语义改动,得配全量对拍), 所以 M39 只标注不修:tests/test_cvm.py::test_inner_assignment_to_outer_name_is_local 用 xfail(strict=True) 钉住现状,哪天补齐了这条测试会失败,提醒把它改成正常断言。

结论:功能「写完了」不等于「能用」。两个执行器 + 一个真实项目 + 一条 全量不变式,比再读一遍代码有用得多——这四个问题一个都不是读代码能读出来的。

D32. 补上 D31 里那条分歧:树遍历的「赋值即局部」(M40)

背景:M39 的对拍挖出一条明确分歧——函数里给外层同名变量赋值时, 字节码执行器按 Python 的「赋值即局部」报「找不到这个名字」,树遍历却按 「先向外找、找到就用」静默算出另一个结果。当时判定为「一边报错一边给值」, 按纪律只能先如实标注(xfail(strict=True)),因为补它属语义改动。

做法:进函数时,把「本函数体里被赋过值的名字」全部先占上未绑定哨兵 (interpreter._Unbound)。于是:

关键选择:判定「哪些名字是局部」直接复用编译器的收集器(compiler._collect_stmts + _Scope.bound),不另写一遍。理由:那份逻辑已经决定了字节码执行器把哪些名字 当局部变量;另写一遍迟早会漂,而漂了就是「同一个程序在不同执行器里行为不同」 ——正是本项目最防的一类问题。代价是 UserFunction 要懒缓存一次收集结果。

报错文案是单一来源(runtime.unbound_local_error):三个执行器都调它。 对拍测试比的是 (输出, 报错全文) 整对,文案差一个字就判成「执行器不一致」 ——把文案集中到一处,比在三个地方各写一遍再靠人盯要可靠得多。

验收:tests/test_cvm.py 里那条 xfail 转成正常断言,另加一条「赋值前读 局部」的对拍;tests/test_m40_scope.py 20 项覆盖遮蔽 / 闭包 / 形参 / 解包 / += / 循环变量 / 两层嵌套 / 导入别名 / CLI 端到端。

D33. C VM 的调试器:加四个只读入口,不猜执行器内部(M40)

D30 给 Python VM 定的做法是「编译器打语句标记 + 主循环查表回调」。C VM 复用了 同一个思路,但多了两层约束:帧与值都在 C 里,且指令宽度是跨语言公开契约。

四个入口(cvm/include/jsvm.h,全部只读):

入口作用
jsvm_set_stmt_marks收下语句起始表(单独一张 int32 数组)
jsvm_set_stmt_hook装语句钩子(只在语句首指令回调)
jsvm_frame_meta问一帧的「代码下标 + 调用点」
jsvm_dump_locals / jsvm_dump_globals把帧变量/全局交给宿主转换

三个决定值得记:

  1. 语句起始表另发一张,不往指令里加字段。指令宽度(6 个 int32)是 Node /

Rust 宿主也按它读的公开格式,为一个调试功能改公开契约不划算。

  1. 钩子用独立的 setter(jsvm_set_stmt_hook),不动 jsvm_load_module 的

签名:不改已有 ABI,而且「要不要调试」与「能不能装载」本来就是两件事。

  1. 值的所有权用「借用」:debug_local 回调收到的 JVal 是借的,宿主当场

转成 Python 对象——不为调试发明第二套所有权规则。

「退出」是这里最贵的一处:它要穿 最终、又不能被 捕获 接住。做法是把 DebugQuit 当卸载类信号(is_signal 返回 3)交给 C 侧异常分发:跳过循环、 直接跳 finally_ip、END_FINALLY 后继续向外。为此在 C 侧补了两件事: END_FINALLY 检查「有没有待重抛的异常」(否则信号被静静吞掉),以及 make_error 不许包装 DebugQuit(否则被用户 捕获 接住)。

没做的一件事:就地求值(查看 表达式)。C 侧没有「在任意帧上求一个表达式」 的入口,要做得把编译好的求值代码塞进当前帧执行,属另一件事。会话明确说 「C 虚拟机暂不支持就地求值」,而不是给个错值。

结论:把「调试器要知道的事」压到最小的一组只读入口上,执行器内部就不必 为调试器让步。对称不是目的,能对拍才是——三个执行器的会话记录逐字相同, 这条断言比「三个都实现了」有意义得多。

D34. 内部保留名:用户写不出的名字才安全(M40)

f-string 插值原先脱糖成内建 文本(值)——而 文本 正好是本项目最常用的标准库 名,一句 导入 文本 就把它遮蔽,于是所有 f-string 当场报「模块不能直接调用」。 这个缺陷藏了很久:语料里没有「既导入文本又用插值」的写法。

做法:脱糖目标改成 $文本。$ 在词法阶段就是「无法识别的字符」,用户写 不出来这个名字,所以永远不会被遮蔽。三个执行器与两个宿主都注册它。

配套的一条纪律:内部名不许出现在用户可见的地方。新增 runtime.is_internal_builtin(判据两条:$ 开头,或说明以「内部用」开头), ai.build_lang_spec() 与编辑器补全都按它过滤——否则 $文本 会被当成可写的 函数推荐给用户(顺带修掉老早就在规格里的 检查实参)。

结论:「用户写不出来」是内部名唯一可靠的保护。靠约定、靠文档、靠 命名前缀都不行——只要用户能拼出那个名字,它迟早会被遮蔽或被误用。

D35. 标准库模块名不能撞内建名:集合 → 容器(M41)

路线图(§3.6 方向一)给 M41 定的标准库清单里有个「集合」模块(放 Counter 式的计数工具)。真动手时发现:集合 是本项目的内建函数名 (集合([1,2,3]) 去重造集合),同名模块一 导入 就会把它遮蔽——正是 M40 刚修掉的 f-string 遮蔽坑(D34)同一类问题。

做法:改名「容器」。理由两条:

  1. 避开遮蔽:导入 容器 与内建 集合(...) 井水不犯河水,用户不用记

「哪个名字要加别名」。

  1. 语义更准:这个模块装的是装东西的工具(计数、分组、取前),不是

集合类型本身——集合类型是内建 集合() 与它自带的方法(并集/交集/ 差集/对称差,M24 就有了)。

结论:标准库模块名先查一遍内建名与关键字。加模块是个「看起来零 风险」的动作,但名字撞上内建就是一次全局遮蔽——而且这种遮蔽只在「用户 同时用到模块与内建」时才发作,测试里不一定碰得到。

D36. 解析期脱糖的第二例:遍历 的多变量解包(M41)

令 甲, 乙 = … 早就支持解包(M25 还加了 *余),但 遍历 只接受单个 循环变量。examples/ai/few-shot.md 的示例 34 就写着 遍历 名字, 年龄 在 对们: ——AI 照着自己的 few-shot 写,必然报错。

做法:与 M26.2 的 用 … 为、M34 的模式匹配同一手法——解析期脱糖:

遍历 名字, 年龄 在 对们:       遍历 __遍历项_3_5__ 在 对们:
    打印(名字, 年龄)      →          令 名字, 年龄 = __遍历项_3_5__
                                     打印(名字, 年龄)

为什么这比「改三个执行器 + 两个宿主」好:解包赋值本身已经是成熟路径 (TargetList 在解释器与编译器里都有实现),脱糖之后五路一行都不用改, 行为必然一致。代价只是循环体里多一句赋值,以及临时变量名要用户写不出来 (用 __遍历项_行_列__ 前缀 + 行号,符合 D34 的「内部名」纪律)。

顺带发现的一个真缺陷(不是这次引入的):cvm_bind._cb_iter_next_batch 的批量快路径原先用 all(type(v) is int for v in chunk) 预扫描一遍再构造, M41 把它改成一趟(边判边装,遇到非整数抛内部哨兵回退)。第一版把哨兵写成 实例而不是类,于是 except (_struct.error, OverflowError, 哨兵) 在运行时 炸成「catching classes that do not inherit from BaseException」——而且只在 C VM 遇到「批量里有非整数」时触发(遍历 [[1,2],[3,4]] 这种嵌套列表立刻 中招,单层整数列表则一路正常)。

结论:「只在某一路才触发」的缺陷,只有对拍能抓住。这条如果只靠 「跑几个例子看看」,很容易漏——因为最常见的场景(整数列表)恰好不走那条 分支。这也解释了为什么 M41 的验收标准里写的是「五路逐字一致」而不是 「能跑通」。

D37. 有状态的标准库模块要「每次导入造一份」(M41)

日志(级别 + 前缀)与 测试(用例记录)需要跨调用保持状态。第一版把 状态放在 Python 的模块级全局里——单跑没问题,但同一进程里跑第二个执行器 就串:五路对拍时,树遍历先跑一遍(级别被设成 调试、用例记了 1 条), Python VM 紧接着跑同一段代码,读到的却是上一轮留下的状态。

做法:给这类模块一个约定钩子 __基石新建__()——do_import 每次导入 都调它,拿一份全新的状态字典(闭包捕获自己的状态)。三个执行器、两个 宿主各自独立,互不影响;同一进程里连跑两遍也不会累积。

配套改了 AI 规格扫描(ai._stdlib_api):有状态模块的函数定义在工厂函数 内部,扫描器要往里再看一层(_收集函数 递归进 __基石新建__ 的 body), 否则 日志 / 测试 的函数会从语言规格里消失——而规格是给 AI 看的唯一事实源。

结论:「模块级全局」在「同一进程跑多路」的架构里是错的。本项目的 核心验收手段就是对拍,而对拍天然会在一个进程里跑多个执行器——所以状态 必须跟着「一次导入」而不是「一个模块」走。

D38. few-shot 示例也要当代码测(M41)

examples/ai/few-shot.md 有 48 段示例,是喂给 AI 的「正确写法」样本。 M41 体检发现:这些示例从没人跑过,其中 4 段是真错——

示例错在哪后果
26 正则匹配变量名叫 文本,遮蔽了内建 文本(...)正则.匹配 收到 _Builtin,报「类型错误」
29 闭包计数器内层函数给外层变量赋值(M40 起算新建局部变量)报「名字「计数」在赋值之前被用到了」
34 多值解包循环遍历 名字, 年龄 在 …(当时不支持)语法错
37 排序字典按值频次.键值对() 这个方法不存在找不到方法

做法:加 tests/test_m41_stdlib.py::test_fewshot_examples_are_valid—— 把 48 段逐段抽出来跑,片段不自包含的按「环境缺失」放行(报错是 「找不到名字」「找不到文件」「网络」这类),语法/API 错误必须报出来。 4 段真错已按 M41 的新能力改写(示例 34 现在真能跑了)。

结论:喂给 AI 的样本本身就是代码,必须进测试。它们的错法还特别 危险——AI 会照着错样本写,而人不会去读那 48 段。

D39. 语言客户端手写,不引 vscode-languageclient(M42)

M42 的验收标准是「每条能力在 VS Code 里手工演一遍」,而扩展此前连 main 都没有 (纯语法高亮)。所以必须先有客户端。

做法:自己写传输层(editors/vscode-jishi/lsp-transport.js,约 150 行: Content-Length 分帧 + 请求应答 + 通知分发 + 服务器反向请求应答), extension.js 只做「编辑器 API ↔ LSP 消息」的翻译。

理由:要的只有三件事(分帧、请求应答、通知分发),而 vscode-languageclient 会带来一整套依赖树。这与本项目在 C 内核(不引第三方 C 库)、 正则引擎(自写)、zip(自写)上的取舍一致:能自己写清楚的就别引依赖。

顺带的好处:手写之后,服务端支持的每一项能力在客户端逐条可见—— 不会出现「装了客户端但某项功能悄悄没接上」;而且能写 verify-lsp.js, 用同一份传输代码脱离编辑器做端到端自检(25 项断言,已进回归)。

结论:为客户端的每一项能力都准备一条「不装编辑器也能验」的路。 装进编辑器之后才发现「点了没反应」,排查成本高得多。

D40. 重命名只做模块级符号:宁可少做,不可改坏(M42)

rename 看似简单(把同名的地方都换掉),真做起来有个硬问题:没有作用域分析。

改名会漏掉声明、只改用到的地方,直接改坏代码。

一起改掉——同样是改坏代码。

做法:只接受模块级符号(sym[&quot;container&quot;] is None),其余一律 prepareRename 返回 null,编辑器直接禁用重命名并显示「无法重命名」。

结论:「做不到」要说出来,不要给一个看起来能用、实际会改坏代码的编辑。 返回 null 让编辑器明确拒绝,用户最多觉得「这里改不了」;给错编辑则是 静默损坏——两者代价差一个数量级。

D41. 语义高亮:只上「语法文件看不出来」的色(M42)

语义高亮的差量编码有两处容易错,都是「单行测试发现不了」的:

  1. deltaLine &gt; 0 时 deltaStartCharacter 是绝对值(相对行首),

只有同一行内才是相对上一个 token。混了的话客户端只在跨行时解错位。

  1. 位置单位是 UTF-16 码元,而词法器给的行列按 Python 字符数算——

emoji 这类天文平面字符占 2 个码元,不换算就会偏。

另外两处分类上的取舍:

用户函数与变量 / 形参 / 属性。字符串、数字、注释交给扩展自带的 TextMate 语法 ——语义层再上一遍只会跟主题打架。

日志/测试)都是常用词,令 参数 = 1 之后 参数 指的是变量而不是模块。 顺序反了就会把用户变量上成模块色(这条有专门的回归钉子)。

结论:差量编码这类「只在特定维度才错」的协议细节,必须写一条专门 跨行的测试;否则线上表现是「偶尔串色」,而本地单行样例永远是对的。

D42. DAP 的线程模型:脚本线程阻塞,主线程读状态(M43)

背景:DAP 是「客户端随时可能来问」的协议——编辑器收到 stopped 事件后会 立刻连发 stackTrace / scopes / variables。适配器既要把程序跑起来,又要在 暂停时应答这些查询,还得让两者不打架。

取舍:

的核心循环,三个执行器的钩子都得跟着改——等于把已经稳定下来的调试内核拆了重搭。

线程阻塞在命令队列上。于是「暂停期间执行状态不变」是天然成立的, 主线程读栈与变量不需要任何锁。

代价是多一条线程与一个队列;换来的是调试内核几乎一行没改——只在 _pause 开头 加了一个 on_pause 钩子、在 _before_statement 里加了一个 pause_requested 判定、给三个执行器各加了一个「按帧取句柄」的方法(编辑器要能点任意一帧看变量)。

配套两条纪律(都踩过才写下来):

  1. stopped 必须在暂停判定成立时发,不能等打印完——编辑器收到就来问栈,

而那时脚本线程正要往下走去读命令,状态已经不会再变。

  1. 「继续类」请求只在确实停着的时候才入队;否则命令会攒下来,下次命中断点

时被立刻吃掉,表现为「断点失灵」。

D43. 调试走 VS Code 内置的适配器进程,不自写传输层(M43)

语言服务那条路我们自己写了 lsp-transport.js(约 150 行),理由是需要在 编辑器之外也能跑同一份代码(verify-lsp.js 与自检脚本都用它)。

调试这条路没这么做,改用 VS Code 的 DebugAdapterExecutable:它负责起子进程 并转发 DAP 消息,扩展侧一行传输代码都不用写。理由:

  1. 没有「编辑器外也要跑同一份代码」的需求——协议级验证用 Python 直接喂 JSON-RPC

更贴近真实(tests/test_m43_dap.py 就是这么做的);

  1. 自己转发意味着多一层进程与管道,出错时更难定位;
  2. 少一份要维护的代码,就少一份与 VS Code 版本漂移的可能。

代价:扩展里就没有一份可复用的 DAP 传输层,所以没有 verify-dap.js。 这是有意的取舍,不是遗漏——协议层由 Python 测试兜住,接线层面靠 tests/test_m43_dap.py 里的三条契约测试(扩展声明 ↔ 适配器行为)。

D44. 高亮表与片段:从语言事实生成,并用测试钉住(M43)

问题:syntaxes/.tmLanguage.json 与 snippets/.code-snippets 是手写的 副本。语言里新增内建、新的库模块,不顺手同步的话编辑器里就悄悄少一块颜色。 这类「悄悄过时」靠眼睛发现不了——M41 的 few-shot 体检就是同类问题: 48 段示例里躺着 4 段真错,谁都没注意。

做法:

  1. 高亮表里的四类词(关键字 / 内建 / 标准库模块 / 异常类型)**从

ai.build_lang_spec() 生成**,不再手抄;

  1. 测试比对「语言事实 ⊆ 高亮表」,忘了同步就红;
  2. 片段里的占位符填成默认值后必须能解析(块头自动补空块、嵌套占位符要认),

于是「插进去就是错语法」的片段跑不过测试。

顺带修掉的两个真错:新建 X() 与裸的插值文本都不能单独成语句(表达式 不能裸着当语句),原来的片段插进去就是错的——已改成 令 对象 = 新建 类名(参数) 与「把插值文本包在 打印(...) 里」。

边界:生成的是词表,不是整份 tmLanguage——正则结构、scope 名、字符串与 注释规则仍是手写的(那些管「怎么着色」,不是「有哪些词」)。

D45. 基石库层:用基石写的标准库,在编译期展开成外壳函数(M52)

问题:标准库要用三种语言各写一遍(Python / JS / Rust)。统计 这类纯逻辑 模块,同一套算法维护三份,改一处要改三处 —— M51 的漂移检测就是被这个逼出来的 (当时 Node 少 12 个函数、Rust 少 6 个,都是静默的)。 docs/架构评估.md 量出的账:宿主层 17365 行里 11161 行是标准库,占 64%。

解法:纯逻辑模块只写一份(jishi/stdlib-jishi/统计.jsh,就是基石源码), 在编译期展开进主程序:

函数 __基石库_统计__():                ← 把整个模块包成**一个函数**
    <模块源码整体缩进一级>
    返回 {"求和": 求和, "平均": 平均, …}    ← 顶层名字打包成字典

# 主程序里的 `导入 统计` 被就地改写成:
统计 = $造模块("统计", __基石库_统计__())

于是「库」在字节码层面只是一次普通的函数调用 —— 三个执行器与两个跨语言宿主 一个字都不用改,它们只是在跑同一份字节码。

为什么不是「运行时加载库的字节码」(走过的弯路)

第一版就是那样做的:把库模块的字节码塞进 payload[&quot;modules&quot;],宿主 IMPORT 时取出来执行。撞上一个死结:字节码的全局表是每个 VM 一份, LOAD_GLOBAL 查的是「当前 VM 的全局表」。在子 VM 里造出来的函数被主 VM 调用时, 会拿主程序的常量表去执行 —— 实测直接 IndexError: consts[ins.a] 越界。

要绕过去得动一堆东西:给函数加「我属于哪个 VM」、跨 VM 调用协议、payload 新字段、 两宿主的加载逻辑…… 而编译期展开把这些问题全绕开了 —— 库代码本来就是 「普通的基石函数」,本来就该参与同一份字节码。

顺带一提:第一版还撞过一个「代码编译进了产物、但用户代码跑的还是宿主层」的 bug —— _rewrite 只把子节点的替换结果写回去了,顶层节点的返回值被丢掉, 于是 导入 统计 原样留着。教训是「树重写要接住每一层的返回值」。

三个关键设计决定

  1. 包成函数,而不是「执行模块的 main 代码」 —— 模块顶层名字变成那个函数的

局部变量,模块内互相引用走闭包(cellvars/freevars),天然与主程序隔离。 (若直接执行 main:模块的函数定义会落进主程序的全局表,既污染命名空间, 模块内函数之间也分不清彼此。)

  1. 外面再套一层 $造模块(名字, 字典) —— 让 导入 统计 拿到的仍是模块对象:

统计.求和(...) 的语法一个字都不用改,打印(统计) 也还是 &lt;模块 统计&gt; (而不是一坨字典)。它是内部内建($ 开头,用户写不出来),只在编译器发射的 代码里出现 —— 与 $文本、检查实参 同一类。

  1. 导入 就地替换,位置语义不变 —— 写在函数体内、条件分支里的 导入 一样

展开,且「什么时候导入」不提前也不推后。

代价(都实测过,如实记)

取 6 轮最小值,计时放在程序内部所以不含编译):

引擎宿主层基石库倍数
树遍历5.61 µs212.02 µs37.8x
Python VM8.22 µs204.27 µs24.9x
C VM5.77 µs159.67 µs27.7x

比架构评估里说的 7 倍严重得多(那里只量了 POC 的 求和)。原因是库代码内部 每次调内建(类型()、长度()…)在 C VM 上都是一次宿主回调,而宿主层版 把这一切做在一次调用里 —— 又是「墙是宿主回调次数」那条老规律。 真实项目里标准库调用只有个位数到几十次,但只要出现在循环体内,这个倍数就是 致命的。所以 M53 的「性能分层」不是可选项:热路径函数必须留在宿主层。

不是「模块的绑定方法」。显示与宿主层一致(&lt;函数 求和&gt;),但调试器里分不出 「模块的」还是「局部的」。

函数头,所以解析后整体减 1 修正 —— 不修正就差一行),但宿主上显示的文件名 仍是主程序(字节码没有 per-code 的文件名);宿主层版报的是调用点。如实记录。

1.8 ms,占编译时间约 11%;其中 parse 本身 14 ms)。 另外改了 .jsh 之后,主程序要重新编译才看得到(库自身的编译产物有缓存, 按 mtime 失效)。

边界与纪律

实际评估后改成了 统计 —— 编码 的 文本字节/字节文本 需要 UTF-8 编解码, 而基石没有 序数/字符 内建,用基石写不出 UTF-8。 判据:只用列表 / 字典 / 文本 / 整数 / 浮点 + 现有内建能表达出来的,才进这层。

否则 统计.数学 这种内部依赖会漏到用户面前。

性能对比都靠它。

(tests/test_m52_jishilib.py 逐字节比两者)。

顺手修掉的一个真缺陷

stats 的 遍历 5(不可迭代)在基石库版下走到 C VM 的 GET_ITER 回调, 报的是「内部错误(宿主回调):TypeError: 'int' object is not iterable」—— 而 Python VM / 树遍历 / 两个宿主都是清楚的中文。查出来是 cvm_bind._cb_iter_new 里只有第二句 iter(...) 在 try 内,_Iter(o) 的构造在 try 外, 于是 Python 的 TypeError 直接冒到 _fail 兜底。已修。 纪律:宿主回调里用户能触发的错误要显式抛 JishiError, 别让 Python 原生异常冒到 _fail —— 那层的文案对真 bug 是正确信号,但对用户输入 就成了天书。

M53 的补充(2026-09-26)

又搬了三个模块:容器(7 函数)、迭代(12 函数)、参数(5 函数)。 至此基石库层有 4 个模块 / 34 个函数。

三条新经验(都进了 AGENTS.md §1 的搬库判据与 §2 的备忘):

  1. __X 导出为 X:有的导出名本身就是关键字,写不成函数名 ——

参数.新建 的 新建 就是(新建 类() 的 新建)。约定 __X → 导出 X。 顺带说明「外壳函数返回字典」这个设计顺手支持了「内部名 ≠ 导出名」。

  1. 别在 .jsh 里手写外壳函数:jishilib 会自动把整个文件包一层。

.jsh 就是普通模块顶层 —— 顶层的 令 天然是外壳函数的局部变量, 所以 _解析器表 这种模块级状态一行就够(而且每次 导入 天然一份新状态)。

  1. 性能分层的第一例:迭代.求和 直接转发给内建 总和。

库代码内部每次调内建在 C VM 上都是一次宿主回调,热路径尤其经不起。 代价:错误文案变成内建那套(类型仍一致,对拍只比类型)。

顺手抓到一个真 bug(在编译器,影响四个引擎):

「循环体里 尝试/捕获、捕获完继续循环」会把栈搞脏,最终报 TypeError: &#x27;RunTypeError&#x27; object is not an iterator。 根因:_c_try 生成的字节码里多了一句 DUP_TOP,而 MATCH_EXC 是 「弹出 cond、读栈顶的 err(不弹)、压结果」—— 复制出来的那份 err 再没人弹,永久留在栈上。三执行器 + 两宿主共用这份字节码, 所以四边一起崩;只有树遍历(走 Python 的 try/except)是对的 —— 五引擎对拍才抓得住它。已修,并加了两条回归测试。

Rust 宿主的两条缺口(M53 撞到,如实记,M54 修):

  1. 文本.查找 没实现(报「查找」方法调用未实现)。

⚠️ 这暴露出 M51 的漂移检测只比标准库函数名、完全不覆盖对象方法 —— 列表/字典/文本/集合/文件 的方法表同样是五份实现,该扩检测。

  1. 「外壳函数里的库函数接收主程序传入的函数」会崩(空不能调用)——

容器.分组、迭代.计数 都受影响(顶层函数互传函数是好的)。 这直接削弱「一份代码五引擎共用」的成色:库代码不可能从不收函数参数。 测试里用 RUST_GAPS 显式列出(带断言 —— 哪天 Rust 修好了会报红提醒更新)。

D46. 把「五引擎的账」结清:闭包单元的共享语义 + 方法漂移检测(M54)

背景:M52 / M53 把四个纯逻辑模块搬进基石库层之后,冒出来的坑几乎全在 「同一个语义、五个实现」这条线上。M54 集中还账 —— 一共抓到 4 个真 bug + 1 个机制缺口,每一个都值得记下来。

一、Rust 的闭包单元不共享(最重的一个)

症状:Rust 宿主上「嵌套的递归函数」直接崩 「空」不能调用。 顶层递归完全正常 —— 所以怎么写单测都碰不到,是 M53 搬 迭代(组合/排列 要递归)时撞出来的。

根因:Cell 在 Rust 侧是值语义的 struct,而 Python / Node 侧是对象。

指令Python / NodeRust(改前)
LOAD_CELL压单元本身(引用)Val::Cell(Box::new(cell.clone())) —— 克隆一份
MAKE_FUNCTION闭包持有同一批单元对象(*b).clone() —— 又把值拷进 Func
STORE_DEREFcell.value = v —— 改它里面的值cells[i] = Cell{..} —— 整体替换

于是闭包拿到的是独立快照:「先创建闭包、后给它赋值」永远失效。

为什么偏偏嵌套递归会炸:编译器给嵌套函数的产物恰好是 「先 LOAD_CELL 捕获自己的单元 → MAKE_FUNCTION → 再 STORE_DEREF 填入函数体」 (这是故意的,就是为了让递归引用解析到同一个单元)。值语义下, LOAD_CELL 那一刻单元还是空的,填进去的值闭包永远看不见 → 内层函数是 空。

修法:引入 pub(crate) type JCell = Rc&lt;RefCell&lt;Cell&gt;&gt;,Func.cells 与 Frame.cells 都改成 Vec&lt;JCell&gt;,LOAD_CELL 压同一个 Rc、 STORE_DEREF 改里面的值。与 JList / JDict / JInst 同一个手法 —— 「要共享可变状态就得共享对象」这条在宿主的每一处都成立。

为什么只有五引擎对拍抓得住:三个执行器都是对的(Python / Node 的 cell 是对象)。 跨语言宿主的语义一致性,只能靠真去跑那个宿主来验。

二、未绑定局部 / 单元:三种表现,两种是错的

同一个「没赋过值就读」,M54 之前五个引擎给出四种行为:

引擎局部(LOAD_FAST)闭包单元(LOAD_DEREF)
树遍历 / Python VME0301 找不到这个名字 ✅E0301 ✅
C VME0301 ✅E2000 运行期错误 ❌ 同一个执行器两套写法
Node「异常:找不到名字」✅(与 Rust 对齐)✅
Rust静默给 空 ❌❌静默给 空 ❌❌

走的是 runtime.unbound_local_error(单一来源,SemanticNameError)。 对拍测试恰好只覆盖了局部、没覆盖单元。已改成一并走单一来源。

静默给个错答案比报错难查一百倍(这是本项目的一条老规矩)。 修法:Frame 加一个 locals_set: Vec&lt;bool&gt;,bind_args 顺带报出哪些槽 真的绑上了;LOAD_FAST 先查它。没有动 Val(加一个 Unset 变体要碰 180 多处 match,风险不划算)。

⚠️ 跨语言宿主的边界(如实记):Node / Rust 没有 Python 侧那套错误码目录, 报的是「错误(异常):找不到名字「x」」;Rust 还不带行列位置 (它的 Instr 根本没解析 line/col)。所以测试只断言「三执行器同为 E0301、 两宿主首行相同」。要彻底对齐得让宿主也带一套错误码 —— 单独一笔账。

三、C VM 的「用后释放」崩溃(崩在 GC 里,最难查)

run_source_c 在 finally 里销毁 VM(host.vm = None),可这次运行 返回出去的基石函数值(CvmFunction)还活着。等 Python 的 GC 哪天顺手回收它, __del__ 就拿着 None 去回调 C 库 —— ctypes 把 None 当 NULL, C 侧解引用空指针 → Windows fatal exception: access violation。

最难查的地方是它崩在 GC 里:报错位置跟出错的那行代码八竿子打不着, try/except 捕不住段错误,而且它看起来不像测试失败(M53 的全量回归里 就有一堆这种噪音,被 tail 截掉了)。

定位手法(值得复用):git stash -u 把工作区回退到上一个里程碑状态复跑 —— 一样崩 ⇒ 既有问题,不是本次引入。这一步在「一堆红里分清新旧」时非常值。 修法:__del__ 先看 host.vm 还在不在;不在就什么都不做 (jsvm_destroy 已经把计数一起释放了)。

四、方法漂移检测:M51 那份检测只覆盖了「标准库函数名」

M51 的漂移检测是宿主自己 --dump-stdlib 报家底,测试拿 Python 侧的 --lang-spec 去比 —— 这个模式很好用,但它只比标准库的模块与函数名。 列表 / 字典 / 文本 / 集合 / 文件 / 小数 的方法表同样是五份实现, 一条都没测。代价是两笔漏网,而且都是偶然撞见的:

做法:把同一个模式扩到方法上。

它本来就是「对象方法」在 Python 侧的单一来源,M54 之前只放了 3 类。

不是另抄清单(宿主自报家底必须报实际能力,否则检测就成了摆设)。

模块级 const *_METHOD_NAMES,分派用它挡名字、dump 报同一张 —— 同源,所以不会各报各的。顺带统一了「不认识的方法名」的报错时机 (以前只有 集合/文件/小数 在取属性时报,列表/字典/文本 要等到调用**时)。

(所以「检测本身真的能报红」那条保险自动覆盖到方法), METHOD_KNOWN_GAPS 与 KNOWN_GAPS 同样是带断言的显式账。 另加 test_M54_补的方法真的能跑 —— 表里有名字 ≠ 真的能用, 把 M54 动过的方法在五引擎上真跑一遍、逐字节比。

顺带补齐:Rust 文本.查找(注意 str::find 给字节偏移, 要数前缀字符数才对得上 &quot;你好吗&quot;.查找(&quot;吗&quot;) == 2)、 Node 文件.刷新(fs.writeSync 本来就无缓冲,空操作)/ 位置 / 定位。

Node 的文件对象顺带重写:原来是 _buf + _eof 两个字段硬凑的, 没有位置概念 —— 这也是它当初压根没法实现 位置/定位 的原因。 改成「懒加载全文 + 字符位置」的模型,口径与 Python 侧一致 (读(n) 读 n 个字符、位置 给已消耗的字符数)。

五、还记着一笔没结的账(如实记,不塞进 M54)

文件.位置 在非 ASCII 上五个引擎给三个不同的值:

(&quot;甲\n乙\n&quot; → 10);Linux 上是 8;

(Python 的 _b_open 用默认 newline=None,Windows 上写出去的是 \r\n);

ASCII 下三方一致(test_M54_补的方法真的能跑 钉的就是这个)。 要彻底统一得:① 把 位置/定位 的口径明确定成「字符位置」; ② 改 Python 侧的 tell/seek 用法;③ 决定换行符要不要翻译(这会影响 读所有行 的字节口径)。每一小步都会动到既有行为,所以单独立一笔, 不塞进 M54(见 docs/路线图.md §九)。

D47. 「一个字符」到底是什么:序数/字符 + 文本按码点 + 内建的账(M55)

M55 回语言本体。主线看着是「加两个内建」,真正要定下来的是一件事: 「一个字符」在五个引擎里必须是同一个东西 —— 因为 序数/字符 就是 「码点 ↔ 字符」,它们要成立,先得这件事有唯一答案。

一、口径:一个字符 = 一个 Unicode 码点

序数(&quot;中&quot;) → 20013、序数(&quot;😀&quot;) → 128512;字符(20013) → &quot;中&quot;。 两者互为反函数。

为什么必须先定这个:三个引擎对文本的底层表示完全不同 —— Python 是码点序列、Rust 的 String 是 UTF-8、JS 的字符串是 UTF-16。 不定口径,长度 / 下标 / 查找 / 反转 就会各给各的答案。

⚠️ 而 M47 给 C VM 定文本时已经写死过这条(「文本是码点序列」, compare_fast 直接 memcmp 就与 Python 的按码点比较逐位一致)—— M55 只是把 Node 侧补齐到这个口径。

二、🔴 Node 侧「文本 = UTF-16 码元」的真 bug

M55 实测(同一段源码、同一份字节码):

表达式Python / RustNode(修前)
长度(&quot;😀&quot;)12
&quot;😀&quot;[0]😀�(半个代理项)
&quot;a😀b&quot;.查找(&quot;b&quot;)23
反转(&quot;a😀b&quot;)b😀a乱码(把 😀 拆成两半再反)

有意思的是 Node 自己内部就不一致:切片 与 遍历 早就用了按码点的 [...s] / for…of,只有 长度 / 单下标 / 查找 / 反转 四处漏着。 所以修起来是四处小改(cpLen / cpAt / cpIndexOf 三个小助手), 不是重写文本层。

判据(值得复用):凡是「按位置处理文本」的操作,都要按码点; 按子串工作的(拆分/替换/包含/大写)跟位置无关,别改 —— 改了既对不上 Python,也不会更好。

三、内建的账:第三个「宿主自报家底」的口子

M51 建了标准库的漂移检测(--dump-stdlib),M54 建了对象方法的 (--dump-methods),但内建函数一条都没测。而「加一个内建要同时动 Python / Node / Rust 三处」,漏一处的症状正是「在某个引擎上没定义过这个名字」。 M55 补上 --dump-builtins(过滤规则与 Python 侧 is_internal_builtin 对齐: 去 $ 开头、去「内部用」、只留真正的内建函数)。

建起来的当场就抓到两笔(Node 与 Rust 上同时缺):

顺带还带出「断言错误 这个捕获类型宿主也不存在」—— 于是 捕获 断言错误 在宿主上直接报「找不到名字」。

⚠️ 超 不参与比对:它是编译器形式而不是可当值的内建函数 (只在类的方法里合法,别处一写就被编译器拒掉),new_builtins() 里有它 只是为了让名字能解析。测试里用 BUILTIN_EXEMPT 显式排除并写明理由。

Rust 的 是实例 还牵出一个结构问题:那边建类时是「把基类的方法表 展开合并」,基类指针用完就丢 —— 没法沿继承链判断。超() 之所以一直 没事,是因为它走合并后的方法表,压根不需要链。 修法:给 Class 加 祖先链的序号(base_ids,建类时算一次), 用 id 做身份比较 —— 与 Python 侧 _instance_of 同语义。

四、顺手还的两笔「报错」账(都属于参数个数)

内建参数个数不对时,五个引擎给出四种行为:

现状(M55 前)M55 之后
三执行器漏 Python 原生英文:_b_len() missing 1 required positional argument: &#x27;v&#x27;「「长度」需要 1 个参数,但传了 0 个」(E2002 类型错误)
Rust12 个内建直接 panic(thread &#x27;main&#x27; panicked)补齐 30/30,文案与 Python 侧逐字一致
Node文案不准(「「空」没有长度」),但不崩仍是 20 个(→ M56,见下)

三条要点:

  1. Python 侧的收口点在 call_value,不只在 __call__:

call_value 里有一句 fn = fn.fn,把 _Builtin 拆成裸函数、 绕过 __call__ —— 只改 __call__ 会「改了却毫无效果」(实测踩过)。 所以抽了个 check_arity,两个入口共用。

  1. _fn_arity 用 inspect.signature 自动推:不逐个内建声明参数个数

(30 个内建、三个引擎,手写必漂)。*args 记为「不限」。

  1. need_builtin_args 与 need_args 在宿主侧要分开(Node 早就是这样):

内建说「「长度」」、方法说「方法「追加」」——称呼不同,文案就该不同。 Rust 原先只有一个 need_args,所以内建的报错里会冒出一个「方法」前缀。

五、M55 没做完、明写进 M56 的三条

  1. Node 的 20 个内建仍缺参数个数检查。不是漏改,是要改签名:

Node 的内建是 (v) =&gt; … 形式,而 needBuiltinArgs 需要 args 数组, 得先改成 (...args) =&gt; …。清单已备好(见 docs/路线图.md §9.4.1)。

  1. Rust 的 捕获 为 e 拿到的只是异常类型,消息丢了

(Val::ExcType(t) 只带类型名)—— 打印(e) 给「断言错误」。 属宿主异常对象模型的缺口,与「Node 打完是 Error: 故意失败」同族。 (M55 顺手把 Node 的 JishiError.toString 补成「错误(类型):消息」, 至少把那个英文 Error: 从用户眼前去掉了。)

  1. 文件.位置 / 定位 的口径(M54 记的账)+ 换行符翻译(D46 末尾)。

M55 的取舍说明:本来可以顺手把 Node 的 20 个也改掉,但那是 20 处签名改写 + 逐条对拍,属于「报错质量」这件事本身。M54/M55 一路挖出来 的报错类欠账已经有 7 条(见 §9.4.1),攒成 M56 一次收口比零散地 顺手改更可控 —— 这也符合「一里程碑只摊一件大事」。


D48. 内建体检:从「比名字」到「真跑」(M56 第一批)

一、为什么会有这个工具

M55 建了内建的名称漂移检测(--dump-builtins),建起来当场抓到 断言 / 是实例 在宿主上根本没有。但那套检测只比名字 —— 而 M53 已经吃过一次亏:「名字在表里 ≠ 真的能调用」(Rust 缺 文本.查找 就是靠五引擎对拍才发现的,名称检测一路绿灯)。

更要紧的是 M55 自己留了个错误豁免:

pythonBUILTIN_EXEMPT = frozenset({"超"})   # 注释:「超 是编译器形式,宿主不需要有它」

这个判断是错的。 编译器把 超().方法(x) 脱糖成 超(自身, &quot;定义类名&quot;), 字节码里就是 LOAD_GLOBAL 超 + LOAD_FAST 自身 + LOAD_CONST &quot;类名&quot; + CALL 2 —— 它是个货真价实的运行期内建。后果:

tests/test_m23_lang.py 从 M23 起就在测 超(),但只测 Python 侧。

一条被豁免的项,就是一个不会被任何测试碰到的角落。

二、工具做了什么(tools/probe_builtins.py)

三节,都是真跑,不看源码:

  1. 名字清单:三侧同宽(沿用 --dump-builtins,与分派同源);
  2. 正常用法:给每个内建一条最小可跑程序,五个引擎跑,输出逐字节一致;
  3. 参数个数错:名字() —— 三执行器同类同码、两宿主首行一致,

并含 Rust panic 扫描(M55 之前它 12 个内建会直接 thread &#x27;main&#x27; panicked —— 那不是报错,是宿主崩溃)。

写这个工具时自己踩的坑(记下来):输入() 在进程内跑会真的去读 stdin。 第一版没喂输入,工具什么输出都没打出来就被 SIGTERM 了。 redirect_stdout 管不到 stdin,得自己换 sys.stdin;再顺手把 stdout 设成 行缓冲,这样中途中断至少能看到跑到哪了。

三、体检结论(2026-09-27)

检查结果
名字清单(三侧同宽)✅ 各 30 个
正常用法(每个内建 × 五引擎)✅ 30/30 逐字节一致(含 打印(超()) 给 &lt;超 乙&gt;)
参数个数错 · 三执行器✅ 30/30 同类同码(E2002 类型错误)
参数个数错 · Rust 不 panic✅ 全部
参数个数错 · 两宿主彼此一致⚠️ 16 个不一致 —— 全是 Node 那边的问题

16 条不一致分两类(完整对照见 docs/路线图.md §9.4.1):

Rust 统一是「「长度」需要 1 个参数,但传了 0 个」。

写法Python / RustNode(M56 前)
文本()报「需要 1 个参数」返回「空」
类型()报「需要 1 个参数」返回「空」
退出上下文()报「需要 1 个参数」返回「空」

不报错的错答案,比报错文案不准难查得多 —— 这是「宿主对参数个数太宽松」 的老毛病(M51 修过 kwargs 版:以前宿主对命名参数「收下、忘掉」)。

一条判据(写用例时定下的):位与/位或/位异或/左移/右移 都是 严格二元(位与(a, b)),不是变参 —— 多给一个参数是一致的参数错, 但跑不通,所以「正常用法」的用例只给两个。

两条「没法构造参数错」的(记在工具的 SKIP_ARITY 里):超(脱糖成固定 2 实参)、打印/输入/集合(0 个参数本来就合法)。

四、超 的修法

Node:有个天然优势 —— JishiClass 保留了 base(M55 给 是实例 用它做继承链判断时留下的)。所以照着 Python 侧补三样就够: JishiSuper 类、getAttr 的 Super 分支、newBuiltins 里的 超。

Rust:那边建类时是「把基类的方法表展开合并」,合并完 把基类指针丢了(只有 base_ids 一串序号,没法反查回类对象)。 所以必须给 Class 补 base: Option&lt;Rc&lt;Class&gt;&gt;:

rust// M56:`超()` 必须沿链找「定义类」再取它的基类,光有序号不够
base: Option<Rc<Class>>,

连带的两个细节:

  1. BUILD_CLASS 里原本是 for (k, v) in base.methods(移动基类)——

要保留 base 就不能再移动,改成借用迭代 + clone;

  1. Val 加 Super(Rc&lt;SuperVal&gt;) 变体,display 的穷尽 match 要补分支

(&lt;超 乙&gt;,与 Python 侧 JishiSuper.__repr__ 一致)。

修完五引擎连 打印(超()) 都是 &lt;超 乙&gt; 逐字一致。

五、教训

  1. 豁免必须能被复核,否则它就是藏 bug 的地方。

现在 BUILTIN_EXEMPT 是空的,但常量保留着 —— 以后真要豁免谁, 必须在这里写清理由。理由写不出来的,多半就是 bug。

  1. 「只比名字」的检测只配当第一道网。 名字同宽 ≠ 能用;

要真跑一遍,而且要用真实用法(多级继承 + 超().初始化(...) 链, 而不是 超() 单独一句)。

  1. 跨语言宿主老是在「保留结构」这件事上偷工:Rust 丢基类指针(本 D)、

丢 Class 的字段语义(D46 的 JCell)、丢错误消息(D48 里记的待办, D49 已补)—— 同一个模式:「够用就先不存」,等到某个特性真的要用时 才发现拿不回来。


D49. M56 第二批:JS 的签名陷阱、宿主异常对象、错误码索引

一、(v) =&gt; … 是一个会「静默成功」的签名(Node)

体检报的那 16 条里,Node 侧最重的是3 条「静默成功」:

写法Python / RustNode(改前)
文本()报「需要 1 个参数」返回「空」
类型()报「需要 1 个参数」返回「空」
退出上下文()报「需要 1 个参数」返回「空」

根因不是忘了写检查,而是签名写成了 (v) =&gt; …:

js'文本': new Builtin('文本', (v) => pyStr(v)),   // 0 个实参时 v === undefined

JS 不会因为少传实参而报错 —— 形参就是 undefined,函数体照跑。 要拦住必须先把签名改成 (...args) =&gt;,再在拿到实参之后做检查:

js'文本': new Builtin('文本', (...args) => {
  needBuiltinArgs('文本', args, 1, 1);
  return pyStr(args[0]);
}),

19 处(16 个公开 + $文本/$造模块/检查实参)都改了;另加 needBuiltinArgsAtLeast —— 最大/最小 是变参(0 个不合法、但个数不限), 文案与 Python 侧 _b_max、Rust 侧 need_at_least_args 逐字一致(「至少要 N 个参数」)。

顺带发现 Rust 侧还有 4 处自己抄的文案(精确 少「,但传了 N 个」、 范围 没书名号、进入上下文/退出上下文 多了个空格)—— 说明「统一走一个检查 函数」这件事没做彻底,改完现在 16/16 逐字一致。

教训:「不报错的错答案」比「报错文案不准」严重一个量级。 后者用户至少知道出事了;前者一路算下去,等发现时早不知道错在哪。 体检(tools/probe_builtins.py)逼着每个内建真跑一遍,才把这类问题翻出来 —— 只比名字的检测对它完全无感。

而且这个坑有两半 —— 体检当场只测了「少给」那一半。

补完「少给」之后顺手验「多给」,又抓到 输入:

写法PythonNode(改前)
输入(1, 2, 3, 4, 5)报「需要 0 到 1 个参数,但传了 5 个」静默忽略多余的(拿第一个当提示语)

原因是默认参数把多给的实参吃掉了((prompt = &#x27;&#x27;))。与「少给」是同一类问题, 只是方向相反。修完把「多给」也做成体检的第四节(SKIP_TOO_MANY 只排除 打印/最大/最小 这三个本来就没有个数上限的),这样以后不会再漏。

二、Rust 的异常对象模型:同一个「值语义 vs 对象语义」又栽一次

捕获处原来是:

rust// 把异常作为特殊值压栈(这里简化为放一个字符串标记 + 异常类型)
self.stack.push(Val::ExcType(e.type_name.clone()));

源码注释自己写着「简化为」—— 代价是:

修法是新增 Val::Exc(Rc&lt;ExcValue&gt;)(type_name + message):捕获时压带内容 的对象,并补上 display、类型/消息 属性、exception_matches、以及 重抛时保住消息(抛出 e 不再变成「错误(类型错误):」)。

这与 D46 的 JCell、D48 的基类指针是同一个模式:宿主实现里凡是要「以后还要 用到」的东西,偷工省掉之后都得在某个时刻补回来。这已经是第三次了, 下次写宿主代码时先问一句:这个字段三个月后会不会有人要?

⚠️ 顺带查清一条更要紧的事:Rust 的 JishiError 只有 type_name 和 message 两个字段 —— 这就是「Rust 的全部报错都没有行列位置」的根因。 补它要动结构体本身与所有 err(...) 调用点,属第 5 条(宿主错误信息模型)的事。

三、错误码索引:jishi 错误 &lt;码&gt;

要解决的问题:报错里已经给了 E0301,但「E0301 是什么、我该怎么办」 以前只能翻源码 —— 对用户是门槛,对模型是「猜一个修法」。

设计上守住了项目的两条老规矩:

  1. 单一来源:码与标题从 errors.py 的 JishiError 子类自动抽,

errcodes.py 只写说明(什么意思 / 常见成因 / 怎么改),不重复码。 两条约束都用测试钉住:errors.py 有的码必须有说明;说明表里不许有 errors.py 没有的码 —— 「加了错误类型忘了写说明」和「删了忘了删」都报红。

  1. CLI 不是 markdown 渲染器:说明文案里不许写 加粗(会字面显示出来),

用「」代替 —— 这条也有测试钉着。

顺手做的两件事:码的写法容错(301 / E301 / e0301 都能查到,因为用户 从报错里抄的时候常常少抄字母或前导零);--ai-card 末尾加一节 「报错怎么读、怎么查」(模型知道有 jishi 错误 &lt;码&gt; 这个口子, 就能自己查而不是猜)。

四、一条「账」本身也会过时

路线图里原写第 6 条:「最像的候选名推荐 —— 找不到名字「prnit」 现在不给候选, runtime.py 已有 最相似(LSP 在用),接进报错即可」。

实测发现描述是错的:

(lookup_name 里的 difflib.get_close_matches);

给候选反而误导;

所以要跟第 5 条一起做。

教训:待办清单也是会过时的。写下的「现状」如果没有当场实测,就该 标成「未核实」。这次是动手前先跑了一遍才发现的 —— 否则会照着一句错描述 去实现一个本来就不需要做的东西。


D50. M56 第三批:文件的三处口径、Rust 终于有行列了

一、「ASCII 上一致」不等于「口径一致」

第 3、4 条都是同一类问题:同一段程序,五个引擎给不同的东西。

项PythonNodeRust
非 ASCII 上的 文件.位置6(tell() 的字节 cookie)4(字符数)6(字节偏移)
读(n) 的 n字符字符字节
写出的换行\r\n(Windows)\n\n

关键是:口径其实早就定好了 —— tests/test_m26_lang.py::test_file_position_and_seek 在 M26 就写死「读 2 个字符 → 位置 = 2」,Node 侧 M54 也是照它实现的 (源码注释里就写着「口径与 Python 侧对齐」)。

但那两条测试只在 ASCII 上跑:&quot;abcdef&quot; 里一个字节恰好就是一个字符, 于是 tell() 的字节 cookie 与「字符数」看不出区别 —— 口径漂了也全绿。

教训:锁口径的测试要用「会让口径分叉的数据」。 中文、emoji、CRLF 就是这类数据;只用 ASCII 测「位置」,等于没测。

二、顺手抓到的比原账更重:Rust 的 读(n) 按字节

read_n 的注释就写着「读几个字节」。于是 读(4) 在「abc中文」上取到 abc + 汉字的前 1 个字节,解码时把汉字切成两半,报:

错误(文件错误):文件内容不是 UTF-8 文本,读不出来

而那个文件明明是合法的 UTF-8。 用户看到这句会去查文件编码、查保存方式 —— 方向全错。

教训:误导性的报错比不报错更耗时。报错说的是「文件不是 UTF-8」, 而真正的问题是「我们按字节切了字符」—— 报错把锅甩给了文件。

三、Rust 的报错:一处填、全场受益

「Rust 的所有报错都没有行列」听着像要动几百处 err(...)。实际查下来, 根因是两个很小的事实:

  1. JishiError 只有 type_name / message 两个字段(补两个 Option&lt;i64&gt; 即可);
  2. 反序列化时把 line/col 丢掉了 —— 字节码里一直带着

(每条指令 6 个数字:op,a,b,c,line,col),Rust 的 Instr 只解了前 4 个。

而「补位置」这件事也只需要一处:execute 在错误冒泡时统一填 (fill_error_loc)—— 因为所有错误(内建抛的、VM 抛的、抛出 的) 最终都要从这里经过。再配合主循环每步把 ip 写回帧,就能查到「正跑到哪一行」。

通用手法:找「所有路径都会经过的那个点」,在那里做统一处理, 而不是去每个产生错误的地方各写一遍。这与 D46 的「同一件事的报错要走 单一来源」是同一个道理,只是这次用在「补信息」上。

四、错误码的闭环

给宿主加错误码,价值不在「多一行字」,而在接上了 jishi 错误 &lt;码&gt;:

错误 E2001:不能除以零
  ← 第 3 行第 6 列

用户(和模型)拿到这个码,可以直接 jishi 错误 E2001 查「什么意思 / 常见成因 / 怎么改」—— 两条本来独立的账(第 5 条的「宿主码」、第 7 条的「错误码索引」) 在这里合上了。

⚠️ 但只给能确定的码:宿主的「异常」是个笼统的类型(找不到名字、属性不存在、 参数个数不对…都归它),标成 E0301 会骗人。所以那张表只有 7 条 (除零 / 类型 / 索引 / 键 / 值 / 文件 / 断言),其余宁可不给(有测试钉住)。

教训:宁可少给,不要给错。「有一个看起来对的码」比「没有码」更糟 —— 用户会照着那个错码去查,查到的是不相干的解释。


D51. M56 第四批:把 difflib 复刻进两个宿主

一、为什么不用「编辑距离凑合一下」

宿主缺「你是不是想写」的候选名,最省事的做法是各写一个 Levenshtein。 但那样三侧的候选可能不一样:

两者在边界情形下排序不同 —— 于是同一个拼写错误,Python 说「想写「长度」」、 Rust 说「想写「整数」」。用户会以为另一侧才是对的,或者以为两边都坏了。

教训:「三侧一致」是这类功能的硬指标,不是加分项。 一个会给出不同答案的辅助功能,比「没有这个功能」更伤害信任。

所以选择了复刻。代价是要把算法抄准 —— 而这引出了下面两条。

二、两个「抄错就分叉」的点

① DP 版 vs 暴力版:同样是「最长公共子串」,结果可能不同

difflib 的 find_longest_match 用 DP(j2len),并且是:

pythonif k > bestsize:            # 严格大于 = 并列时保留**先遇到**的

「先遇到」的判据是 i 与 j 的遍历顺序。自己写一个「暴力枚举所有子串取最长」 看着等价,但并列时会选中另一个块 —— 而 get_matching_blocks 是递归找的, 块不一样,M 就不一样,ratio 就分叉。

② 元组顺序:是 (分数, 名字) 而不是 (名字, 分数)

CPython 3.13 的实现是:

pythonresult.append((s.ratio(), x))
result = _nlargest(n, result)      # == sorted(..., reverse=True)[:n]

分数在前,所以同分时按名字比较 → 名字降序。 而「保持名字池原序」是另一种排序。实测:3000 组随机对拍里 103 组顺序不同。

教训:「我读懂了这段代码」和「我抄对了这段代码」是两件事。 用对拍把两者分开 —— 见下一条。

三、方法论:先在能对拍的地方把算法钉死

复刻 difflib 的正确顺序不是「写完 Node、再写 Rust、然后一起调」:

  1. 先在 Python 里写出复刻版(同一门语言,能直接与 difflib 逐项对拍);
  2. 跑 4000 组随机 ratio + 3000 组随机候选,直到全一致;
  3. 再把这份已经验证过的算法翻译成 JS 与 Rust(翻译只可能引入语法错误,

不会引入算法分歧);

  1. 最后用五引擎对拍钉住三侧输出一致。

好处:调试成本被压到最低。第 2 步一旦不一致,立刻知道是算法抄错了 —— 而不是「到底是 Node 的某个边界有问题,还是 Rust 的浮点有问题」。

这套方法能推广到任何「三侧要一致」的复刻:先在最容易对拍的那一侧 把算法写成可对拍的形式,对拍通过再翻译。

四、顺带踩到的:JS 的字符串比较不是按码点

候选名要排序,就得比较字符串。三种语言的默认行为各不相同:

比较依据与 Python 的码点序
Python码点—(基准)
JSUTF-16 码元✗ 补充平面字符会排错位
RustUTF-8 字节✅ 恰好一致(UTF-8 保序)

所以 Node 侧写了 cpCompare(按 Array.from 后的码点比),Rust 侧直接用 cmp。

这是 M55「一个字符 = 一个码点」在排序上的又一次落地。 候选名几乎不会是 emoji,但既然定了规矩,就不留「只在特殊字符上分叉」的坑。


D52. M49:自建分发源的落地,与「521 不是源站坏了」

背景:ICP 备案完成(基石.cn,苏ICP备2026073372号),M49「自主分发」可以落地。 目标不是「再挂个官网」,而是让 jishi 安装 在大陆真的能装上 —— 原先默认源是 raw.githubusercontent.com,大陆访问时通时不通。

一、两份索引,各自自洽

服务器上那份索引(部署时生成)下载地址指向自己;仓库里那份(推 GitHub)仍指 GitHub。

索引来源索引里的下载地址效果
自建源(基石.cn)基石.cn/packages/下载/…索引 + 包都在境内,实测 0.11s
GitHub(兜底)raw.githubusercontent.com/…慢,但能用

为什么不干脆只维护一份「下载地址指自建源」的索引推给 GitHub: 那样自建源一挂,谁都装不了 —— 索引拿得到、包拿不到。 「慢」可以忍,「装不上」不行。两份索引各自自洽,自建源故障时是整条链路 一起退到 GitHub,不会半路断掉。

📌 这也是「多源回退」的正确粒度:回退要按「整条链」退,不是只退第一跳。

二、🔴 「HTTPS 打不开」的定性过程(这一段最值钱)

现象:http://基石.cn/ → 200,https://基石.cn/ → 521 Connection Reset by Origin Server。

按直觉猜了三轮,全错:证书不对?CDN 缓存了 521?回源地址配错了? (TLS 握手其实是成功的 —— TLSv1.3 + 正确的证书,说明证书没问题。)

定性靠的是源站日志:发一个 HTTPS 请求、再发一个 HTTP 请求,然后看源站 access.log:

请求源站日志结果
HTTP✅ 有记录(CDN 回源节点的 IP)200
HTTPS❌ 完全没有记录521

HTTPS 请求根本没到源站 80。唯一的解释是:CDN 对 HTTPS 请求走的是另一条回源 路径(HTTPS 回源到 443),而源站没有任何东西在 443 上监听。

修法:源站也开 443(Let's Encrypt 证书 + tools/nginx-jishi-tls.conf)。 这样两种回源协议都能应答,不用去赌 CDN 那侧是怎么配的 (另一条路是在 CDN 控制台把回源协议显式改成 HTTP —— 但那是「依赖对方的某项配置」, 不如让源站两种都能接)。

方法论:access.log 里有没有那条请求,比猜配置快十倍。 它一刀切开三种可能 ——「源站坏了 / 链路坏了 / CDN 坏了」。 而且它顺便排除了一个陷阱:CDN 缓存会让「看起来正常」变得不可信 (HTTP 那 200 有一半是缓存给的,不代表回源通)。 所以做这种判断要用带随机参数的 URL 破缓存,或看源站日志本身。

三、⚠️ 大陆服务器拉 GitHub 的真实数据

用户问「中国的服务器拉 GitHub 是不是就会超时」——是的,实测:

方式结果
git ls-remote✅ 能通(只读元信息)
git fetch --depth 1(约 3MB)❌ 300 秒超时
tarball(codeload.github.com)⚠️ 一次成功,另一次中途 IncompleteRead

⇒ 部署改为本地构建 + scp(tools/deploy_push.sh);服务器自己拉只当备用。 而这组数据本身就是「必须自建分发源」的证据:凡是「部署/安装依赖 GitHub」的环节, 在大陆都是不可靠的。

四、几个具体的实现选择

① 超时故意调短(6 秒)。原先 15 秒 × 2 个源 = 最坏干等半分钟。 多源回退的前提是「快速失败」—— 不然第一个源慢慢超时,用户早就以为程序卡死了。

② 单个源不通不算「环境异常」。jishi 医生 里备用源不通只报 [警告], 只有全都不通才置 ok = False。否则用户会去修一个本来不需要修的东西 (备用源在大陆常常不通,但主源好好的)。

③ 提示要给「做法」,不是给「原因」。全都读不到时列清试过哪些地址和失败原因, 然后给三条能照做的路:--index &lt;别的源&gt; / --local-zip 包.zip / --local-dir 目录。

④ 抓到一个既存 bug:encode_url 会把 Windows 盘符的冒号编成 %3A (file:///C:/… → file:///C%3A/…),之后 urllib 就走不通了。 而 file:// 的 base_url 是注释里明说的预期用法(本地镜像)。 —— 是「用本地文件当索引源」写测试时踩出来的:测试里换个真实的数据形态, 就能把只在这条路上才犯的错翻出来(同 M56 的教训:只在 ASCII 上测「位置」等于没测)。

⑤ 站点规则单独成片段。80 与 443 两个 server 块 include 同一份 nginx-jishi-locations.conf:复制两份迟早会漂,而「只有一个端口有问题」的 bug 最难查(在 HTTP 上试半天,问题只在 HTTPS 上)。

五、顺手修的一处「提示误导」

导入 X 失败时说「试试:导入 随机 / 导入 数学」—— 但如果本地装着同名包, 用户真正想写的是 导入 X 从 本地包(本地包与标准库是两种语法)。 这时指向几个不相干的标准库模块是反向指导。现在装了同名包就直接给正确写法。

📌 教训与 M56 的「候选名推荐」同源:报错的价值在于「下一步该做什么」。 「找不到 X」后面接什么,取决于用户本来想做什么 —— 能判出来就别给通用建议。


D53. R0:先立基准再动手,以及「工具分不清事实与事故」

背景:R 线要把前端从 Python 换成 Rust(见 docs/架构迁移路径.md)。 这种改写最容易出的错不是「崩了」,而是「悄悄变了」—— 某个 token 的列号 差 1、某条指令少个字段、某句报错措辞不同。这类差异跑得通、看起来正常, 等发现时已经扩散到几个特性上;更麻烦的是到那时已经分不清是「Rust 实现错了」 还是「本来就该这样」—— 没有基准,争论没有意义。

一、决策:R0 先于一切,且基线取自「既有行为」

两条自我约束:

  1. 先立基准,再动手。R0 不改任何架构,所以它零风险;但它是 R2–R5

每一步的验收闸门。没有它,后面的「一致」都只是「看起来一样」。

  1. 基线是「现在的行为」,不是「重新设计」。所以它不需要谁来拍板 ——

它只需要被看清。(这与「取舍要等拍板」不矛盾:这里没有取舍, 只有「现状是什么」。)

覆盖 73 个语料 × 四个阶段(tokens / AST / 字节码 / 诊断)。 ⚠️ 边界如实标注:只覆盖编译期诊断 —— 35 个 error_cases 里有 20 个是 运行期错误(除零、越界…),它们的错误要跑起来才出现,留给 R6 纳入夹具。

二、🤖 自动化工具分不清「事实」与「事故」

这是本轮最值钱的教训,而且是我自己撞出来的。

夹具第一次试跑就"成功"冻结了 73 份基线 —— 但其中每份的 bytecode 都是 null,诊断写着「编译阶段:&lt;内部错误:ImportError&gt;」。原因是 我把 serialize.dump_json 写成了 dumps_json。

如果我按原计划直接 freeze 然后提交:以后 Rust 版必须忠实复刻这个 ImportError 才算「一致」—— 荒谬,但流程上是"对"的。

于是加了一道闸门:run_stages 只把 JishiError 记成诊断;抛别的类型 一律 ImplementationBug → 拒绝冻结,且不留半成品清单 (否则会得到一个「一半是标准、一半是 bug」的基线)。

📌 通用形式:凡是「把当前状态升格为标准」的动作,都必须有一道 「这状态是正常的吗」的闸门。测试快照、格式化器的 golden file、代码生成器 —— 全都一样。工具只会「记录」,不会「判断」;判断这一步必须由人或显式规则补上。

三、同一课的另一面:「跳过校验」伪装成「校验通过」

同一天在 M49 也撞上同源的问题:fetch_release_mirror.py 用 「文件名 + 大小一致就跳过」判断「文件已下载」。而 SHA256SUMS 这个文件名 不随版本变,两版大小又相近 → 它拿上一版的清单去校验新包 → 四个文件名都不在清单里 → 四条校验全被静默跳过,只在 stderr 打了几行警告。

改法:清单每次强制重下;并且清单里没有该资产名 → 硬错误、拒绝发布。

📌 与第二节同源:「没做」和「做成了」在输出上必须可区分。 警告不算 —— 警告会被忽略,而忽略之后的状态与成功完全一样。

四、一个反直觉的收获:3.10 与 3.13 的产物一模一样

夹具顺手把「两个 Python 版本语义是否一致」变成一个几秒能回答的问题。 实测:Python 3.10.9 与 3.13.14 跑出的四阶段产物逐项一致(73/73)。

这直接缩小了 CI 排障范围:CI 里 Python 3.10 那两个任务一直失败, 而 3.10.9 跑夹具是全绿的 ⇒ 那不是前端语义差异,方向应转向 测试基础设施或平台假设(某些用例依赖了环境而不是语言)。

⚠️ 未度量:这套夹具尚未在 CI 上真正跑过(本地与 CI 的差异本身就是怀疑对象)。

五、确定性与「不许悄悄缩水」

转字符串标记 / filename 用相对路径)。差点栽的一处:Rust 的 serde_json 拒收 NaN 字面量 —— 若不管,夹具会产出一份「Rust 吃不下」的标准。

几个语料」是能得逞的,而保护范围已经小了。

而不是给一个几 MB 的大 JSON 让人猜。


D54. R1:把「产出什么」和「怎么比」定下来(契约先行的理由)

背景:R0 立了基线,但那只解决「Python 前端有没有变」。R2 起要验的是 「Rust 产出的产物,与 Python 的行为一致吗」 —— 这需要一把能拿外地产物来比 的尺子和一份说明产物长什么样的文档。

一、为什么先写契约,而不是「先写代码、出问题再对齐」

Rust 侧完全可以「照着 serde_json 的习惯」自己定一套输出格式,然后 compare 报出几百条差异 —— 而那时分不清:

契约把第二种可能性提前消掉。它的价值不是「好看」,是让每一处差异都可归因。

📌 这与 R0 的教训同源:先立判据,再动手。区别只是 R0 立的是「数据」, R1 立的是「接口」。

二、compare 的三条设计

① 差异要说出「这是什么元素」,不能只给坐标。 从「tokens[2].col 基线 5 / 外部 6」升级成 「tokens[2].col —— OP「=」(第 2 行第 5 列) 基线 5 / 外部 6」。 只给坐标的话,人还得回去数第 3 个 token 是什么 —— 那就把定位的成本转嫁给了读者。 (实现上有个小坑:token 也带 类型 字段(值恒为 &quot;Token&quot;), 所以判分支时要先判 token 再判 类型**,否则输出会退化成 Token「=」、丢掉行列。)

② 允许「只做了一部分」,但没做的必须报出来。 --stage tokens 让 R2(只做分词)能立刻验收。但不带 --stage 时,外部没提供的 阶段是「未提供」而不是「通过」 —— 退出码照样是 1。

这条是本项目反复踩过的坑,所以这次写在设计里:

而「警告一下继续」不算 —— 那种状态在 CI 里与「通过」长得一模一样。

③ --from &lt;目录&gt; 而不是「读一个命令的 stdout」。 外部实现是另一种语言,它的进程边界越简单越好: 「产出一堆文件」比「约定一个交互协议」稳得多 —— 出错时还能直接把那些文件拿来看。 (语料 id 的规则也简单:路径把 / 换成 __、去掉后缀。)

三、契约里最容易被忽略的一节:确定性要求

「产物必须逐字节可复现」听起来像废话,但它有四个具体落点,每一个都踩过:

要求踩坑记录
键排序 + 中文原样不排序时字典插入顺序会渗进产物
行尾一律 LF2026-09-30:Windows 检出转 CRLF,CI 上那条「行尾必须 LF」的测试红了、本地却是绿的
位置按码点M55:长度(&quot;😀&quot;) 在 Node 给 2 —— JS 是 UTF-16
特殊浮点写字符串标记JSON 里没有 NaN 字面量,Rust 的 serde_json 直接拒收

四、如实标了四条已知风险(写在契约文档里)

  1. 浮点的打印口径:Python 的 repr 与 serde_json 在极值上可能给出不同文本

(1e+30 这种)。语料里目前没触发 —— 这是「未验证」,不是「没问题」。

  1. ast 的脱糖时机:结构一致 ≠ 行为一致(所以 R5 的字节码对比不能省)。
  2. 编译期诊断只覆盖到「第一个错」:多错并存时谁先报取决于遍历顺序。
  3. 运行期诊断不在本契约内:留到 R6 纳入夹具。

📌 写「已知风险」这一节的用处:把「还没验的地方」从「以为验过了」里摘出来。 本项目的老规矩 —— 没度量就写「未度量」,边界如实标注。


D55. R2:前端扩展怎么接、为什么只接一条路径,以及「5 倍够不着」是量出来的(2026-10-01)

一、接法:Rust 作为 Python 扩展(而不是子进程,也不是 C ABI + ctypes)

三条路都想过,选 pyo3 的理由很直接:

方案为什么不选
独立可执行文件 + 子进程每份源码一次进程创建(本机底噪 285 ms,见 docs/架构迁移评估.md)—— 比要省的还贵;LSP 每敲一键都要走这条路,不可能
C ABI(extern &quot;C&quot;)+ ctypes项目里已有这套(C VM 就是),但每个 token 都要手工编组过边界,而 token 流正是最需要「少一次转换」的地方;顺手还要自己管内存
pyo3 扩展直接产出真正的 oracle.jishi.tokenizer.Token 对象 —— 上层(parser / formatter / LSP)一个字都不用改,而且一致性夹具靠 type(obj).__name__ 认节点,同形才不会改冻结产物

⚠️ 但没走 maturin(路径文档原本写的是 pyo3 + maturin)。两个环境坑让 「一键能编」变成空话,都写进了 core/build.py 的 docstring:

  1. pyo3 的构建脚本会去跑一次 Python,本机报

os error 231(所有的管道范例都在使用中) —— 看起来像我们写错了, 实际是环境。对策:按当前解释器生成 PYO3_CONFIG_FILE,pyo3 就不自己探测了。

  1. windows-gnu 目标需要 dlltool(pyo3-ffi 用它生成导入库),

它跟着 MinGW 来了但常常不在 PATH。对策:与 cvm/build.py 同样的候选清单, 可用 JISHI_MINGW 指定。

于是构建就是 python core/build.py:生成配置 → cargo build --release → 把 _core.dll 复制成 jishi/_core.pyd。产物按 Python ABI 绑死,所以不进仓库 (.gitignore 挡住);没有它时 JISHI_FRONTEND=rust 明确报错并给出修法, 绝不静默回落 —— 「跳过」与「通过」必须分得开(R0 那条闸门)。

二、只换一条路径:tokenize(),不是整个 Tokenizer

Tokenizer 类里那些内部方法(_run / _tokenize_line)仍是 Python,因为 jishi.incremental(LSP 的增量分词)用的是 LineAnchor 那套行级词法状态, 而 R2 根本没搬它。假装「整条前端都换了」会在下一阶段被现实打脸。 所以开关只挂在 tokenize() 上,文档里也明写「R2 只换这一条全量路径」。

三、报错回传:只回传事实,不回传结论

扩展抛自己的 LexError,只带六个属性:码 / 消息 / 行 / 列 / 提示 / 下划线。 异常类由 Python 侧从 oracle.jishi.errors 按 code 现查,源码行由 with_source(lines) 按 行 现取。

为什么绕这一圈:抄一份「错误码 → 异常类」的映射就多一个漂移源,而同一个 错误的文案、类名、^ 下划线的宽度都要与 Python 侧逐字一致(M56 四批工全花在 这类细节上)。现在 Rust 侧根本没有那张表,也就无从漂。

四、「≥5 倍」够不着 —— 但这是量出来的,不是估的

tools/bench_frontend.py 每次都算一遍天花板并把推导印在输出里:

端到端 = Rust 纯核心(69.6 ms) + 把 Token 交回 Python(62.4 ms)
                                   其中 46.4 ms 是纯造 11.7 万个 Token 的**地板**
天花板 = 490.8 ÷(69.6 + 46.4) = 4.23x   ← 与实现好坏无关

也就是说:只要 tokenize() 还得把 Python 对象交回去,5 倍在这层接口上就是 假想值。实测核心 7.05x、端到端 3.72x。 📌 ✅ 2026-10-01 已拍板:把 R2 的验收线改成 「纯核心 ≥5x」,把「端到端 ≥5x」挪到 R5 —— 那时下游才不需要 Python 对象。 这是建议,不是替作者决定的结论(见 docs/架构迁移路径.md §三 R2 与 §九)。

📌 通用形式:先量再说,量完再定;不要让一条写死的验收线去指挥事实。 更狠一点的做法是把天花板算进工具的输出里 —— 它每次跑都会提醒你 目标是不是可达,避免半年后有人还拿着旧线去追一个不存在的东西。

五、顺手抓到的五件事(都不是「写错了」,是「本来就没测」)

  1. 🔴 R0 的 73 个语料里一个 emoji 都没有 —— 而列号口径是这次迁移的

头号风险。M56 的原话是「只在 ASCII 上测位置 = 没测」,结果夹具自己也犯了 同一条。R2 补了 11 个边角语料(emoji / 多行注释 / 全角归一化 44 项 / 三引号独占行 / 数字写法 / 插值分段表 + 5 个词法错误)。 ⚠️ CRLF / 孤立 \r / BOM 进不了语料目录:Path.read_text 默认做通用换行, \r\n 被悄悄归一化掉 —— 这类只能写字符串级的测试(24 条)。 🔴 另外还加了一个语料类别 tests/frontend_cases/:tests/cases/ 的 不成文约定是「三执行器都能跑」,而 C VM 有意拒绝超 i64 的整数 (E2005)—— 于是「编译得过、但某个执行器拒绝」的语料无处安放, 而 R5 的字节码比对恰恰需要它(契约 §4.3「超过安全整数按十进制字符串 编码」那条分支只有它走得到)。语料的分类要跟着「谁能跑」走, 不是跟着「文件扩展名」走。

  1. 🔴 TOKEN_TYPES 这个集合漏了 FSTRING(M7 加插值字符串时没补)。

一直没露馅是因为没人拿它当清单用。R2 的 kind_name_index 用 unreachable! 兜底 → 当场 panic。宁可当场炸,也别悄悄给错值。

  1. 🔴 emit 不先清空输出目录 = 又一个「跳过伪装成通过」:扩展 panic 导致

emit 中途失败,而紧接着的 compare 拿上一次的产物比出了全绿。 已改成「先删再写」。这与 R0/M49 那两条是同一个坑的第三副面孔。

  1. 测试助手自己写错判据:_describe 把「两边都报错」直接当成「不一致」,

于是 85 个语料里 10 个本来就在词法阶段报错的语料白白红了 10 条。 判据写错,比实现写错的代价更大 —— 它会让整片结果不可信。

  1. 漂移检测扩到三张表:关键字 / 粘连前缀 / 运算符,由扩展自己报出来

(keyword_table() / glue_table() / operator_table())与 Python 侧逐项比。 ⚠️ 顺序也要比:粘连检查依「长度降序 + 稳定排序」,表对了顺序错了, 提示语就会换个关键字。


D56. R3:把「反拼」这个信号当成接口错了 —— 以及三条报错一致性的教训(2026-10-01)

一、信号:需要「反拼」才能让下游干活,多半是接口给错了

R3 开工时的做法是照着 R1 契约定的:Python 侧调 parse(tokens, source_lines, filename),Rust 侧把 source_lines 用 &quot;\n&quot; join 起来反拼出源码,再自己分词、自己解析。

写完之后拿两个前端口径对拍,第一轮就红了 9 例里的 4 例,全在同一个形状上:

源码:'如果 真:\n\n'        ← 块首后面跟一个空行
Python:E0202 于 2:1        Rust:E0202 于 1:1     ← 行号少一行

根因不是解析器写错了,是反拼有损:source_lines 有两条口径 —— text.split(&quot;\n&quot;)(保留末尾空行)与 incremental.split_lines(…) (去掉末尾空行,为了和词法器内部的 lines 对齐)。后者是 LSP 那条路, 于是「编辑器里那条红线整体上移一行」—— 不报错、不崩溃,只是错得很有说服力。

处置(顺序值得记):

  1. 让 Rust 的 parse 吃源码文本(AST 只由文本决定;tokens 对解析是冗余的,

Rust 拿不到也不该拿)。这一刀把「两条行切分口径」这个隐性约定直接删掉了。

  1. 新增 parse_source(source, filename) 作为生产入口:

CLI / 检查器 / LSP / 调试器 / 沙箱全走它。顺带省掉一次重复分词 —— 原来调用方先 tokenize 一遍、Rust 侧 parse 又自己分一遍,同一份源码扫两趟。

  1. parse(tokens, lines, f) 保留(LSP 增量分词手里已经有 tokens),

但加一条精确守卫:token 流末尾 EOF 的行号就是原文的行数 (Tokenizer.__init__ 的确定行为),拿它当标尺去量反拼出来的文本 —— 对不上就是丢了内容,当场抛错。

📌 通用形式:当你要把 A「反拼」成 B 才能让下游干活时,先怀疑接口 —— 需要反拼,往往意味着下游真正需要的是 A,而中间那个「B 的表示」只是 历史包袱。能直接递原始输入,就别在两侧维持两条口径的隐性约定。 更狠一点:口径不一致的代价往往是静默偏一位,而不是报错 —— 这种错最难查,所以宁可当场炸。

⚠️ 守卫的判据要连剥开头 BOM 一起算(&quot;\ufeff&quot; 这种输入剥完是 0 行)。 第一版少了这一步,于是对合法输入误报 —— 误报比不报更糟: 它会让走对路的人被拦住,然后大家把守卫整个关掉。

二、报错补全必须认一个出口,别按「谁抛的」分岔

类外 超() 那条错是解析之后由 _check_no_stray_super 补的检查, 它不走 Parser._err —— 于是漏了两样:with_source(源码行)与 underline (^ 指示)。Python 侧不显示源码行、Rust 侧显示,两个前端一渲染就分岔。

根因是「两条产生错误的路径各自补上下文」,而不是「少写了一行」。 (同类:R2 的 emit 没清空输出目录、R0 的非基石错误照冻结 —— 本项目第三次栽在「两条路径只改了一条」上。)

三、别把内部数据结构泄进面向用户的文案

插值字符串(FSTRING)的 value 是一张分段表 [(&#x27;text&#x27;, …), (&#x27;expr&#x27;, …)]。 _describe() 的兜底分支是 f&quot;「{t.value}」&quot; → str(list) → repr, 于是用户看到的报错是:

不知道怎么写这句话:「[('text', '导\t!=从情次数…')]」

难看不只是小事:要让 Rust 逐字复刻这段文本,就得复刻 Python 的 repr(str) 全套转义规则(引号选择、\t、\xNN、非打印字符按 str.isprintable() 判 \uNNNN…)—— 而 isprintable 依赖整张 Unicode 类别表, 在无依赖的 Rust 里是个填不满的坑。

处置:两侧统一说人话 —— 插值字符串一律显示「插值字符串」, 统一入口是 _tok_text / tok_text(所有把 token 值写进文案的地方都必须过它)。

📌 通用形式:把「内部结构的默认字符串化」当作缺陷看待 —— 它既是 UX 问题,也是一个跨语言复刻的入口(凡是「默认表示」, 通常在另一门语言里都要重写一遍)。

四、R3 的实测与结论

项结果
AST 逐项一致88/88 语料 ✅(+ 5 万组随机源码对拍 0 分歧)
语法阶段诊断一致88/88 ✅(逐字,含 ^ 下划线)
语法端到端823.3 → 270.6 ms(3.04x)
纯语法核心823.3 → 145.9 ms(5.64x)
⚠️ 端到端天花板4.73x(与 R2 同构:交回 Python 的对象本身有地板,7 万节点 ≈ 28 ms)

三个新语料是「发现了才补的」(41_块首后空行 / 42_类外超 / 43_整行插值字符串):语料是结果,不是计划 —— 先对拍发现分叉,再把那个形状固化成语料。反过来做(先列语料清单再实现) 会漏掉这类「没想到要测」的形状。


D57. R4:一张对外的表,也要有人去查「它说的能不能兑现」(2026-10-01)

一、「表里有」≠「会出现」

jishi 错误 &lt;码&gt; 与 --ai-card 里的错误码,是对用户和模型的承诺: 「E0205 是什么意思、常见成因、怎么改」。但从来没人查过这些码是不是真会出现。

R4 建了一个体检(tools/probe_errcodes.py),第一次跑就抓到两笔:

码情况
E0205「括号没有闭合」死码:ParseUnclosedError 定义在、说明文案在、查得到 —— 但全仓库没有任何触发点(括号没闭合其实报 E0201)
E0204「这里需要一个名字」可达、能触发(八种形状),却一个语料都没有

E0205 这类问题的性质:它不是 bug,是空头承诺 —— 用户照着查一个不可能发生的错, 模型照着一份描述去猜一个不存在的故障。而没有任何测试会因此变红, 因为它「什么都没有做错」。

📌 通用形式:凡是对外承诺的表(错误码表、能力清单、API 列表、 --dump-* 自报家底),都要有一条「它说的能兑现吗」的检查。 本项目的漂移检测管的是「两边对不对得上」,这一条管的是 「承诺本身有没有落到地上」—— 是同一个家族里更靠前的一环。

二、「不可达」必须能复核,否则登记就是许愿

体检允许登记「这个码不可达」,但登记只准写两种类别,且每种类别都有复核手段:

于是登记不会悄悄过期:哪天有人把 E0205 接上了,这条登记就会报红要求复核。 (和本项目的老规矩一致:豁免必须能复核。M55 的内建豁免、M50 的宿主缺口 都是这么办的。)

三、渲染 进契约:挑那个「能连坐整个类」的不变量

R4 给 diagnostics 补了三个字段:下划线、修正,以及渲染 —— 即 render() 输出给用户的那段多行文本。前两个是「事实」,第三个是事实的下游产物, 看着冗余。它的价值在于:

只比「我列出来的字段」,永远抓不到「我忘了列的字段」。 而 渲染 是所有事实的函数 —— 漏一个,渲染就不一样。

R3 的教训正是这个:漏了一个事实字段,两边各渲染各的,比对仍然是绿的。

📌 通用形式:为「清单式比对」配一个整体式的兜底(渲染结果、最终输出、 端到端行为)。清单负责定位,整体负责不漏。

四、两份清单并存 + 恰好没用到 = 测不出来,但问题在

「扩展事实 → JishiError」原有两个出口,词法与语法各抄了一份字段清单:

所以怎么测都是绿的。

这是本项目第 N 次栽在「两条路径只改了一条」上(前两次:emit 没清空输出目录、 非基石错误照冻结、类外 超() 的报错补全)。

处置有两层:

  1. 收成一个出口(errors.from_extension)—— 结构上不再有第二份清单;
  2. 字段表往返测试:把 EXTENSION_FIELDS 里的每个字段摆到冒牌扩展异常上,

过一遍还原,断言一个都没丢;并且两条路径都要过一遍(词法那条走真的 _rust_tokenize,靠替换 _core() 复现)。

📌 通用形式:修「两条路径不一致」,别停在「把漏的那行补上」—— 问一句「下一个字段/下一个分支会不会又漏」。能收成一个出口就收; 收不了,就补一条表驱动的往返测试,让「加字段的人」躲不过去。

五、顺带结清的一笔旧账:验收线搬家

R2 交付时留下一处「待拍板」:端到端 ≥5 倍在这层接口上够不着。 2026-10-01 拍板:R2/R3 这一层认「纯核心 ≥5x 且产物逐项一致」, 「端到端 ≥5x」移到 R5(那时下游不再把 Python 对象交回 Python)。

这条值得记的不是结论,是为什么当初该量:如果只报端到端倍数(3.72x), 结论会是「R2 不达标」;把「纯核心」「纯造对象的地板」一起报出来,才看清 天花板是接口给的(4.23x,与实现无关)。 指标选错,会把「接口问题」误判成「实现不够快」,然后朝着一个不存在的目标优化。


D57 补记(2026-10-01 晚):死码接上之后,「不可达登记」这一栏就空了

D57 写完当天,作者拍板把 E0205 接上(而不是删掉)。补记三条值得留下的:

一、接上它的时候才发现,「报在哪儿」比「报什么码」更值钱

第一版是照最直白的做法写的:在 EOF 处报 E0205。写完一看渲染是这样的:

错误 E0205:括号没有闭合(这里需要「)」,但已经到文件末尾了)
  ┌─ x.jsh:1:1
  │
  │

没有源码行、没有 ^,位置是「最后一行第 1 列」。 原因很实在:EOF 那个 token 的 source_line 是空的、col 恒为 1 —— 它是词法器补的哨兵,不是源码里的东西。

于是一句「往回找最近的那个没闭合的开括号」的提示,配上一条指不到任何地方的红线。 用户拿到的是「我知道你错了,但我不告诉你错在哪」。

改成报在那个没闭合的开括号上(错误路径上往回扫一次),才是这个错误码真正该有的样子:

  ┌─ x.jsh:1:7
  │
  1 │ 令 甲 = (1 + 2
    │       ~^
  │
  提示:给这里这个「(」补上对应的「)」。

📌 通用形式:新增一个错误时,先看它的渲染,别只看码和消息对不对。 「位置」是错误的一半信息量 —— 而哨兵值往往没有位置(EOF、合成的节点、 默认构造的对象),照着它报错等于放弃那一半。

二、「把码归拢」的诱惑要顶住

接 E0205 时有个更省事的做法:凡是「期望右括号」就报 E0205。但那样会吃掉一条 更值钱的信息:

令 乙 = [1, 2
令 丙 = 3

这时报 E0201「这里需要「]」,但看到了「令」」比报「括号没闭合」更有用 —— 它直接说出冲突发生在哪里(第 3 行那个 令)。而「括号没闭合」只告诉用户 「有个括号没关」,他还得自己找是哪一行。

所以规则收窄成:只有「一直走到文件末尾都没等到右括号」才算 E0205。

📌 通用形式:新增细分错误码时,先问「它会不会吃掉原来那条更精确的信息」。 错误码细分的目的是多给信息,不是换个说法;如果细分之后信息变少,宁可不细分。

三、同一个 bug 家族,第四次现形:内部表示漏到用户眼前

做 E0205 时顺带发现:EOF 的 value 是 Python 的 None,而 _tok_text 的标签表 里没有 EOF —— 于是「这里需要一个值,但看到了「None」」。

这和 R3 的「插值字符串把分段表 [(&#x27;text&#x27;, …)] 泄出来」是同一个毛病, 和 M55 的「空 当哨兵」也是同一个家族。本项目的立场已经很清楚:

凡是内部表示,都不许落到用户眼前。 报错文案里出现 None / [( / {&#x27; / at 0x / Traceback,都按 bug 处理。

现在的守门方式(tests/test_m61_frontend_errors.py)分两层:

[(&#x27; / &lt;class &#x27; / at 0x / Traceback 这些结构性标记。

⚠️ 兜底层故意没有把 None / True / False 放进黑名单:它们是用户可能真的 写在源码里的字(英文关键字容错提示会原样引用:「True」是英文写法…), 文本级扫描分不清「引用用户写的字」与「泄漏内部表示」—— 第一版就是这么写的, 当场报了 2 条假阳性。误报比不报更糟:走对路的人被拦下几次,就会把这条测试关掉, 那时它连真的泄漏也拦不住了。


D58. R5:让「端到端 ≥5x」兑现的,是换产物的形态,不是「Rust 更快」(2026-10-02)

一、天花板是接口给的,所以只能从接口上拆

R2/R3 的倍数一直撞在同一堵墙上:

天花板 = 基线 ÷(纯核心 + 把产物做成 Python 对象的地板)

地板量出来是实打实的:11.7 万个 Token ≈ 39 ms、7 万个 AST 节点 ≈ 28 ms。 把核心优化到 0 也翻不过去 —— 因为 tokenize() / parse_source() 的 返回值就是 Python 对象。

所以 R5 换的不是「更快的实现」,而是产物的形态:字节码的产物是 JSON 文本。文本没有「构造对象」那一步 —— 这条路没有地板。

口径倍数
源码 → 字节码 JSON 文本(compile_to_json)5.37x ✅
源码 → CompiledModule 对象(compile_source,VM/CVM 用)5.08x

📌 通用形式:当一个指标无论怎么优化都撞墙时,先别优化 —— 回去看那个指标是不是被接口的形状框住了。R2 那次的正确动作不是「再压榨 Rust」,而是量出天花板、把验收线挪到天花板能兑现的那一层(R5), 然后在那一层上换形态。

二、「契约产物」≠「调用方需要的东西」

R5 本可以让 compile_source() 拿 JSON 反序列化回来,一行就交差。 不行:Code.local_hints 不在字节码 JSON 里(格式没这个字段),

✅ 2026-10-03 更新(R6.6):用户拍板动格式,local_hints 已经进 v3 字节码 —— 「格式装不下」这个理由没有了。compile_module() 仍然留着,理由换成 「少一次 JSON 往返」。下面这段保留作历史。

而字节码执行器报错时要用它 ——「赋值前读局部」那句 「外层也有一个叫「甲」的变量…」就是从这儿来的。

只给 JSON 的话,rust 前端跑 VM/CVM 时这句话会静默消失:程序照样跑、 结果照样对,只有出错时少一句提示。这正是最难发现的那种差异 (R4 刚吃过一次同款:漏一个字段,比对还是绿的)。

📌 通用形式:用「契约产物」当唯一出口之前,先问一句 「有没有哪个字段是格式装不下、但下游在用的?」 有的话,就别让契约产物当唯一出口。

三、逐行移植 = 一次最彻底的代码审查

R5 抓到一个既有 bug(与 R5 无关、两个前端一致、三执行器却分岔):

令 甲 = 100
函数 f():
    尝试:
        令 甲 = 1        ← 只在「尝试」块里赋的值
    捕获 值错误:
        通过
    返回 甲
打印(f())   打印(甲)
执行器输出
树遍历1 100
Python VM / C VM1 1

根因:compiler._collect_stmt 没有 Try 分支(Raise/Break/Continue/ Pass 也没有),于是「只在 尝试 里赋过值的名字」进不了 scope.bound, 被编成 STORE_GLOBAL —— 变量泄漏成了全局。

发现它的方式:为了把这段代码搬对,必须读懂每一行;读懂了, 就看出来它漏了一支。「移植」本身就是一次最彻底的代码审查 —— 这不是 R5 的副产品,这是那一类工作的主要价值之一。

⚠️ 处置:逐字复刻、一个字都不动(改它会改变既有行为,那是另一件事), 记进 docs/前端契约.md §12.4,报给作者拍板。 「先复刻、后修」这个顺序不能反 —— 反了就说不清「到底是搬错了还是改坏了」。

四、两样「不能靠眼看」的东西

移植里最危险的不是逻辑分支,是常量表和格式细节:

node/vm.js)。防漂移靠 opcode_table() 与 OP_NAMES 对拍。

不写指数(1e30 印成 30 个 0)、{:e} 的指数不补零(1e-5 vs 1e-05), Python 还有自己的科学/定点分界(decpt &lt;= -4 || decpt &gt; 16)。 只能拿一万多个值逐个比,眼睛看不出来。

📌 通用形式:凡是「另一种语言的默认写法」,都要当成需要复刻的规格 来对待,并且用数据对拍,不要用眼睛验收。


D59. R5.1:逐行移植顺手挖出的四个既有 bug —— 以及「谁来查」这件事(2026-10-02)

R5 把 1151 行的编译器逐行搬进 Rust。为了搬对,每一行都得读懂;读懂之后, 代码里漏掉的那一支就藏不住了。这一节记四个缺口 —— 它们全都与 R5 无关, 是两个前端一致、但「三个执行器之间」或「引擎自己不同形状之间」不一致的既有 bug。

📌 「移植」本身就是一次最彻底的代码审查。 这不是副产品,是那类工作的 主要价值之一 —— 而且它抓的是测试没覆盖到的形状,不是「搬错了」。

一、编译器名字收集漏了 Try(最重的一个:编译期直接崩)

compiler._collect_stmt 没有 Try 分支(Raise / Break / Continue / Pass 也没有,但那三个没有子节点、无害)。于是「尝试 / 捕获 / 最终」里的 赋值与引用从没进过作用域分析。三种后果,一种比一种重:

后果症状有多难发现
① 体里赋值的名字进不了 bound编成 STORE_GLOBAL —— 变量漏成全局,把外层同名变量改掉只有「外层有同名变量」时才看得出来(否则全局与局部等价)
② 体里读的闭包变量进不了 used闭包捕获不到 → 运行期 E0301「找不到这个名字」需要「函数套函数 + 只在 尝试 里读」
③ 体里定义函数/类,func_scopes 没登记发射期 KeyError —— 编译期直接崩用户写一个很自然的程序就撞上,报的是个光秃秃的整数

实测(修之前,同一份源码):

形状树遍历Python VM / C VM
只在 尝试 里赋值 + 外层同名1 1001 1
闭包变量只在 尝试 里读42报 E0301
尝试 里定义函数42编译期 KeyError

修法(两个前端各一处,逐字对应):_collect_stmt / collect_stmt 补 Try 分支 —— body / handlers(类型是表达式、捕获名与「赋值」同级、 捕获体按语句收集)/ finalbody;Raise 的值表达式也收。


二、Python VM:BUILD_CLASS 的局部变量撞掉了帧的栈基址

vm.py 的 _execute 在进入每个帧时 base = frame.base —— 这是帧的栈基址, RETURN / END_FINALLY 靠它 del stack[base:] 把本帧的栈收干净。 而 BUILD_CLASS 分支里写了:

pythonbase = None            # ← 想表达「这个类没有基类」
if has_base:
    base = stack.pop()

同一个 base。类一建完,帧的基址就变成了 None,于是 RETURN 执行的是 del stack[None:] —— 等价于 del stack[0:],把整个求值栈清空。

症状:「在函数里定义类」这个再普通不过的写法,函数返回后调用方拿到的是 上一个值 —— 错误信息还指着调用行,看起来像「打印不能调用」这种莫名其妙的话。

形状树遍历Python VMC VM
函数里定义类7报「「7」不能调用」7
类在 如果 / 循环 / 尝试 里正常报同样的错正常

⚠️ 为什么一直没暴露:模块帧不收 RETURN(以 HALT 结束),所以 「模块级定义类」侥幸没事 —— 而示例与语料里的类恰好都在模块级。 C VM / Node / Rust 各自实现,用的是 baseCls 这种不同名字,也都没事 —— 只有 Python VM 中了。

📌 通用形式:在一个长函数里,局部变量名就是全局命名空间。 帧级状态(栈基址、当前帧、指令表)的名字要显眼到不会被顺手借用, 或者干脆放进一个小结构体。名字撞车的代价往往是静默清栈,不是报错。


三、树遍历:捕获 … 为 名字 偷偷给捕获体开了一个新作用域

interpreter._do_try 里:

pythonif h.name:
    h_env = Environment(env)      # ← 为了绑那个名字,顺手把整个捕获体也搬进去了
    h_env.set(h.name, err)
else:
    h_env = env
self.exec_block(h.body, h_env)

于是捕获体里的赋值出了捕获体就没了。全语言只有这一处是这样: 如果 / 否则 / 循环 / 遍历 / 当 / 尝试 体 / 最终 全都直接用外层 env;文档也早就定了口径 ——「循环变量与「遍历」语句一致(写入当前作用域)」。

形状树遍历(修前)两个 VM
捕获体里 令 结果 = 1,之后 返回 结果报「赋值之前被用到」1

修法:捕获名就是当前作用域里的一次赋值(env.set),捕获体也在当前 作用域跑 —— 与编译器(把 h.name 与捕获体里的赋值都算本作用域的局部)以及 两个 VM 一致。Environment 一共只被 用户函数 与这一处用到,改完之后 那个子作用域就没地方用了。

📌 通用形式:「为了绑一个名字」不该改变块的作用域。 一门语言里「块作用域 / 函数作用域」只能有一套口径;哪一处特立独行, 哪一处就会长出「只在某一种写法下才有的怪现象」。


四、调用链的挂载时机:显式 抛出 与运行期错误长得不一样

报错里的「调用链(从外到内)」是三处分别实现的,于是时机不同:

实现挂在什么时候「抛出」的错误有链吗
树遍历错误跨出函数边界时(_eval_call)+ 冒到顶层时❌(同帧被自己捕获 → 从没跨出边界)
Python VM错误冒出来那一刻(_dispatch_exception)✅
C VMnote_frames 回调 → 挂在宿主 self.pending 上❌(抛出 那条路 pending 还是空的)

症状:文本(e)(或未捕获的错误)里,「显式抛出的错」没有调用链, 「除零」那种有 —— 而同一种错误两种长相,用户会以为「为什么这个错不告诉我 调到哪里了」。

修法(三处对齐到「冒出来那一刻」):

_eval_call 的 finally 还没弹);

note_frames 送来调用链时(self.pending 还是 None)就挂到它身上。

📌 通用形式:同一个「附加信息」(调用链 / 提示 / 源码行)在三处实现, 时机就一定会漂。要么收敛成一处,要么给三处各写一条用同一种错误的 对拍用例 ——「除零有链、抛出没链」这种差异,只有并排比才看得见。


五、验收:一个语料扫一遍两个前端 × 三个执行器

四个缺口里,有三个是 conformance.py check 全程绿的情况下存在的 —— 因为它比的是前端产物,而「产物一样 ≠ 跑起来一样」。

新增 tools/check_engines.py:扫全仓库语料(含 tests/error_cases/, 因为报错文本也是可观察行为),在三个执行器上跑,逐字节比 「树遍历 vs Python VM vs C VM」;豁免必须显式登记且说得出理由 (超 i64 整数、打印墙上时间的跑分示例)。

结果:93 个语料 / 对拍 91 个 / 豁免 2 个 / 不一致 0 个(两个前端各一遍)。

⚠️ 一条纪律:freeze 只把当前行为升格为标准,它不判断对错。 这次重冻动了 9 个语料(5 个示例项目 + 4 个基石库模块 —— 它们都有 尝试 块) + 新增 1 个。核对方式不是看指令 diff(位移会产生大量假配对),而是 拿行为对齐:全语料三执行器一致 + 黄金输出不变。


D60. R6.0:先把「量什么、怎么量」定下来 —— 顺带量出核心 VM 的四个缺口(2026-10-02)

R6 的任务是「VM + 标准库换 Rust」,结束时要做一个决定:C VM 留不留。 路径文档给的判断是「C VM 的墙是宿主回调(约 2.4 µs/次),同进程 Rust 原生标准库 没有这个边界 ⇒ Rust VM 可能反超」—— 并明确标注这是推测、未度量、先量再说。

所以 R6 的第一步不是写代码,是把度量做对。这一步的产出比预想的多。

一、度量口径:先扣掉「进程启动」,否则量的是另一件事

Rust VM 现在是一个独立二进制(rust/target/release/jishi-rs,吃字节码 JSON)。 一次 subprocess 里含进程创建、动态库加载、读文件、退出 —— 实测空程序 8~10 ms。 而基准里好几个场景的 C VM 只要 3~10 ms。

不扣的话,「整数循环累加」那一格会显示成 10 ms 级的差距,结论就从 「VM 快慢」悄悄变成了「进程启动快慢」。

处置:tools/benchmark.py 先量一次空程序的启动成本(5 次取最优), 从 Rust VM 那一列里扣掉 遍数 × 启动成本,并把启动成本印在表头上。

📌 通用形式:换了一个形态(这里是进程)就换了一套隐藏成本。 比之前先把「两边都不干活的耗时」量出来 —— 那是这套口径的零点。 与 R2/R3 的「端到端天花板」是同一件事的两副面孔:先把地板找出来。

二、新闸门:tools/check_engines.py 把 Rust VM 当第四个执行器

它比 conformance.py 多管一件事:「产物一样 ≠ 跑起来一样」。 R5.1 抓到的四个既有 bug 里,有三个是 conformance.py check 全程绿的情况下存在的。

Rust VM 接进来的方式正好复用 R5 的成果:JISHI_FRONTEND=rust 编出字节码 JSON → 交给二进制跑 —— 全程不经过 Python 对象,就是 R6/R8 要的那条链。

⚠️ 两条口径必须写在明面上(都已在工具里登记):

调用链)。这是已登记的宿主设计:字节码 JSON 里没有源码文本, 独立宿主画不出来(见 docs/embed.md)。本次 91 个可对拍语料里 26 个命中。

而且会过期:某个语料哪天好了,工具会报「登记过期」并以非零退出**, 逼着人来删一行。照的是 probe_errcodes.py 那套老规矩。

三、顺手量出来的四个缺口(全在 Rust VM 一侧)

① 🔴 遍历 是 O(n²) —— 最要命的一个

n=  2500      35.4 ms
n=  5000     151.2 ms   4.3x
n= 10000     636.3 ms   4.2x
n= 20000    2499.4 ms   3.9x      ← 规模翻倍、耗时 ×4

隔离实验(都跑 2 万次):

形状耗时
模块级 令 总数 = 0 + 遍历 累加2586 ms
函数内局部累加2480 ms
循环体只写 计数 = 1(不做累加)2602 ms
函数内、循环体只写一个字面量2446 ms

⇒ 与「全局 / 局部」无关、与循环体做什么无关 —— 是 遍历 本身。 (对照组:当 循环倒计数在同一份基准里是 7.1x,说明不是「循环都慢」。)

📌 这一条直接改变 R6 的行动顺序:先修 遍历,再谈优化。 在 O(n²) 面前,任何「常数因子」的优化都是白费。

② 捕获 运行期错误 接不住 键错误 / 索引错误

语言规格 §8.9 明写「基类能抓子类:异常 > 运行期错误 > 类型错误/值错误/…」。 实测:值错误 / 除零错误 接得住,键错误 / 索引错误 漏了 —— 于是 examples/12_异常处理.jsh 在 Rust VM 上直接以未捕获错误收场,而另外三个 执行器都正常。⇒ Rust 侧的异常父子表漏了两支。

③ 报错时 stdout 一个字都不冲出来

打印("前一句")      ← 三个 Python 执行器都会先输出这一行
令 甲 = 1 / 0       ← Rust VM:stdout 全空,只在 stderr 报错

程序崩了也看不到「跑到哪了」—— 与北极星(出问题能快速定位)直接冲突。

④ 报错渲染缺源码行 / ^ / 调用链(本次「登记」而非「修」)

原因很清楚:字节码 JSON 里没有源码文本,独立宿主画不出来。 但核心二要求「报错必须可操作」,而 R7/R8 之后单二进制就是唯一形态 —— 所以这一条必须在 R6/R7 里解决。两条现成的路:

📌 这也是「宿主渲染」与「核心渲染」的分界:宿主能省,核心不能省。

四、R6.0 的结论

当循环倒计数(7.1x vs 37.7x)两项外,Rust VM 全面落后,遍历 那一项是 0.02x(O(n²))。

(目标是「跑得对」),不是为核心重写过、调过的 VM。R6 的工作正是把它变成核心。

「Rust 能不能反超」要等 遍历 修好、再做一轮同口径度量才谈得上。


D60 补记(2026-10-02 同日):遍历 的 O(n²) 修掉了 —— 改动就一行

R6.0 量出来的四个缺口里,①(遍历 是 O(n²))是最要命的一个,所以 R6.1 直接修它。

一、根因就一行:remove(0)

rust// rust/src/lib.rs(修前)
fn iter_next(it: &mut Val) -> ... {
    Val::List(l) => { ... Ok(Some(l.remove(0))) }        // ← 每次搬移整个 Vec
}

迭代器的实现是「列表 + 每次从头取一个」。Vec::remove(0) 要把后面 n-1 个元素 整体左移,于是整趟遍历 O(n²)。

对照另外两处(都不慢):Python VM 用 next(stack[-1])(真迭代器)、 Node 用 makeIterator(obj).next()(真 JS 迭代器)—— 只有 Rust 这一份是 「列表 + 从头弹」。

二、修法:快照反转后从尾部 pop()

rust// 快照反转 → 迭代器从尾部弹,顺序不变、每步 O(1)
fn reversed_iter(items: Vec<Val>) -> Val { let mut v = items; v.reverse(); list_new(v) }
fn iter_next(...) { Val::List(l) => Ok(l.borrow_mut().pop()) }

为什么敢这么改(安全性论证要具体,不能只说「应该没事」): 这个列表是 GET_ITER 造出来的私有快照,全仓库只有两处碰它 —— FOR_ITER(取下一个)与循环的清理路径(POP_TOP,直接丢掉)。 没有任何地方观察它的内部顺序,所以「里面存的是倒的」不可能被看出来。

三、数据

n = 250050001000020000
修前(净耗时)25 ms141 ms626 ms2489 ms
翻倍倍率—×4.3×4.2×3.9 ← O(n²)
n = 50000100000200000400000
修后(净耗时)28.3 ms51.4 ms91.7 ms184.7 ms
翻倍倍率—×1.82×1.78×2.01 ← 线性

同一个形状、同一个 n(20000):2490 ms → 4.82 ms,约 500 倍。 行为逐字节不变:93 个语料 × 各执行器(含 Rust VM)不一致 0。

四、一个测量上的坑:基线要当场量

第一次修完复测,读出来的数是「几乎不随 n 变化」—— 差点得出 「循环没跑」的错误结论。查下去才发现:那次是 n 太小,而进程启动成本 从先前量到的 8 ms 涨到了 ~85 ms(刚编出来的 exe 被实时扫描, 过一会儿才会落回去)。

📌 零点必须跟着被测对象一起、当场量。R6.0 那次量到的 8.4 ms 是 那台机器那个时刻的零点;换个时刻、甚至只是刚重新编译过, 它就不是那个数了。拿旧零点去减新数据,会把「启动变慢」读成 「VM 变快/变慢」—— 这跟 D60 正文里「扣掉启动」是同一件事的两面: 先扣零点是对的,但零点本身也会漂。

五、剩下的三个缺口 + 顺手发现的第 4 个分岔

② 捕获基类(键错误/索引错误 ← 运行期错误)、③ 报错时 stdout 不冲出、 ④ 报错渲染缺源码行 —— 都还在 tools/check_engines.py 的 RUST_VM_KNOWN_GAPS 里登记着(登记过期会报红)。它们是 R6 主体接下来的活。

另外,写 R6.1 的守门测试时撞出第 4 个分岔(预存,与本次修复无关):

令 列 = [1, 2, 3]
遍历 项 在 列:
    列.追加(项)        ← 边遍历边往**被遍历的那个**列表里加
    打印(项)
执行器行为
树遍历 / Python VM / C VM / Node活迭代器 → 一直循环下去(与 Python 一致)
Rust VM快照 → 打印 1 2 3 就结束

四个「活」、一个「快照」—— Rust VM 是那个少数。

⚠️ 它不能写成测试:写了就等于让三个执行器死循环(我第一版就是这么 把 pytest 挂住 5 分钟的)。也不能进语料(夹具会跑它)。 「三个死循环、一个跑完」这件事本身说明:该由作者定「要哪一种语义」 ——

📌 「发现一个分岔」不等于「知道该往哪边修」。 四个对一个只是票数, 不是判据 —— 判据是「哪一边符合基石自己的语义习惯」。 拿不准就把它记下来、别动,等定了一起动 —— 这比猜一个方向然后改五处强。


D61. R6.2/R6.3:把「报错」从一个执行器的行为,变成四个执行器的同一件事(2026-10-02)

R6.0 记了 Rust VM 的四个缺口。①(遍历 O(n²))在 R6.1 修掉了,这一轮把 ②③④ 一起收掉。但收的过程比预想的深 —— 因为它碰到了一个更底层的问题:

「报错」在这门语言里一直有四份实现。 三份 Python 的共用 errors.py, 第四份(宿主)是手写的一堆字符串。

R6.2 编了一个探针(tools/probe_runtime_errors.py)把这件事量出来: 19 个常见运行期错误形状里,18 个在四个执行器之间不一致。

一、两类根因,都不是「宿主写错了」

类例子性质
Python 侧漏内部表示e.消息 = list index out of range(英文)、&#x27;乙&#x27;(键的 repr)、&#x27;int&#x27; object is not subscriptable、「([],)」(元组的 repr,多一个逗号)中文教学语言里的中英混排 / repr 泄漏
宿主侧「笼统化」把 找不到这个名字 / 找不到这个函数 / 这个东西不能调用 都报成「异常」「运行期错误」;属性错打值(「1」没有属性)而不是类型名(「整数」没有属性)e.类型 与 捕获 X 的匹配跟着一起错

📌 第一类最值得记:errors.py 里 _type_error_zh 早就为 TypeError 写过一段注释 ——「这些消息原来漏成英文……对中文教学语言来说读起来费劲。 认不出的模式返回空串、保留原文——宁可不翻,也不要翻错」。 也就是说:规矩早就立好了,只是 IndexError / KeyError 这两支没被覆盖到。 一件早就想清楚的事,仍然可能在具体分支上漏掉。

二、宿主侧那条更隐蔽:「笼统的类型名」会连坐两个功能

宿主原来把多种语义错误归到「异常」—— 看起来只是文案差,实际连坐:

修法:把 Python 侧那几个类型名的真名搬过来当常量 (T_NAME / T_FUNC / T_NOT_CALLABLE),并补进 error_code / error_title。 ⚠️ 这成了一份隐式的拷贝(宿主没有类可用,只能写字面量)—— 所以配了一条 测试:宿主的异常父子表要与 errors.py 的类继承逐项对拍。

三、缺口②③:一个「一行」和一个「忘了分支」

语言规格 §8.9 明写「基类能抓子类」,而 Python 侧是拿真类继承(isinstance)判的。 → 表补全成七支。

三个 Python 执行器是边跑边出,宿主是最后一起打印——于是程序崩了看不到跑到哪。 → 出错分支先 print!(get_output)。

📌 ③ 还有一个「判据」层面的教训:check_engines.py 那时只在没出错时比 stdout (出错就把缓冲区丢了)—— 所以这个缺口根本比不出来。 判据选错,问题就不在视野里。 修完判据((stdout, 报错文本) 两段都比) 之后,它才现形。

四、缺口④:宿主自己长出一份完整的报错渲染

宿主是独立进程,拿不到 Python 的 render()。以前它只打两行 (错误 E2001:不能除以零 + ← 第 3 行第 5 列),没有源码行、没有 ^、 没有调用链 —— 而 R7/R8 之后单二进制是唯一形态,那就是体验退步。

关键判断:不把源码塞进字节码。 那会让每份 .json 都背上整份源码 (还和源码文件两份、容易不同步)。改成出错那一刻按 filename 读一次源文件:

不崩、不报 IO 错(有测试盯着)。

调用链也补齐了:帧上记 call_line/call_col(「本帧是在哪被调用的」, 不是「当前跑到第几行」)—— 口径与 Python 的 _frames_to_trace 逐条对齐 (跳过模块主帧、匿名函数显示成「匿名函数」、最多 20 帧)。

结果:逐字相同(同一份源码、同一个错误,宿主 stderr 与 Python render() 一字不差)。

五、剩下的六条:登记,而不是「大致像」

对齐之后体检只剩 6 条,全都说得清为什么,逐个登记(登记会过期):

条原因
13_从python导入有意的边界:Rust 引擎不支持 从 python 导入(同 03/11)
15/16/17/18(四条)位置口径不同:Python 侧用 AST 调用节点的列(a.追加() 报在 a,第 2 列),宿主手里没有 AST、只能报指令的列(第 5 列)。类型/消息/源码行/调用链全一致,只差 ^ 落在哪个字符下。要对齐得让编译器把「调用节点的位置」也发进字节码(动格式)—— 待拍板
35_赋值前读局部提示语不同:Python 那句来自 Code.local_hints,而它不在字节码 JSON 里(见 docs/前端契约.md §12.3)。要一致也得动格式 —— 待拍板

✅ 2026-10-03 更新(R6.6):上面 5 条全部收掉(用户拍板动格式)。 那 4 条位置口径的修法不是「把位置也发进字节码」,而是照 Python 的机制来: 让绑定方法值随身带取属性的位置(Python 的 _BoundMethod 一直是这样, 宿主这边值在、只是没用)。local_hints 才真动了格式(v3)。

📌 「登记」和「大致像」的区别:登记是写清原因、能被复核、会过期报红; 「大致像」是把差异留在那里、谁也不知道有多少。 前者逼着人做决定(哪怕决定是「先这样」),后者只是把问题推给下一个读代码的人。

六、这一轮真正的方法论

  1. 别只看「谁写错了」,先看「有几分实现」。四份实现必然漂;

探针的价值是把「漂了多少」变成一个数(18/19 → 0/19)。

  1. 判据决定视野。同一个工具,判据从「只比 stdout」换成「stdout + 渲染」,

可见的问题从 0 个变成 26 个。

  1. 「用户看到的那一份」才是判据。比 str(e) 是比一个中间产物;

比 with_source(...).render() 才是比用户看到的东西。

  1. 修文案不是「小事」:它同时是 e.类型 的值、捕获 X 的匹配依据、

错误码的查表键。一处笼统,三处连坐。


D62. R6.4:把 Node 宿主拉进判据 —— 以及「判据的视野」这件事(2026-10-02)

D61 结尾留了一句「Node 那份是同一个缺口的下一个」。这一轮把它做了, 但做的过程又证明了一次 D61 §六 的第 2 条。

一、先做的不是修,是把 Node 拉进视野

tools/check_engines.py 与 tools/probe_runtime_errors.py 以前都只带 四个执行器(三个 Python + Rust VM)。Node 宿主从头到尾不在判据里 —— 只有 tests/test_m13_node.py 那批手写用例覆盖到的地方才有人管。

拉进来的当天,探针(19 个形状)从「0 不一致」变成 11 个不一致:

类形状Node(改之前)Python 基准
静默成功列[9] = 3不报错,还把列表撑长了索引错误 / 列表下标越界
静默成功[1].弹出(9)undefined(静默)索引错误 / 列表下标越界
静默成功1 &lt; &quot;a&quot;(check_engines 抓到的)假类型错误 / 不能比较「1」和「a」
类型名笼统甲()(非函数)运行期错误这个东西不能调用(E2006)
类型名笼统没定义过的东西异常找不到这个名字(E0301)
类型名笼统导入 不存在的模块异常找不到这个函数(E0302)
文案漂移1 / 0消息 不能除以零消息空(标题里已经有了)
文案漂移1 + &quot;a&quot;「文本」只能和「文本」相加「整数」和「文本」不能做「+」运算
文案漂移范围(1,2,3,4) / 最大([]) / a.排续()三句都不同见 probe_runtime_errors.py

📌 一模一样的教训,第二次:判据的视野有多宽,能看见的问题就有多少。 R6.2 是把「比什么」(str(e) → render())换掉,这次是把「比谁」(四个 → 五个)补上。 两次都不是「发现有人写错了」,而是先把东西放进视野,错才现形。

二、那三条「静默成功」值得单说

三条的根都是宿主语言的默认行为恰好是错的:

这就是 D61 里那句「不报错的错答案最难查」的第三、四、五副面孔。 修法是显式分派:列表赋值先查下标范围,&lt;/&gt; 走一个自己写的 orderCompare(数值按数值、文本按码点、列表逐元素),不可比就报错。

⚠️ orderCompare 顺带把另一个老坑补上了:JS 的 &lt; 比的是 UTF-16 码元, &quot;😀&quot; &lt; &quot;🀄&quot; 与 Python 的码点序不是一回事(M55 记过同一类问题)。

三、宿主那两份也能合成一份

修完之后,Rust 与 Node 剩下的缺口完全同构(同一批语料、同一个原因):

条数原因
~~4~~ → 0~~^ 落在哪一列~~(R6.6 已收:方法值带位置)
~~1~~ → 0~~「赋值前读局部」的提示语~~(R6.6 已收:字节码 v3)
3有意边界:宿主不支持 从 python / 本地包 导入(剩下的是设计,不是缺口)

于是把两张登记表合成一张 HOST_KNOWN_GAPS。理由:它们说的本是同一件事, 两张表迟早会漂。机制上还加了一条:两个宿主都对齐了才算「登记过期」 (以前一个宿主修好就报过期,会逼人把另一个宿主的登记删掉 —— 那正好是错的)。

四、顺手挖到「基准自己也在漂」

对齐过程中碰到两处不是宿主的问题,而是 Python 侧的问题:

  1. 不能比较「None」和「1」 —— f&quot;不能比较「{left}」和「{right}」&quot; 用的是裸

f-string,于是 None 这个英文漏到用户眼前(同 R3 的分段表、R4 的 None), 而且嵌套容器里的字符串会丢引号([[&#x27;a&#x27;]] 显示成 [[a]])。 → 改用 jishi_repr(top=True)(打印 那一套),三个执行器一致地变好。

  1. 1 &quot;a&quot; 报 unsupported operand type(s) for or pow(): &#x27;int&#x27; and &#x27;str&#x27;

—— _type_error_zh 认的是 for (\S+): 那一版格式,而 的原文多了 or pow(),于是这条一直漏英文。→ 补一条模式,先试 再试通用。

📌 「同一个行为有多份实现」不只发生在宿主之间。Python 侧自己也有多个分支 (IndexError / KeyError / 的报错格式 / 真类继承 vs 手写表), 只要分支多了,漏就是时间问题。所以:改文案时先数一数「这个语义在几处被实现」**。

五、登记一条新发现(需要拍板)

体检顺带撞见一个语义分叉:负索引。

令 列 = [1, 2, 3]
打印(列[-1])        # 三个 Python 执行器:3(Python 的负索引)
                    # 两个宿主:索引错误 / 列表下标越界

三个 Python 执行器支持负索引(它们底下就是 Python 的 list),两个宿主 拒绝(Node 显式要求 index &gt;= 0、Rust 同理)。语料里没人用过负索引, 所以这一步谁也没发现。

⚠️ 这是语义分叉,不是文案分叉 —— 修法两条路: (a) 宿主实现负索引(对齐 Python 的语义);(b) 语言层面禁掉负索引(改基准)。 按「三执行器语义必须一致」+「Python 是基准」,倾向 (a),但那是语义变更, 待拍板。在定下来之前,这个形状不要进语料(否则夹具会冻出一个 「两个宿主都不对」的基准)。


D63. R6.6:把最后 5 条登记收掉 —— 以及「登记里写的原因,未必是真正的原因」(2026-10-03)

D62 结尾留了 5 条登记(4 条「^ 落在哪一列」+ 1 条 local_hints), 都标着「要动字节码格式 → 待拍板」。用户拍板:都做了。

一、那 4 条:真正的原因不是「宿主没有 AST」

登记的原文写着:

Python 侧这类错用的是 AST 调用节点的列,宿主手里没有 AST、 只能报指令的列。要对齐只有一条路:让编译器把「调用节点的位置」 也发进字节码(动格式)。

真去修的时候,先把两边实际报的位置量出来,才发现描述就不准:

令 a = [1]
a.追加()        →  Python 报 :2:2     宿主报 :2:5

:2:2 是那个 .,:2:5 是那个 (。再 --dump-bytecode 一看:

指令位置
GET_ATTR 追加@2:2
CALL@2:5

Python 报的就是 GET_ATTR 的位置,宿主报的就是 CALL 的位置。 两边手里都有这两个数 —— 差的不是「数据有没有」,是「报错时挑了哪一个」。

于是修法完全变了,而且不用动格式:让「绑定方法值」随身带着 取属性的位置(runtime._BoundMethod.line/col 在 Python 侧一直是这样: _BoundMethod.__call__ 出错时「只补还没填的」位置),宿主照这个机制来 ——

_doCall 里只是没用它;

参数,调用出错时补位置。

⚠️ 不能按「名字里有 .」一刀切(试过,被自己的测试拦住):

形状两边该报哪为什么
a.追加():2:2(.)值是 BoundMethod,带位置
数.开方():2:5(()模块属性是裸 Builtin,不带位置
甲():2:2(()普通调用,两边都用调用点

📌 教训:登记里写的「原因」是当时的猜测,不是事实。 登记表的价值不只是「记住有哪些差异、别忘掉」——它还是一张待验的假设清单。 真去修的时候必须把现象重量一遍:这 4 条挂了两轮(R6.3、R6.4)都写着 「要动字节码格式」,而实际上一个字节都不用动。 如果当初照着那句原因直接去改格式,会做一件大得多、而且并不解决问题的事 (把调用点的位置发进字节码,CALL 的 c 槽确实空着 —— 但宿主拿到的仍是 「调用点」,而 Python 报的是「取属性点」,一样对不上)。

二、local_hints:这条才真动了格式

字节码升 v3:Code 多一个 local_hints(槽位号 → 提示文本, Python 侧本来就有这个字段、只是没序列化)。

改的地方有四处,少一处就是「宿主静默拒绝加载」或「按旧格式错误解析」:

在哪干什么
jishi/serialize.pydump 写出去、load 读回来(只改一半最常见)
core/src/serialize.rsRust 前端也得产
node/index.js解析 + 版本检查(顺手把写死的 2 换成 FORMAT_VERSION 常量)
rust/src/lib.rs解析 + 版本检查(BYTECODE_VERSION)

📌 加了一条测试把它们钉在同一根线上: test_m25_varargs.py::test_字节码格式版本四处同步 —— 读四个文件里的数字比一遍。 「改一处忘三处」是这类常量的典型死法。

🔴 闸门又立功了:Rust 前端第一版把槽位号直接当 JSON 键写(1:&quot;…&quot;, 没引号),产出的根本不是合法 JSON。它没有静默通过 —— conformance emit 当场报「非基石错误,拒绝产出」,正是 R0 立的那道闸门 (「自动化工具分不清事实与事故」)。修一个字符就好了。

✅ 两个前端四阶段 93/93 全一致,基线重冻(diff 干净:只有 version 2→3、 checksum、以及新增的 local_hints)。

三、顺带撞见第 5 个既有缺口(新登记,待拍板)

量位置时顺带发现:「实例方法参数个数错」报在哪一行,三个 Python 执行器自己就不一致。

类 甲:
    函数 方法(自身, x):
        返回 x
令 o = 甲()
o.方法()

→ 树遍历 :5:5    Python VM :2:1    C VM :2:1    Rust :5:5    Node :5:5

树遍历与两个宿主一致(调用点),VM / C VM 报到了函数定义处。 根因在 Python 侧:

直接 re-raise、不补 —— 于是参数绑定错保留它自己的位置(定义处)。

修法看着简单(照 _BoundMethod 补一次),但它补不了:_BoundMethod 用的是 「只补还没填的」,而这条错已经有位置(定义处)→ 补不上。 要么改成「覆盖」(那是语义变更:会影响其它用户函数的报错位置), 要么去改 Fn 的参数检查。两条都有牵连,因此不在本轮做。

⚠️ 语料里没有这个形状(所以夹具一直绿的)—— 与 R6.4 那三条「静默成功」 同一类:判据没覆盖到的地方,错了也没人知道。按规矩:定下来之前, 这个形状不要进语料(否则会把「三个执行器不一致」冻成基准)。

✅ 2026-10-03 更新(R6.7):已修,语料也进了。 修的过程比预想的曲折, 因为根因不是一个,是三个(还分散在三种语言里): Python VM —— call_function 里 _bind(..., line=code.firstlineno, col=1), 位置写死定义处; C VM —— C 侧 jsvm_call_function 里 bind_args(..., 0, 0), 把宿主传下来的调用点丢了; * 顺带:_cb_method_call 没记 _pending_call_site(只有 _cb_call 记), 于是方法调用那条路上读到的是过期值或 0。 修法都是「照树遍历的机制来」:让「Python 侧回流」的入口用调用点。 🔴 顺带撞出两笔:① Rust 的用户方法调用把关键字参数静默丢了 (Val::BoundMethod(User) 压帧写的是 &amp;[], &amp;[]),对.方法(不存在的名=1) 报成「缺少参数:x」(看着像另一件事)—— 现在 kwargs 一起传下去; ② 树遍历的类构造参数错没有行列(VM/CVM/两宿主都有), 在共享的 runtime.make_instance 上补一次(「只补还没填的」)。 🔴 还挖出一个「改了没生效」(不在三执行器语义里,但在工具链里,更阴): jishi/_native/jsvm.dll(wheel 预编译)与 cvm/bin/jsvm.dll(cvm/build.py 产物) 是两份,而 _lib_path() 优先前者 —— 改了 cvm/src/jsvm.c 并重建, Python 加载的仍是那份九天前的 dll,一句提示都没有。 排查时「C 侧的修法像是完全没作用」,兜了一大圈。 已把源码树优先(cvm/bin/ 排在 _native/ 前面)—— 发布链不受影响 (wheel / PyInstaller 里没有 cvm/bin/,自然回退)。 📌 又一次「静默用错产物」:这类 bug 的共同点是没有报错, 只有「怎么改都不动」这一个症状。

四、验收

闸门结果
一致性夹具 / Rust 前端四阶段93/93(重冻后)
执行器体检(五执行器,含报错渲染)不一致 0;登记 3 条(全是「有意边界」)
运行期错误探针 / 标准库探针 / 内建探针0/19 · 0 条 · 无悬案
全量回归见 PROGRESS.md

新增 tests/test_m69_host_err_pos.py(14 项):那 5 条语料在 Python CLI / Rust / Node 三处 stderr 逐字节相同,且位置落在对的地方 (不只是「一致」—— 当初两边一起错也算一致)。


D64. R6 主体开工:字典 O(n²) → O(n)(2026-10-03)

R6 前半段(R6.0–R6.7)都在做报错对齐,那是「跑得对」。R6 主体的活是 「跑得快」——把 Rust VM 从「兼容宿主」提升为「核心」。用户拍板先补 R6 主体 (再做 R7 工具链)。

一、第一刀落在哪:字典

R6.0 的度量里,Rust VM 几乎全面反超树遍历(4–29x),唯一落后的是 「字典写入密集」0.30x。根因一眼就能看出:JDict = Vec&lt;(Val,Val)&gt;, dict_set 用 iter().find() —— 写入是 O(n²)。

按 R6.1 立的纪律「先修 O(n²),再谈常数因子」,这是第一目标。

二、为什么不能直接 HashMap&lt;Val, _&gt;

字典要 O(1),第一反应是 HashMap&lt;Val, Val&gt;。但 Val 的 == 是跨类型数值 相等(1 == 1.0 == 精确(&quot;1&quot;) 都是真),而:

直接给 Val 实现 Hash,就会「两个相等的键哈希不同」→ 落在不同桶 → 字典「查得到」变成「查不到」。这是哈希表的第一铁律:哈希必须与相等一致。

所以哈希键单独规范化成 DictKey:

rustenum DictKey { None_, Bool(bool), Int(i128), Str(String) }

DictKey::Int(1) —— 于是 1 和 1.0 哈希一致(== 也相等)。

传递)/ NaN 等 → 返回 None,回退线性扫描(正确但慢,且这种键极罕见)。

结构是「Vec 保序 + HashMap 索引」:

ruststruct Dict { items: Vec<(Val, Val)>, index: HashMap<DictKey, usize> }

items 保持插入顺序(迭代、键()、值()、显示、== 都依赖它 —— 基石字典 对齐 Python 的有序 dict,顺序必须确定);index 给可哈希键 O(1) 定位。 dict_set 命中就就地替换(位置不变),否则追加并登记 index。

⚠️ dict_remove 有一个易错点:删除会让 items 后面的元素前移,所以 index 里所有 >pos 的位置都要 -1。这是重构里唯一需要专门测的点(进了 test_m71)。

三、顺带修掉两个跨语言分叉(对拍撞出来的)

  1. Rust 字典 == 是顺序相关的:旧实现直接比 Vec&lt;(Val,Val)&gt;(顺序相关),

{1:2,3:4} == {3:4,1:2} 在 Rust 给 假、其它四个执行器给 真。 Python dict 的 == 是顺序无关的 → 改成「长度相同 + 每个键都在对方里」 (用 index 做 O(1) 查找)。

  1. Node 字典把 1 和 1.0 当两个键:原生 Map 用 SameValueZero(按**对象

身份**),而 Node 的浮点是 FloatBox 对象、整数是 number/bigint —— 字[1]=10; 字[1.0]=99 会变成 3 个键。改成先按 valueEq 找键(增删查全过)。

📌 这两笔都是「语料没覆盖到的地方,错了也没人知道」(R6.4 的老教训)—— 字典键用「整数 + 浮点混合」、字典「顺序不同的相等」都不在语料里。

四、度量的坑:短场景不能看 benchmark 的倍数

修完之后跑 tools/benchmark.py,字典写入显示「0.30x → 0.96x」,看着只好了 3 倍 —— 但 microbenchmark 证明 n=16000 也只要 ~7ms(已经是 O(n))。

原因是 benchmark 的 Rust 那一列要「扣掉 98ms 的进程启动成本」,而字典写入的 净耗时只有几毫秒 —— 扣完启动后剩下的全是噪声,倍数当然失真。这正是 benchmark 自己注释里预言的:「只有单轮耗时远大于启动成本的场景才说明问题」。

📌 短场景的性能,判据该是「复杂度」(n 翻倍看耗时翻几倍),不是「那一列的 倍数」。R6.0 也记过同一类教训(零点要当场量)。

五、验收

删除重排 / 更新 / 合并 / 弹出 / 按值排序);

一致性夹具 94/94 —— 字典重构没破坏任何语义;

下一步(R6 主体继续):集合 O(n)→O(1)(同款手法)、整数循环/算术 vs C VM 的常数因子差距、以及 Rust 树遍历参考执行器。


D65. R6 主体续:集合 O(n) → O(1) —— 以及「一个形状都没进过语料」(2026-10-03)

D64 把字典的 O(n²) 收掉之后,按同一个思路看集合。集合原来是一个裸 Vec&lt;Val&gt;:添加 去重靠线性扫、包含 是 O(n)、移除 是 Vec::remove (O(n))——「建 n 个元素的集合」是 O(n²)。

一、结构:保序 + 索引 + 墓碑

rustpub(crate) struct Set {
    items: Vec<Option<Val>>,        // 保序;None = 墓碑
    holes: usize,
    index: HashMap<DictKey, usize>,
}

哈希表),迭代顺序必须确定 —— 用无序哈希集合会让同一程序两次运行输出不同, 跨宿主对拍还会随机失败。这一条是不能动的(规格里写着「集合是有序的」)。

而 Vec::remove 要把后面整段前移 —— O(n),于是「删 n 个」还是 O(n²)。 第一版我图省事就写了 items.remove(pos) + 修 index,量出来 n=16000 批量删除 要 1845ms(×8 / ×4.3 / ×4.05 —— 一眼 O(n²))。 改成置 None 打墓碑(O(1)),空洞过半时压一次(摊还 O(1)), 同一场景降到 37ms(≈49 倍)。

这条有专门的语料与测试(18_集合.jsh 的尾部、test_m72 的「删过半仍保序」)。

二、顺带撞出三个既有分叉(都不是本轮引入的)

对拍集合时冒出 4 个不一致,追下去发现根因都在别处:

#现象根因
1真 == 1 / 假 == 0 / 真 == 1.0 只有 Rust 给「假」Rust 的 Val::eq 少了「布尔与数值」这一支
2{真, 1} 在 Rust 上是 2 个元素同上(键归一也没把布尔当数字)
3「不能放进集合」的报错三个执行器三个说法三侧各写各的:Python message 短 + hint 一行;Rust 把 hint 揉进 message 且少半句;Node 又是第三个变体
4同一条提示语在 Node 上不显示runtime.js 的『添加』方法表 catch 后重建错误(只为补调用点位置),把 hint 丢了

第 1/2 条是同一处:Val::eq 缺分支 + 哈希键没跟着归一。== 与哈希键是一对 ——「== 说相等」而「哈希键不同」的话,字典 / 集合就会「查得到」变「查不到」。 Node 侧其实一直是对的(setKey 的注释里写着「Python 里 True == 1,所以布尔 按数字归键」),只有 Rust 漏了。

📌 修「相等」时永远顺手搜一遍「哈希 / 键」:这是哈希表的第一铁律 (哈希必须与相等一致),而它在四份实现 × 十几个类型上,很容只改一边。

第 4 条最阴:catch 里重建错误对象是对的(要补位置),但重建会把没显式带过来 的字段全丢掉。加个 hint 参数就好了 —— 问题是「不问就发现不了」: Python 侧渲染有、Node 侧渲染没有,只有把两边完整渲染逐字比才看得出来。

三、新登记(既有缺口,待拍板):精确小数能不能做字典键?

量形状时顺手撞见:d[精确(&quot;1&quot;)] = 2

两边都不好看:Python 侧那句英文是内部表示泄漏(用户看不懂,也不该看见类名); 宿主侧「允许」则与 Python 语义不一致(1.5 == 精确(&quot;1.5&quot;) 为真,做成两个键的话 「同一个键」会裂开)。

修哪边要拍板(语义问题:「哪些值可以做字典键」),所以:

与 D63 §三 同一规矩。

四、真正的教训:tests/cases/ 里一个集合语料都没有

上面那些分叉能藏这么久,原因不是「藏得深」,而是判据里根本没有集合:

$ ls tests/cases/ | grep 集合
(空)

tests/cases/ 是四阶段夹具 + 全语料五执行器体检的语料来源。一个集合语料 都没有 → 集合的语义从来没进过任何自动判据 → 手上的手写测试覆盖到哪里才有人管。 (与 R6.4「Node 不进判据就等于没实现」是同一类,只是这次缺的是语料而不是执行器。)

所以本轮先补语料(tests/cases/18_集合.jsh + .out),再修代码 —— 语料一进去,那 4 个不一致立刻可见,改完也立刻能证明改对了。

📌 「没被语料覆盖的功能」等于「没被实现」:一个语言特性写完了、手写测试也过了, 只要没进 tests/cases/,它在四阶段夹具与全语料体检里就是不存在的。 加语言特性时顺手加一条语料,成本很低。

五、验收

闸门结果
集合对拍(自建,30 形状 × 五执行器)0 不一致
布尔 / 数值对拍(自建,17 形状)0 不一致
一致性夹具 / 两前端四阶段95/95(新增 18_集合.jsh,diff:新增 1、变更 0)
执行器体检(五执行器,含报错渲染)不一致 0;登记仍 3 条(全是有意边界)
运行期错误 / 标准库 / 内建探针0/19 · 144 条 0 · 无悬案
复杂度(自建,n 翻倍看倍数)添加 ×1.7~1.8 · 成员 ×1.8~2.1 · 删除 ×1.7~2.0(改前 ×8/×4/×4)

新增 tests/test_m72_set_hash.py(17 项)。


D66. R6 主体续二:整数循环 —— 全局变量表与整数比较(2026-10-03)

D65 之后按顺序做下一件:「整数循环 / 算术 vs C VM 的常数因子」。基准里这一项 Rust 比 C 慢 4.9 倍(80.7ms vs 16.5ms),是所有场景里差距最大的一个。

一、先量:把「循环」拆成一段一段

单个场景只有几毫秒,扣掉 86ms 的进程启动之后全是噪声。把规模放大到 n=500000 再量,数才稳(R6.0 记过「零点要当场量」,这里还要加一条 「规模要够大,否则净耗时被启动成本的噪声吞掉」)。

然后按「循环体里放什么」拆开:

场景(n=50 万)C VMRust VM比
① 只有循环69.4ms170.1ms2.45x
② 循环 + 双变量读73.8ms227.7ms3.08x
③ 循环 + 乘法78.6ms248.8ms3.17x
④ 混合算术102.7ms417.3ms4.06x
⑥ 当 循环倒计数25.9ms199.2ms7.69x

关键的一步是把同一逻辑换个地方写(模块级 vs 函数内局部):

场景C VMRust VM比
当 循环 · 模块级变量26.2ms198.3ms7.58x
当 循环 · 函数内局部27.8ms90.7ms3.27x
累加 · 模块级75.0ms232.2ms3.09x
累加 · 函数内局部74.6ms107.1ms1.44x

C VM 两者完全一样,Rust 差一倍多。 这就把矛头指到了「全局变量」上。

📌 通用的定位手法:同一个逻辑,换一种写法再量一遍。 差值落在哪,瓶颈就在哪 —— 比读代码猜快得多,也比 profiler 好读。

二、根因:Rust 的全局变量表是 HashMap&lt;String, Val&gt;

rustop::LOAD_GLOBAL => {
    let name = self.module.names[ins.a as usize].clone();   // ← 分配一个 String
    match self.globals.get(&name) { ... }                   // ← 再哈希查找
}

而 C VM 一直是数组:JVal globals; / 按名字下标索引 */,vm-&gt;globals[a] —— cvm_bind.py 那边也是「按 names 顺序把内建铺成数组」再交给 C。 只有 Rust 侧用了 HashMap。

ins.a 就是 module.names 的下标(编译器保证 names 去重,我验证过: [&#x27;甲&#x27;, &#x27;打印&#x27;, &#x27;长度&#x27;] 无重复),所以完全可以按它索引:

rustglobals: Vec<Option<Val>>,        // 长度 = names.len();None = 未定义

「已定义的槽位」——close_matches 本来就有全序排序(分数降序 + 同分名字 降序),所以顺序无关 ✓(顺手还消掉了 HashMap 随机顺序带来的不确定性)。

效果(交错 A/B,同机同时段):

场景改前改后提速
当 循环倒计数(模块级)190.7ms55.8ms3.42x
全局读写(无算术)173.9ms74.8ms2.32x
整数循环累加(模块级)222.2ms105.4ms2.11x
混合算术(模块级)421.8ms246.9ms1.71x

改完之后「模块级」与「函数内」基本持平(改前是差一倍多)。

三、顺手撞出的既有 bug:整数比较走了 f64

看 compare 时发现它的数值分支是:

rustlet (la, lb) = (l.clone(), r.clone());          // ← 白克隆,只为报错用
let (a, b) = to_num_pair(l, r)?;                // ← 两边都 `as f64`

所有整数比较都先转 f64 —— 超过 2^53 就开始失真:

2 ** 62 > 2 ** 62 - 1   →   另外四个执行器:真      Rust:假   ✗

(== / != 走 Val::eq,本来就精确;出问题的只有 &lt; &gt; &lt;= &gt;= 四支。)

修法就是加一条整数快路径 —— 它同时是提速与修正确性:

rustif let (Val::Int(a), Val::Int(b)) = (&l, &r) {
    return Ok(Val::Bool(match op { 2 => a < b, 3 => a > b, 4 => a <= b, 5 => a >= b, .. }));
}

效果(交错 A/B):当 循环(函数内局部)86.0 → 51.6ms(1.67x); 「大整数比较」场景 326.5 → 119.2ms(2.74x)。

📌 又是同一条:语料里没有大整数比较(tests/cases/ 里连集合语料都刚补上), 所以这个 bug 一直是绿的。补了 tests/cases/19_整数比较.jsh。

四、benchmark 的那个坑(也是这一轮才看清)

tools/benchmark.py 的 Rust 列 = 「实测总时长 − repeats×启动成本」。 问题是净耗时比启动成本还小的场景,这次减法会把启动成本的估计误差放大:

「整数循环累加」那格显示 55ms,而把同一逻辑放大到 n=500000 实测只有 4.2ms / 20000 次 —— 差了 3 倍。(第一版「字典写入 0.30x → 0.96x」也是 同一个坑,D64 已经记过一次。)

现在给这种格子打 ⚠ 标记(判据:净耗时 < 启动成本 ÷ 3),并在列尾写明 「别用它下判断」。精确的倍数只认「同机同时段交错 A/B + 足够大的 n」。

五、这一轮没做的(如实记)

那处(每进帧克隆一次指令数组),对斐波(18) 只有 ~1ms(约 2%)—— 不是主因。 真正的大头在每帧 2–3 次堆分配(locals / locals_set / cells), 要动「栈式帧」那一层,是另一件大事。

🔴 2026-10-05 实测更正:128 字节(撑大它的是内联的 Class 载荷 120 字节, 不是当初以为的 i128/String)。详见 D77 §三;那时这条是没量过的估计, 正好印证本项目的规矩:「以为」不能代替「量过」。 瘦身(把大载荷装箱)影响很多地方,是独立的一件。

不做(不为 2% 去动 VM 的字段类型)。

六、被全量回归抓到的一处(我改出来的)

改完 globals 之后,「找不到名字」的候选提示在 Rust 上没了 —— test_m51_consistency.py::test_M56_宿主也给候选名 报「Rust 没给候选」。

根因是我把候选池缩小了:原来是 globals.keys()(= 全部内建 + 用户变量), 我改成了「names 里已定义的槽位」。而 names 只装本文件里出现过的名字:

打印(长渡([1]))   →   names = ['打印', '长渡']     ← 里面没有「长度」

提示要说「你是不是想写「长度」?」,可「长度」根本不在 names 里 → 池子空 → 没候选。C VM 侧一直是 pool = dict(self.globals)(全内建)再补用户全局 —— 照抄就对了(修完即在 VM 里存一份 builtin_names)。

⚠️ 我的自建对拍没抓住它:test_m73 里那条用例写的是 令 长度 = 1 \n 打印(长渡) —— 它恰好把「长度」放进了 names,于是池子 虽然变小、结果却没变。用例要挑「会让口径分叉的数据」(M51 那条老教训), 现在改成两个形状都测(内建名在 / 不在 names 里)。

📌 又一次印证:单项验收绿 ≠ 没坏 —— 全量回归才是那张大网。

七、验收

闸门结果
一致性夹具 / 两前端四阶段96/96(新增 19_整数比较.jsh)
执行器体检(五执行器,含报错渲染)不一致 0;登记仍 3 条(有意边界)
运行期错误 / 标准库 / 内建探针0/19 · 144 条 0 · 无悬案
交错 A/B(同机同时段,5 轮取最优)全局读写 2.32x · 当 循环 3.42x · 大整数比较 2.74x · 整数累加 2.11x
全量回归见 PROGRESS.md

新增 tests/test_m73_int_compare.py(13 项)+ tests/cases/19_整数比较.jsh。


D67. R6 主体续三:函数调用 —— 帧创建的三处开销(2026-10-03)

D66 结尾如实记了「没做的:函数调用密集」。这一轮把它做了。

一、先量:把「调用」拆成不同参数个数的场景

基准里「函数调用密集」(斐波那契)Rust 比 C 慢 5.2x,但那个场景的净耗时被 进程启动吞掉大半。自己拉一张表(同机同时段,规模够大):

场景(n=20 万次调用)C VMRust VM比
零参调用92.2ms123.7ms1.34x
一参调用119.5ms193.9ms1.62x
三参调用118.7ms242.8ms2.05x
闭包捕获112.3ms214.1ms1.91x

每次调用的额外开销(减去零参那条): Rust 一参 +350ns、三参 +595ns;而 C VM 几乎与参数个数无关(±27ms)。 → 差异不在「算术」也不在「参数个数」,在每次调用的固定开销上。

二、三处根因(按「掏钱的多少」排序)

① instrs.clone() —— 每进一次帧就克隆整个指令数组

rustfn execute_frame(&mut self, fi: usize) -> ... {
    let instrs = code.instrs.clone();     // ← 一次堆分配 + 拷贝,每次调用都来一遍

为什么会有这一行:循环体里到处是 self.xxx()(要 &amp;mut self),直接借用 self.module 会和借用检查打架。修法是换持有方式,不是「忍一忍」 —— 把 module 换成 Rc&lt;Module&gt;,克隆一次只加引用计数:

rustlet module = Rc::clone(&self.module);          // 廉价
let instrs = &module.codes[code_idx].instrs;   // 长期借用 **局部变量**,与 self 无关

📌 「为了绕开借用检查而克隆」是一个信号:先问「换个持有方式能不能不克隆」 (Rc / 索引 / 借用拆分),实在不行再克隆。

② 用 HashMap&lt;usize, Val&gt; 装参数

参数通常 1~3 个,一次哈希表分配 + 每次 insert 的哈希纯属浪费 → Vec&lt;Option&lt;Val&gt;&gt;。 顺手把中间那个「按参数顺序的值表」也省掉了(单元表既然在手上就直接写)。

③ 每帧两次堆分配(locals / locals_set)→ 帧池

帧弹出时回收,下次调用复用容量(pop_frame 清内容、留容量)。

三、⚠️ 更正上一轮的估算(如实记)

D66 结尾我写的是:

「量过 instrs.clone() 那处,对斐波(18) 只有 ~1ms(约 2%)—— 不是主因」

这是错的。 实际它是最大的一处:单改这一项, 斐波(24) 就从 135ms 掉到 95.7ms(1.41x)。当时的估算错在两点: ① 拿最小的那个场景(斐波(18),调用次数少、指令数少)算;② 没有把「一次堆分配」 算进去(只看「拷贝了多少字节」,而分配本身是固定的大头)。

📌 教训:「某处只占 x%」这种话,不把那一项单独改掉量一遍,就不要说。

四、帧池最危险的两处(翻了车就是「不报错的错答案」)

当成「未赋值」—— 与 M54 那条账同源);

所以:pop_frame 里把 locals / locals_set / cells / loops / handlers 全清掉、只留容量;bind_args 里 locals_set 整个重置成 false。 两条都有专门的测试(test_m74 的「未赋值局部在帧复用后仍然报错」/「闭包各自独立」)。

五、效果(交错 A/B,同机同时段,5 轮取最优)

场景改前改后提速
斐波(24)(15 万次调用)131.9ms88.9ms1.48x
一参调用 20 万次193.8ms132.1ms1.47x
三参调用 20 万次238.3ms165.3ms1.44x
闭包捕获 20 万次213.2ms148.8ms1.43x
零参调用 20 万次122.3ms107.6ms1.14x

对 C VM 的差距:2.0~2.3x → 1.2~1.5x(斐波(22) 2.19x→1.51x、 斐波(24) 2.33x→1.53x、一参 1.62x→1.24x、三参 2.05x→1.48x)。 benchmark.py 的「函数调用密集」一栏 86.2ms → 63.6ms。

六、这一轮没做的

~~Val 瘦身(现在 size_of::&lt;Val&gt;() 32 字节:Int(i128) 16 + tag; C 侧的 JVal 更小)~~ 🔴 2026-10-05 更正:实测 128 字节(--dump-val-size),32 这个数是错的。 收益也已实测(D77 §三):N=200 万时「建列表 + 遍历」比「不建列表」多花 1143 ms, 而列表载荷就是 200 万 × 128 B = 244 MB。瘦身(把 Class/Func/ExcValue/模块载荷 装箱)能压到 32 字节,是值得做的一件 —— 待排期。 它要动 Val 的表示,牵动几乎所有 match,所以要单独一件做。


D68. R6 主体续四:树遍历参考执行器(第一个切片)(2026-10-03)

docs/架构迁移路径.md 的 R6 主体三件事的最后一件:Rust 树遍历参考执行器。

一、为什么要它 —— 「独立实现互拍」是这套架构最值钱的验证资产

五个执行器(树遍历 / Python VM / C VM / Rust VM / Node)之所以能互相抓 bug, 靠的是语义独立。字节码 VM(不管哪份实现)有一个天生的盲区: 它照着字节码跑 —— 字节码生成错了,它跑得「完全正常」,只是结果不对。

树遍历直接看 AST,不经过字节码 —— 两边对不上,问题就落在编译器上。

二、关键设计决策:吃 AST JSON,而不是「宿主自己 parse」

第一反应是让宿主直接调 core 的 parser。查了一下可行性:

core/src/parser.rs     pyo3=0   2686 行
core/src/tokenizer.rs  pyo3=0    995 行
core/src/compiler.rs   pyo3=0   1716 行
core/src/lib.rs        pyo3=30   439 行   ← 只有它是 pyo3 包装层

parser 本身不依赖 pyo3,理论上把 lib.rs 的接口层用 #[cfg(feature)] 隔离、crate-type 加 rlib,宿主就能依赖它。

但没有必要:AST 已经是契约产物(docs/前端契约.md §4.2 的 ast 阶段, 两个前端都产它)。吃 JSON 有三层好处:

  1. 宿主不必碰 core 的构建(少一层 pyo3 解耦与跨 crate 依赖);
  2. 与 R5「产物优先给文本」同一条思路 —— 宿主只认文本产物;
  3. 前端换成哪一份(Python / Rust core)都不影响它。

代价是走一遍 JSON 序列化 —— 这是一致性工具,不是性能热点,划算。

三、第一个切片的边界(如实记)

只实现了 8 种节点:Program / Assign(=) / ExprStmt / Name / Num / Str / BinOp / Call(含 打印)。

未实现的节点明确报「还没实现「X」」 —— 绝不静默跳过(那会给出一个 看起来正常的错答案,本项目最恨这个)。Val / binop / display / 内建表 这些语义层是共用的(都在 lib.rs 里)—— 有意如此:要抓的是 「编译器 vs 求值」的差异,不是两份 + 的实现谁对。

新工具 tools/check_walk.py(进度仪表)跑出来的现状:

语料 70 个 · **一致 7** · 还没实现 63 · **不一致 0**

还没实现(按出现次数):
   17 × Import     15 × FuncDef    11 × List      3 × If
    3 × 内建函数「长度」  2 × ClassDef   2 × Dict      2 × Compare
    1 × For / Loop / 复合赋值 / 范围 / 输入 / $文本 / 调用目标「Attr」/ TargetList

这张表同时就是下一步该补什么的清单(按「影响多少语料」排序)。

四、分步计划(按上面的清单)

  1. 控制流 + 字面量:If / While / For / Loop / Compare / BoolOp /

UnaryOp / List / Dict / Pass / Break / Continue —— 补完这些能覆盖大部分 tests/cases/;

  1. 内建调用接上:现在 打印 是本地实现的,其余内建(长度/范围/输入…)

要接进来。⚠️ 这里有一个架构问题要拍板:内建函数的签名是 fn(&amp;[Val], &amp;mut VM),而树遍历执行器没有 VM。三条路: ① 把签名改成 &amp;mut dyn BuiltinHost(VM 与 Walker 都实现它)——最干净, 但要动所有内建;② 树遍历执行器内置一个「最小 VM」;③ 重写一份内建(不行, 会漂)。倾向 ①,但要单独量影响面。

  1. 函数与闭包:FuncDef / Return / Lambda —— 要作用域链 + 单元表;
  2. 类:ClassDef / Attr / Subscript / TargetList(复合赋值);
  3. 异常与导入:Try / ExceptHandler / Raise / Import;
  4. 报错渲染对齐:现在只对拍成功路径的 stdout —— 报错要复用 render_error

(它需要源文件名 + 读源码,宿主这一步要先接上)。

五、验收(第一个切片)

闸门结果
新工具 tools/check_walk.py语料 70 · 一致 7 · 还没实现 63 · 不一致 0
01_算术.jsh 两条路逐字一致✅(树遍历 = 字节码 VM = .out 期望值)
未实现的节点明确报错,不静默给值(有测试钉住)
运算符表与字节码契约读文件比对(walk.rs vs core/src/opcodes.rs)

新增 rust/src/walk.rs(~250 行)+ tools/check_walk.py + tests/test_m75_walk.py(4 项)。

⚠️ 这一件还没做完(只做了第一个切片):上面 §四 的 2–6 步是后续。


D69. R6 主体续五:内建宿主接口 —— 内建只有一份实现(2026-10-04)

D68 §四第 ② 步「内建调用接上」卡在一个架构选择上,三条路:

  1. 内建签名 fn(&amp;[Val], &amp;mut VM) → &amp;mut dyn BuiltinHost(VM 与 Walker

各实现它)—— 最干净,但要动所有内建;

  1. 树遍历侧内置一个「最小 VM」—— 省事,但那是第二份宿主语义,会漂;
  2. 重写一份内建 —— 直接出局(内建语义变两份,互拍立刻变噪音)。

用户拍板走 ①。这一轮把它做了。

一、接口是先把用法列出来再定的,不是拍脑袋定的

动之前先数了一遍:内建里到底访问了 vm 的什么?

$ grep -oE '\bvm\.[^ (]+' rust/src/lib.rs | sort | uniq -c
      4 vm.lookup_method      4 vm.format_value
      2 vm.make_iter          2 vm.call_value
      2 vm.output.push_str    1 vm.output.push

一共只有 5 个能力(output 的两个用法合成一个 write_output)。所以:

rustpub(crate) trait BuiltinHost {
    fn format_value(&mut self, v: &Val, top: bool) -> Result<String, JishiError>;
    fn lookup_method(&mut self, obj: &Val, name: &str) -> Option<Val>;
    fn make_iter(&mut self, obj: Val) -> Result<Val, JishiError>;
    fn call_value(&mut self, callee: Val, args: Vec<Val>) -> Result<Val, JishiError>;
    fn write_output(&mut self, s: &str);
}

📌 别凭感觉定接口:把「现在到底用了哪几个方法」列出来,接口就有了。 猜出来的接口要么多(多一个方法,树遍历侧就多一处「实现不出来」的负担)、 要么少(做不下去再返工)。

VM 的实现在同一个 impl 里逐条转发给它的固有方法(VM::format_value(self, …) 全限定写法,免得读的人以为是递归)—— 一行一个,没有逻辑。

二、规模:lib.rs 只有 4 处,标准库有 217 处

第一版只改了 lib.rs,一编译就撞出一片错误:stdlib/*.rs / crypto.rs / http.rs / regex.rs / zip.rs 里每个函数也是 Val::Builtin(name, |args: &amp;[Val], _vm: &amp;mut VM| -&gt; R { … }) —— 共 23 个文件、217 处(其中 211 处从不碰 vm,所以叫 _vm)。

改动是机械的(正则批量替换 + 每个文件补一行 use crate::BuiltinHost;), 但先用断言把所有 &amp;mut VM| 都数了一遍:217 == 217 才动手 —— 只要有一处形态不同,就停下来看清楚,而不是「替换了多少算多少」。

真正用 vm 的只有 6 处(分组 / 计数 的 call_value,日志 的四个级别走 一个 输出 辅助函数);那一个辅助函数的签名也跟着改(它是唯一一处 非闭包形态的 &amp;mut VM —— 也是靠那个断言才逼出来的)。

三、顺手做的一次抽取:format_value_with

format_value 原来是 VM 的固有方法,里面 80% 是「容器递归 + 标量显示」, 只有「自定义对象要不要走 文本()」这一步才需要宿主。两个执行器都要格式化值 —— 容器那部分必须共用,否则 [1, {2: 3}] 在两个执行器上可能长得不一样 (正是本项目最恨的漂移)。所以抽成自由函数:

rustfn format_value_with(
    v: &Val, top: bool,
    instance_text: &mut dyn FnMut(&Val) -> Result<Option<String>, JishiError>,
) -> Result<String, JishiError>

VM 传的钩子是「有 文本() 就调它」,Walker 传的钩子是「还没实现」—— 其余逐字共用。

四、Walker 的实现:做得到的共用,做不到的诚实报错

方法树遍历怎么做
format_value走共用的 format_value_with;自定义对象的「文本()」→ 报「还没实现」
write_output写进自己的 output
make_iter走共用的 make_iterator(容器 / 文本 / 文件)
lookup_methodNone(「进入/退出/关闭」要等「类」那一步)
call_value报「还没实现「回调用户函数」」

⚠️ 不静默给错值是硬规矩:这五个里做不到的两个都明确报错,绝不「当没看见」。

五、内建接上之后,撞出两处真问题

① run_walk 把「出错前打印的内容」丢了。 它返回的是 Result&lt;String, String&gt; —— 出错就走 Err,而 w.output 是 已经产出的输出,没人拿它。字节码 VM 在 R6.2 就统一过「出错前写进输出流的 要吐出来」,参考执行器必须一致。

症状:02_计算器(先 打印 再 输入(&quot;请输入第一个数:&quot;))在空 stdin 下 读到 EOF 报「输入结束」—— 字节码 VM 的 stdout 有 欢迎使用基石计算器\n请输入第一个数:,树遍历是空的 → check_walk.py 立刻判成「不一致」(在这之前它一直躲在「还没实现「输入」」那一类里, 因为树遍历根本走不到那一步)。

修法:run_walk 改成返回 Result&lt;String, (String, String)&gt;(Err 里带 「已产出的输出」),main.rs 先 print! 再 eprintln!。

📌 判据扩大覆盖面的那一刻,就是旧实现露馅的那一刻 —— 与 R6.4 「把 Node 拉进判据」同一个机制。

② 字面量 真 / 假 / 空 还没接。 前端把 真 / 假 编成 Num 且 value 是布尔(不是 Bool 节点):

json{"类型": "Num", "line": 1, "col": 4, "value": true}

空 则是 Name({&quot;类型&quot;:&quot;Name&quot;,&quot;id&quot;:&quot;空&quot;})。树遍历的 eval_num 只认数字 → 打印(真) 报「Num 的 value 不是数字」。 归「字面量」那一步(§六第 ① 步),这一轮不做(一里程碑只摊一件大事)。

六、验收

闸门结果
新语料 21_内建调用.jsh(三条路逐字一致)✅ 树遍历 = 字节码 VM = .out
一致性夹具 / 两前端四阶段98/98(新增 1、变更 0)
执行器体检(五执行器,含报错渲染)不一致 0;登记仍 3 条(有意边界)
运行期错误 / 标准库 / 内建探针0/19 · 144 条 0 · 无悬案
树遍历进度仪表语料 71 · 一致 7 → 11 · 还没实现 63 → 60 · 不一致 0
全量回归见 PROGRESS.md

新增 tests/test_m76_walk_builtins.py(22 项)+ tests/cases/21_内建调用.jsh。 其中两条是结构钉住:① walk.rs 里不许有「注册内建」的动作(防第二份实现); ② BuiltinHost 的方法数必须是 5(加方法要有意识地加)。

七、下一步(D68 §四的 ①③④⑤⑥)

① 控制流 + 字面量(含 真/假/空)→ 覆盖大多数 tests/cases/; ③ 函数与闭包(作用域链 + 单元表);④ 类(ClassDef / Attr / Subscript / 复合赋值);⑤ 异常与导入;⑥ 报错渲染对齐(要源文件名与源码 —— 这一步得先决定宿主怎么拿到它们)。


D70. R6 主体续六:树遍历参考执行器 —— ① 控制流 + 字面量(2026-10-04)

D68 §四列的六步里的第 ① 步。表里原来「还没实现」的前几名(List 11 / If 3 / Dict 2 / Compare 2 / For / Loop / 复合赋值…)这一轮全收掉了。

一、补了什么

类别这一轮加进来的
语句If(否则如果 是多一条 branch + 单独的 orelse)、While、For、Loop、Break、Continue、Pass
表达式Compare(含链式)、BoolOp、UnaryOp、List、Dict、Subscript(读)
赋值目标TargetList(令 a, b = …、令 a, *余 = …)与复合赋值(+= 那一族)

进度仪表:一致 11 → 25、还没实现 60 → 47、不一致 0。 剩下的全是「后面几步的」:Import 17 / FuncDef 16 / 调用目标「Attr」10 / ClassDef 2 / 赋值目标「Subscript」2。

二、三个「藏在别处」的字面量(照「节点类型」的直觉写一定翻车)

  1. 真 / 假 是 Num,不是 Bool 节点:

``json {&quot;类型&quot;: &quot;Num&quot;, &quot;value&quot;: true} ` 所以 eval_num 要认三种 value`:整数、浮点、布尔。

  1. 空 是 Name({&quot;类型&quot;:&quot;Name&quot;,&quot;id&quot;:&quot;空&quot;})—— 前端在编译期把它换成

none 常量(jishi/parser.py 的 _parse_atom 那一支),AST 里留的是个名字。 树遍历不认它就会被报成「找不到名字「空」」。

  1. 中断 才是 break(继续 是 continue)—— 不是 跳出。

⚠️ 我第一版的对拍脚本写的 跳出,结果两边一起报「找不到名字「跳出」」, 看起来像对拍通过 —— 差一点就把一个「两边都错」当成绿灯。 记教训:写对拍用例时,先确认关键字是哪个(jishi/tokenizer.py 的 KEYWORDS 是权威)。

三、两处「语义容易想当然」的地方

链式比较要短路。 1 &lt; x &lt; 5 不是「算出一个 bool 再比」,而是 「相邻两项都成立,且有一节假就停」——后面那节的求值根本不发生:

令 d = {}
打印(1 > 2 > d["没有这个键"])     → 假    (第二节的取下标没有执行)

有专门的测试(test_链式比较要短路)。求值顺序错了不会报错,只会多算一次 (有副作用时就露馅)。

与 / 或 的结果一定是真/假,不是短路值本身。 与 Python 不同: 1 或 2 给「真」而不是 1。这不是我定的,是既有语义 (interpreter._eval_boolop -&gt; bool、core/compiler.rs::c_boolop 的注释就写着 「与树遍历一致:结果一定是真/假,不是短路值本身」)。照直觉(Python 语义)写会错。

四、顺手修掉一个不报错的错答案(既有 bug)

unary_neg 原来是:

rustfn unary_neg(v: Val) -> Val { … _ => Val::None_ }      // ← 返回 Val,不能报错

于是 -「x」 在三个 Python 执行器上都报「类型错误(「x」不能取负号)」, 而 Rust 宿主打印出「空」 —— 一个不报错的错答案(本项目最恨的那一类)。 改成返回 Result 并对非数字报同一句话;-真 也顺手对齐成 -1 (Python 里 bool 是 int 的子类,-True == -1)。

📌 共用也是共债:树遍历要调这个函数,才发现「共用的那一份」自己就是分叉源。 好处是改一处两边一起好;坏处是共用函数有 bug 时两个宿主一起错 —— 所以三层互拍(树遍历 / 字节码 / Python)的冗余度必须保住。

五、量出来的既有分叉(都没修,登记待拍板)

做形状对拍时顺手撞出 5 处先于本轮就存在的分叉,一律不动 (一里程碑只摊一件大事;而且「改成哪个」是语义问题):

形状3 个 Python 执行器Rust 宿主Node 宿主
-「文本」报「不能取负号」✅ 本轮已修成报错给 NaN(不报错的错答案)
-列表报「不能取负号」✅ 报错给 -1(不报错的错答案)
-空报错,但文案是 「None」(内部表示泄漏)报错,文案 「空」给 0
循环 &quot;3&quot;3 次报错3 次
循环 3.73 次(int() 截断)3 次报错

⚠️ 定下来之前这些形状不要进语料(否则会把「不一致」冻成基准)—— 语料 22_控制流.jsh 的文件头就写明了它避开了哪些形状。 「None」 那条尤其值得修:它是内部表示泄漏,与 R6.3 修的 list index out of range 是同一类。

六、验收

闸门结果
形状对拍(自建,36 形状 × 树遍历/字节码/Python 三路)0 不一致
新语料 22_控制流.jsh(三条路逐字一致)✅
一致性夹具 / 两前端四阶段99/99(新增 1、变更 0)
执行器体检(五执行器,含报错渲染)不一致 0;登记仍 3 条(有意边界)
运行期错误 / 标准库 / 内建探针0/19 · 144 条 0 · 无悬案
树遍历进度仪表语料 72 · 一致 25 · 还没实现 47 · 不一致 0
全量回归见 PROGRESS.md

新增 tests/test_m77_walk_control.py(73 项)+ tests/cases/22_控制流.jsh。 其中三条是结构/契约钉住:语义函数不许在 walk.rs 里重写、 真/假/空 的节点类型断言、链式比较短路。

七、下一步(D68 §四的 ③④⑤⑥)

③ 函数与闭包(FuncDef / Return / Lambda + 作用域链 + 单元表)—— 这一步一落地,调用目标「Attr」 那 10 个也会跟着解开(大部分是方法调用); ④ 类;⑤ 异常与导入;⑥ 报错渲染对齐。


D71. R6 主体续七:树遍历参考执行器 —— ③ 函数与闭包(2026-10-04)

D68 §四第 ③ 步。表里 FuncDef(16)+ 调用目标「Attr」(10)里的相当一部分 (方法调用要等 ④,但函数作为值、f(1)(2)、令 g = f 这类这一轮全通了)。

一、两套做法、同一套语义

字节码 VM树遍历参考执行器
名字 → 存储编译器算好槽位;跨函数用单元表(cellvars/freevars)词法链(Scope:vars + parent)
读LOAD_FAST / LOAD_DEREF沿链往上找
写STORE_FAST / STORE_DEREF只写当前作用域

树遍历手里只有 AST、没有编译器算好的槽位,所以用链是自然选择 —— 而这正是 Python 侧 Environment(jishi/interpreter.py)那套语义。 能共用的是「判断」,不是「存储」:形参绑定(含全部报错文案)抽成了 bind_params,两个执行器都调它;放值进哪儿各管各的。

二、Val 多了一个变体:Val::WalkFn

⚠️ 不能复用 Val::Func —— 那里面装的是字节码的 code_idx,而树遍历 手里是 AST、「不经过字节码」正是它存在的理由。所以给它一个自己的变体, 但显示 / 类型 / 同一性 / 可调用判定必须与 Val::Func 完全一致 (&lt;函数 名&gt; / 函数 / 创建序号 / 可调用),否则同一段源码在两个执行器上 会打出不同的字。

顺带:内建现在能回调用户函数了(映射 / 过滤 / 排序按 / 归约 —— 它们走 BuiltinHost::call_value),这是 ③ 落地后自然长出来的能力。

三、🔴 最容易写错的一条:「赋值即局部」必须先判

一个名字只要在本函数里赋过值,它在这个函数里就只是局部变量 —— 读它不许穿透到外层找同名变量(Python 语义)。

python令 甲 = 1
函数 f():
    打印(甲)      # ← 这里必须报「赋值之前被用到了」,不能拿外层的 1
    甲 = 2

判断顺序放反的症状是「静默拿到外层的值」:不报错、答案是错的 —— 本项目最恨的那一类。我第一版就是先「沿链找」再判 locals,対拍里一条就露了: 树遍历打 1\n1\n,字节码打「名字「甲」在赋值之前被用到了」。 现在有专门的测试钉住它(test_赋值即局部_先读后赋要报错)。

⚠️ 与之配套的是扫「本函数赋过值的名字」(assigned_names): 不能递归进嵌套的 FuncDef / Lambda 体(那是它们自己的作用域)。 这一步一旦漏分支就会静默错 —— 与 R5 移植时编译器漏 Try 分支是同一类 (docs/前端契约.md §12.4),所以这里顺手把 Try / 捕获 也一起收了。

四、另外两处必须对的地方

还回 空)。三个循环语句都要 f @ Flow::Return(_) =&gt; return Ok(f)。

函数 f(x=基准) 里的 基准 只在定义时读一次。

五、🔴 语料第一次「问类型」,C VM 的内裤就露出来了

新语料里有一句 打印(类型(加倍))。五执行器体检当场报红:

CVM.stdout: '… 10 CvmFunction 0\n'      ← 另外四个都是「函数」

根因在 jishi/runtime.py 的 type_name:它的兜底是一份手写清单 (按类名对齐三个执行器的函数包装类):

pythonif name == "UserFunction": return "函数"
if name == "VmFunction":   return "函数"
return name                     # ← C VM 的 `CvmFunction` 掉到这里,直接漏出类名

漏了 CvmFunction。这个 bug 一直躺着,原因很直接:此前没有任何语料 拿 类型() 问过函数 —— 又是「判据没覆盖到 = 没实现」那条(D65 集合、 D66 大整数比较都是同一个病)。已修(三合一成一行 + 注释说明这是「手写清单」 的典型风险),并补了一条五执行器都跑的测试。

六、验收

闸门结果
形状对拍(自建,57 形状 × 三路)0 不一致
新语料 23_函数与闭包.jsh(三条路逐字一致)✅
一致性夹具 / 两前端四阶段100/100(新增 1、变更 0)
执行器体检(五执行器,含报错渲染)不一致 0(登记仍 3 条有意边界)
运行期错误 / 标准库 / 内建探针0/19 · 144 条 0 · 无悬案
树遍历进度仪表语料 73 · 一致 25 → 39 · 还没实现 47 → 34 · 不一致 0
全量回归见 PROGRESS.md

新增 tests/test_m78_walk_functions.py(46 项)+ tests/cases/23_函数与闭包.jsh。 剩下的是:Import 17 / Attr 10 / Try 3 / ClassDef 2 / 赋值目标「Subscript」2 —— 正好对应 ④(类 + 属性 + 下标作目标)与 ⑤(异常与导入)。

七、下一步

④ 类(ClassDef / Attr / Subscript 作赋值目标 / 初始化 / 实例方法); ⑤ 异常与导入(Try / Raise / ExceptHandler / Import,含 从 python 导入 那个有意边界);⑥ 报错渲染对齐(要源文件名与源码 —— 这一步得先决定 宿主怎么拿到它们:AST JSON 里没有,可能要给 --walk 加一个「源文件路径」参数)。


D72. R6 主体续八:树遍历参考执行器 —— ④ 类与继承(2026-10-04)

D68 §四第 ④ 步。表里 Attr(10)+ ClassDef(2)+ 赋值目标「Subscript」(2) 全收掉,顺带把内建类型的方法(列.追加(…) 那些)也接上了 —— 进度仪表 一致 39 → 53、还没实现 34 → 21、不一致 0。

一、核心决定:不另造一套「类」

树遍历产出的就是宿主那个 Class(methods 里放 Val::WalkFn)。 于是下面这些一行都不用改就能用:

直接用上的为什么
是实例(对, 类)只看 Class.id / base_ids
超()只看 base 链(Val::Super)
get_attr / set_attr查找顺序(字段 → 类变量 → 方法)只有一份
display / type_label「甲 实例」「类 甲」只有一份
identity_key实例按 Rc::as_ptr 同一性

代价是给 Class / Instance / get_attr / set_attr / next_obj_id 加 pub(crate),并在 get_attr 的实例分支里多两个 Val::WalkFn 分支 (绑定方法:共享函数体、只换 self_val)。 📌 加 10 行 pub(crate),省下几百行重复语义 —— 而且省下的那些是 「最容易悄悄漂」的那种重复。

二、self 怎么表达:WalkFn 多一个 self_val

字节码侧用 BoundMethod::User { func: 字节码函数, instance };树遍历没有字节码, 所以用「同一个 WalkFn + self_val = Some(实例)」。调用时 self 落到第一个 形参(自身)上 —— 与 call_frame(self_arg=…) 同一个做法。

⚠️ 三处必须跟着走,否则同一段源码在两个执行器上打出的字不一样: display(&lt;方法&gt; vs &lt;函数 名&gt;)、type_label(方法 vs 函数)、 identity_key(绑定方法仍明确报错 —— Python 里 a.方法 是 a.方法 为假, 用「实例 + 方法名」判会得到真,宁可明确不支持)。

三、顺带一大块:内建类型的方法表也搬成了自由函数

列.追加(…) / 字典.… / 文本方法… 走的是 VM 里那张 248 行、73 个分支的方法表。 以前树遍历连「Attr 作调用目标」都报「还没实现」,所以从没碰到它 —— 这一轮一碰,10 条语料立刻卡住。

先量再搬:那张表用到的宿主能力只有 self.call_value(4 处), 于是我把它整段搬出 impl VM、改成

rustpub(crate) fn call_builtin_method(host: &mut dyn BuiltinHost, name: String,
                                  receiver: Val, args: &[Val]) -> Result<Val, JishiError>

self. → host.,一行逻辑没动。两边共用同一份 → 方法语义不会漂。 📌 「这段代码到底依赖宿主什么」是可以量的(grep self\. | uniq -c)—— 量出来只有 4 处,就知道搬得动;量出来几十处,就该换个方案。

四、另外两处「差一点就静默错」

共用的 make_iterator,于是「定义了 迭代() 的对象」遍历不了 (报「不能遍历」)。改成 self.make_iter(...)(宿主接口)—— 它会先调 迭代(), 与字节码 VM 的 GET_ITER 同一条路。(长度(自定义对象) 同样受益。)

只是被调的值是 Val::Class。要在 eval_call 里加一条分支 (先建空对象,再把 初始化 当未绑定函数调、实例塞第一个实参)—— 顺序与 make_instance 一致,连「类「甲」没有构造方法」那句都照抄。

五、验收

闸门结果
形状对拍(自建,73 形状 × 三路)0 不一致
新语料 24_类.jsh(三条路逐字一致)✅
一致性夹具 / 两前端四阶段101/101(新增 1、变更 0)
执行器体检(五执行器,含报错渲染)不一致 0(登记仍 3 条有意边界)
运行期错误 / 标准库 / 内建探针0/19 · 144 条 0 · 无悬案
树遍历进度仪表语料 74 · 一致 39 → 53 · 还没实现 34 → 21 · 不一致 0
全量回归见 PROGRESS.md

新增 tests/test_m79_walk_classes.py(47 项)+ tests/cases/24_类.jsh。 两条结构钉住:不许在 walk.rs 里另实现 get_attr/set_attr/ call_builtin_method(必须调共用那份)、绑定方法的显示与类型与字节码一致。

⏳ 顺手记一笔(没改):新语料里我本想写一个叫 文本 的方法(用来测 「打印实例走 文本()」),静态检查的零误报闸门报了 name.shadow:函数名「文本」遮蔽了内建函数「文本」。 方法名遮蔽内建算不算误报可以再议(Python 里类命名空间并不遮蔽全局内建), 但这一轮先照闸门走(把方法改名成「介绍」),文本() 钩子改在测试文件里覆盖。

六、下一步

⑤ 异常与导入(Try / Raise / ExceptHandler / Import)+ ⑥ 报错渲染对齐 (按既定倾向:给 --walk 加一个「源文件路径」参数,让宿主能打出源码行 / ^ / 调用链 —— AST JSON 里没有源码,所以只能从外面给)。


D73. R6 主体续九:树遍历参考执行器 —— ⑤ 异常与导入(2026-10-04)

D68 §四第 ⑤ 步。表里 Import(17)+ Try(4)全收掉;顺带把 Slice(切片)也补了 —— 它一直挂在 ① 的尾巴上(examples/projects/ 里两个示例用它)。

进度仪表:语料 75 · 一致 53 → 75 · 还没实现 2 → 0 · 不一致 0。 📌 这是这个执行器的第一个里程碑意义点:语料里出现过的节点类型,现在全部实现了 (check_walk.py 的「还没实现」那一列清零)。

一、三条边界都照「既有规范」,各有注释与测试

形状口径为什么
控制信号(返回/中断/继续)不被 捕获 接住,但「最终」一定要跑,跑完原样往外传它们不是错误
「最终」自己跳出去覆盖外层信号(最终: 返回 2 压过 尝试: 返回 1)Python 的 finally 就是这么定的
捕获体在哪跑当前作用域(不新开子作用域)R5.1 定的:早先树遍历给「捕获 为 名字」单独建子作用域,于是捕获体里的赋值出了捕获体就没了
捕获体自己出错时「最终」不跑照 interpreter._do_try 那个 except (RunBreak, RunContinue, _ReturnSignal, DebugQuit)(四个宿主也一致)—— 不照 Python 语言本身

「最终」那个「可以覆盖外层信号」的性质,就是 run_finally 返回 Option&lt;Flow&gt; 的原因(返回 Some(f) = 这个块自己跳出去了)。

二、共用了一处:报错头行(error_head)

树遍历早先自己拼了一遍头行,于是同一段源码两个宿主打出不同的字:

抛出 值错误("x")   树遍历:错误 E2005:值错误(x)      字节码:错误 E2005:数值不对(x)

差的正是「显示内部类型名还是 error_title(值错误 → 「数值不对」)」,另外 消息为空时不该加那对括号。抽成 error_head(render_error 与 run_walk 共用), 三条容易写歪的规则就只剩一份了。

📌 又一次「共用也是共债,但先把债收成一份」:这类「同一段文本两处拼」的漂移, 只要有两份实现就一定会出现,而且只有逐字比才看得出来。

三、🔴 顺手修掉一个真 bug:一个不存在的异常类型名

宿主把 数值错误 当异常类型名用了三处(切片的步长不能为 0、切片赋值长度不匹配)。 而语言里的异常类型只有 异常 / 运行期错误 / 类型错误 / 值错误 / 索引错误 / 键错误 / 除零错误 / 文件错误 / 断言错误 —— 没有 数值错误。后果不是「文案不好看」:

尝试:令 甲 = [1, 2] / 打印(甲[::0]) / 捕获 值错误 为 e:打印(e.类型)
  Python / PythonVM / C VM / Node:接住 值错误
  Rust 宿主:                      【错】错误(数值错误):切片的步长不能为 0   ← 接不住!

错误码 也给不出(error_code(&quot;数值错误&quot;) 落到 None)。Python 侧用的是 RunValueError(值错误,码 E2005),改三个字就对齐了。语料 25_异常与导入.jsh 第 12 段把它钉住,另有 test_不许出现不存在的异常类型名。

📌 「类型名」是接口不是文案:它同时被 捕获 X 的匹配、e.类型、错误码表 三处消费 —— 写错一个不存在的名字,错误就捕获不到,而且不会报「这个名字不存在」。

四、顺手补齐:切片(Slice)

SliceVal / slice_indices / get_item / set_item 早就有(M33),编译器也早已 把 a[1:3] 编成 BUILD_SLICE + GET_ITEM —— 树遍历缺的只是「把 AST 的 Slice 节点 变成 Val::Slice」这一步(十几个分量、flags 位标志与 BUILD_SLICE 同口径)。 顺带发现 切片赋值本来就被支持(甲[0:1] = [9]),树遍历原来的 unimpl(&quot;切片赋值&quot;) 是错的。

五、🔴 发现的一个真 bug(登记,本轮不修):捕获体跳出时「最终」不跑

参考执行器一上手就抓到了一个编译器侧的 bug —— 这正是它存在的理由:

函数 f():尝试:抛出 值错误("x") 捕获 值错误:返回 5 最终:打印("清")
  树遍历解释器(规范):清 / 5        四个字节码宿主:5(**最终块没跑**)

根因:c_try 只把尝试 体套进了 finally,捕获体没有。于是「捕获体里 返回 / 中断 / 继续」时,四个字节码宿主都不跑「最终」块(错误那一支反倒 与规范一致 —— 规范里「捕获体出错时最终不跑」本来就是这个口径)。

修它要动编译器的发射 + 两个 VM 的异常分发(还要重冻夹具),是另一件大事:

① tests/test_m80_walk_exceptions.py::test_已知登记_捕获体跳出时最终块的差异 —— 一旦两边一致它就会失败并提示删掉(过期报红); ② tools/check_walk.py 的 docstring 有一节专门写它; 开发时的形状对拍小工具 build/_walk_probe.py(未入库)也有一份 KNOWN_DIVERGENT;

—— 与 D63 §三 / D65 §三 同一规矩。

六、量出来的其它既有分叉(都没修,登记)

形状三 Python 执行器Rust 宿主Node 宿主
抛出 一段文本错误 E2009:异常(…)错误(异常):…(没有码)同 Rust
捕获 后面的名字被赋成了非异常类型错误 E2002:类型错误(「捕获」后面只能写异常类型…)悄悄不匹配(exception_matches 返回假)错误 E2002:类型错误(「1」不能用于捕获)
d[精确(&quot;1&quot;)] = 2报 unhashable(英文 + 内部类名)允许允许
模块属性不存在(文本.长度)错误 E0302:…(模块「文本」里没有「长度」)错误(异常):模块里没有「长度」(没有码)同 Python

前两条与「宿主错误码表」(M56 第 5 条)同源;后两条是宿主标准库 / 哈希键的旧账。 都没有进语料,也没进 HOST_KNOWN_GAPS(那是按语料文件豁免的机制, 一条形状没法登记在那儿)。📌 要拍板的是「宿主补全错误码表」这件事, 一次能把前两条一起收掉。

七、验收

闸门结果
形状对拍(自建,109 形状 × 三路)一致 107 · 已知登记 2 · 不一致 0
新语料 25_异常与导入.jsh(六路逐字一致:tree / PyVM / C VM / Rust / Node / 树遍历执行器)✅
一致性夹具 / 两前端四阶段102/102(新增 1、变更 1)
执行器体检(五执行器,含报错渲染)不一致 0;登记仍 3 条(有意边界)
运行期错误 / 标准库 / 内建探针0/19 · 144 条 0 · 无悬案
树遍历进度仪表语料 75 · 一致 75 · 还没实现 0 · 不一致 0
全量回归见 PROGRESS.md

新增 tests/test_m80_walk_exceptions.py(46 项)+ tests/cases/25_异常与导入.jsh。 四条结构钉住:报错头行走 error_head、不许出现不存在的异常类型名、 两个宿主的异常类型表与 lang-spec 同宽、已知登记会过期报红。

八、下一步

⑥ 报错渲染对齐(按既定倾向:给 --walk 加一个「源文件路径」参数,让宿主能打出 源码行 / ^ / 调用链 —— AST JSON 里没有源码,只能从外面给)。做完这一条, R6 主体(docs/架构迁移路径.md 里的三件事 + 树遍历执行器的六步)就全完了。


D74. R6 主体续十:树遍历参考执行器 —— ⑥ 报错渲染对齐(2026-10-04)

D68 §四六步里的最后一步。至此 R6 主体的「树遍历参考执行器」六步全部做完, docs/架构迁移路径.md 里的 R6 主体三件事(字典 / 集合 / 整数循环与算术 / 函数调用 / 树遍历参考执行器)也全部交付。

一、做了什么:让树遍历的报错渲染与字节码 VM 逐字一致

以前树遍历只渲染「头行 + 提示语」,差「源码行 / ^ / 调用链」。这一步补齐,而且 不是另写一份,是抽成一份:

块以前现在
头行D73 已抽 error_head共用
源码行 / ^没有render_error_with(源码行从回调读)
调用链没有call_stack 拍出 + 共用渲染
提示语有共用

关键决定:源码行从外面给。树遍历的输入是 AST JSON(里面没有源码),所以 --walk 多了个可选的「源文件路径」参数:jishi-rs --walk AST.json 源文件.jsh。 渲染函数抽成 render_error_with(e, filename, get_line) —— 字节码 VM 传 vm.module.source_line、树遍历传「按路径读磁盘」,格式只有一份(正是 D73 抽 error_head 那条路的延续)。

二、两个「差一点就静默错」的实现细节

① 错误对象补位置。 共用的 binop / get_item / compare 返回的错误不带 行列(它们不知道自己在哪)。字节码 VM 靠 fill_error_loc(错误冒泡时用「出错 指令」的 line/col 填);树遍历在 eval / exec_stmt 的分派层用「出错节点」的 位置填 —— 两者天然一致(验证过:1 / 0 的 BinOp 节点 col 与 BINARY_OP 指令 col 都是 6)。

🔴 踩的坑:match arm 里的 ? 会提前 return、绕过下面的补位置。于是 exec_import 等的错误(「不支持从 python 导入」)没了位置、报错渲染缺源码行框。 改成 .map(|_| Flow::Normal),让错误也流经 fill_loc。

📌 这是「? 的提前返回」这个 Rust 语义在「包装一层错误处理」时最容易踩的坑: 任何「在函数出口统一处理错误」的包装,都会被 arm 内部的 ? 短路掉。

② 方法调用的报错位置。 列.追加() 传错参数,字节码 VM 报在「取属性」那处 (列 的 col),树遍历一开始报在「追加」的 col —— 差好几列。对齐:BoundMethod 自带 line/col(get_attr 时记的「X.方法」那一处),方法内部报错时用它填, 与 VM 的 call_bound(bm_pos)同口径。

三、调用链(trace)的对齐与一个新发现的既有分叉

Walker 维护 call_stack,出错时拍成 trace。口径照 Python 侧 _eval_call: 只有「直接函数调用」进栈(UserFunction 直接调用才 append;实例.方法() / 类实例化 / 内建回调走 UserFunction.__call__,注释里明确「不动 _call_stack」)。

量的时候撞出一个既有分叉(都登记,本轮不修):

形状Python 规范字节码 VM
实例.方法() 里报错调用链空(方法不进 _call_stack)有方法帧(算 ← 第 5 行)
新建 甲() 里报错空有构造帧(初始化 ← 未知位置)
方法套函数 内() 报错只有 内 帧算 + 内(多算方法帧)

根因:字节码 VM 的 BoundMethod::User / 类实例化都走 push_func_frame,把方法/ 构造帧也压进 frames。参考执行器站在规范这边(方法调用不进栈),分叉登记在 tests/test_m81_walk_render.py 的两条测试里(会过期报红)。

顺带还量到:递归没有深度限制 —— Python 报「递归层数太深了」+ 提示,两个 Rust 宿主一个爆内存(memory allocation of 21GB failed)、一个栈溢出。同一个病(Rust 无 递归上限)的两种死法,也登记(这是 VM 的活,不是本执行器的)。

四、check_walk.py 升级:从「比 stdout」到「比完整渲染」

以前它只比 stdout、报错只看到「还没实现」。现在:

filename 一致);

于是它从一个「进度仪表」变成「两个实现完整行为互拍」的判据 —— 报错语料 (tests/error_cases/ 里能编译的那些)也进它的覆盖面了。

五、验收

闸门结果
check_walk(完整渲染逐字一致)语料 75 · 一致 75 · 还没实现 0 · 不一致 0
test_m81(报错渲染逐字一致,14 形状 + 结构钉住)18 项全过
一致性夹具 / 两前端四阶段102/102(本轮不动语料)
执行器体检(五执行器,含报错渲染)不一致 0;登记仍 3 条(有意边界)
运行期错误 / 标准库 / 内建探针0/19 · 144 条 0 · 无悬案
全量回归见 PROGRESS.md

新增 tests/test_m81_walk_render.py(18 项)。

六、R6 主体收尾

树遍历参考执行器的六步(D68 §四 ①–⑥)全部完成。剩下两块跨里程碑的账(都登记 待拍板,别进语料):

后名非异常类型、模块属性不存在的报错 —— 一次能一起收(D73 §六前两条)。

(§三)—— 都根因在编译器 / VM 的异常分发与帧管理,要动字节码 + 重冻夹具。


D75. R6 主体收尾:跨里程碑的两块账一次收掉(2026-10-04)

D73 §五/§六、D74 §三登记的账,用户拍板「先处理这个,再实测定 C VM 去向」。三条全是 「悄悄给错答案 / 悄悄漏行为」那类,对拍根本抓不住(要么两边一起错、要么根本不报错)。

一、① 宿主补全错误码表(M56 第 5 条)

形状修前(Rust / Node)修后
抛出 一段文本错误(异常):…(没码)错误 E2009:异常(一段文本)
捕获 后名非异常类型Rust 悄悄不匹配、Node 消息不同错误 E2002:类型错误(「捕获」后面只能写异常类型…)
模块属性不存在错误(异常):模块里没有「长度」(没码)错误 E0302:找不到这个函数(模块「文本」里没有「长度」)+ 提示

关键顺序:先把语义错误改成具体类型名,再给「异常」配 E2009。Rust 的 error_code 以前刻意不给「异常」配码(它是个笼统类型,找不到名字/属性…都归它)—— 所以先 get_attr 的模块分支改 T_FUNC(E0302)+ 模块名 + hint,make_exception 留下的「异常」就只剩「抛出 非异常值」这一种,才能放心配 E2009。Node 侧同理,且 删掉 exceptionMatches 里「cond instanceof JishiError 按类型名匹配」的分支 (Python 对「捕获一个被捕获到的异常对象」是报错,type_name 是「异常」)。

exception_matches 从 bool 改成 Result&lt;bool&gt;:条件不是异常类型就报 E2002,位置 落在捕获 那一行(字节码 VM 靠 MATCH_EXC 指令的 line/col 填、树遍历用 h 节点填)。

二、②a 编译器修复:捕获体跳出时最终块不跑

根因:c_try 只把 尝试 体套进 finally,异常分发到 catch 时外层 SETUP_TRY 已经 被 dispatch_exception 弹掉,于是捕获体里 返回/中断/继续 时栈上没有任何 handler —— step_return/dispatch_signal 找不到 finally。

修法:给每个捕获体再包一层 SETUP_TRY catch_body_err, fin(只有带 最终 块才 需要)。四种走法各归其位:

「捕获体出错最终不跑」口径,照 interpreter._do_try,不照 Python 语言本身)。

🔴 踩的坑:catch_body_err 那个 CHECK_SIGNAL 的目标忘了回填(a=0), 信号被跳到指令 0 重跑整个函数 → 无限循环。这个 bug 对拍抓不住——它既不报错、 也不给错答案,只是挂死;是加了 eprintln 看 dispatch_signal 的循环才定位到 CHECK_SIGNAL a=0。📌 「改编译器发射 + 跳转回填」这类活,跳转目标必须逐条数过。

改动只在编译器(字节码布局),四个字节码宿主(PyVM/C VM/Node/Rust)零改动—— 它们本来就会处理 SETUP_TRY,只是编译器从没给捕获体发过这层 handler。重冻夹具后 只有 2 个语料 bytecode 变了(25_异常与导入 / examples/12_异常处理)。

三、②b 方法调用 trace:Rust VM + Node 不压方法/构造帧

Python 侧 _eval_call 只有 UserFunction 直接调用才 append 到 _call_stack; 方法 / 类实例化 / 内建回调都走 UserFunction.__call__(不动栈)。字节码 VM 的 BoundMethod::User / make_instance 走 push_func_frame,把方法/构造帧也压进了 frames → 调用链里多了不该有的帧。

修法:Frame 加 in_trace 标志,只有直接函数调用(CALL 到裸 Val::Func) 设 true,其余(方法 / 初始化 / 内建回调)设 false,snapshot_trace 跳过。 只有 Rust VM 与 Node 有这病——Python VM 与 C VM 的方法调用/实例化本来就 回流到 Python runtime(R.call_value → UserFunction.__call__),不压 VM 帧。

四、验收

闸门结果
一致性夹具(重冻)102/102(只 2 个语料 bytecode 变)
check_engines(五执行器,含报错渲染)不一致 0;登记仍 3 条(有意边界)
check_walk(完整渲染逐字一致)语料 75 · 一致 75 · 还没实现 0 · 不一致 0
test_m80(新增 7 形状 + 删 1 条过期登记)全过
test_m81(删 2 条过期登记 + 3 条正向)全过

删掉 3 条「过期登记」测试(捕获体跳出 + 方法调用链 ×2),换成正向断言钉住新行为。 「登记必须能过期」又一次真的落地。

📌 重做说明:本节内容在 2026-10-04 从 bd87663 干净重做后逐条复验 (先 ① 宿主错误码表 → 再 ②a 编译器修复 + 重冻夹具 → 再 ②b 调用链), 每一步都过了当步的闸门;全量回归见 PROGRESS.md。


D76. R6 主体验收:实测定 C VM 去向(2026-10-04)

R6 主体(树遍历参考执行器六步 + 字典/集合/整数循环/函数调用四项性能)全部完成后, 按既定计划「实测定 C VM 去向」。先量再说,量完再定,不预设(本项目的度量纪律)。

一、性能:五个场景组,tools/benchmark.py(每场景 1 秒、取最优轮)

相对树遍历的提速(>1 = 更快;C VM 进程内,Rust VM 独立进程已扣启动):

场景Python VMC VMRust VM
整数循环累加0.66x9.0x1.7x
递归斐波那契1.1x18.6x6.3x
当循环倒计数1.1x39.7x6.1x
列表构建与遍历0.79x11.7x2.1x
浮点列表遍历0.62x5.2x1.1x
字典写入密集0.64x5.1x0.84x
布尔列表遍历0.64x5.3x0.89x
字符串拼接0.66x2.4x0.29x
文本比较0.85x9.1x1.3x
函数调用密集0.88x12.5x3.5x
算术混合运算0.89x17.8x3.7x
文本下标 / 切片 / 遍历文本0.8x / 0.8x / 0.5x0.61x / 0.39x / 0.56x1.4x / 1.2x / 1.3x
字典查找密集 / 内建调用密集0.76x0.93x / 0.94x1.3x / 0.90x

⚠️ Rust VM 那一列在短场景不可信(脚本自己打了 ⚠):它每轮含 86–103ms 进程启动, 而多数场景净耗时只有几十毫秒 —— 扣完启动剩下的数太小,误差被放大。 「Rust 列净耗时太小,别用它下判断」。

二、决定性对比:长任务(启动成本摊薄后,谁快)

300 万次整数循环(令 s = 0 / 遍历 i 在 范围(3000000):s = s + i),三次取最优:

执行器总耗时净耗时备注
树遍历5430.8 ms5430.4 ms语义基准
Python VM8380.4 ms8380.0 ms比树遍历慢
C VM489.7 ms489.0 ms进程内,无启动成本
Rust VM752.2 ms649.0 ms启动 103.3 ms

📌 结论一:Rust VM 没有反超 C VM —— 长任务上 C VM 净耗时是 Rust VM 的 0.75x (即 C VM 快约 1.33 倍);短任务上 Rust VM 还要多付 ~100ms 启动。 📌 结论二:C VM 是唯一「进程内且快」的执行器 —— 对 20–50ms 的短脚本, 树遍历是当前唯一零启动成本的备选,而它慢 5–40 倍。

三、维护代价(另一面)

执行器规模近 30 天改动(总 187 次提交)
C VM(cvm/src/jsvm.c)2408 行 C12 次(6.4%)
Python VM(jishi/vm.py)932 行10 次
树遍历(jishi/interpreter.py)853 行17 次
Node(node/vm.js + runtime.js)6285 行16 次
Rust VM(rust/src/lib.rs + walk.rs)6802 行38 次

📌 加内建/方法不需要动 C VM(内建走宿主回调),所以它的同步面比看上去小; 它真正的代价是每平台要编一个 DLL(发行包/CI 的那条链)。

四、结论与去向(已拍板:保留 C VM,2026-10-04)

决定:保留 C VM,不退不冻(用户拍板)。理由按重要性排:

  1. 性能上不可替代:进程内、长任务最快(11.1x 树遍历)、短任务零启动成本 ——

Rust VM 是另一条路(跨语言宿主),不是它的替代品;

  1. 维护面小:6.4% 的改动量、加内建不用动它;
  2. 已知边界已登记清楚(文本下标/切片/遍历文本因宿主回调反而慢于树遍历),

属透明记录而非缺陷。

不建议冻结:C VM 一旦不跟语义,就不能再进「五执行器对拍」判据 —— 那会让「三执行器语义一致」这条硬约束出现一个说不清的豁口,代价比省下的同步工作量更大。

Rust 线不因此改道:Rust 的价值是「不依赖 Python 的宿主 + 端到端 5.37x 前端」, 与 C VM 的定位(Python 里的快执行器)互补。

📌 这条决定到此为止:C VM 继续按「五执行器语义一致」的标准维护(它是唯一 「进程内且快」的执行器,冻结就会让对拍判据出现豁口)。若将来某次实测发现它不再是 进程内最快的(或 Rust 宿主能在进程内运行),再回来重开这个问题——用数据重开, 不靠感觉。


D77. 清三笔已知未决:列表活迭代 / 遍历中改容器家族 / Val 实测(2026-10-05)

进 R7 之前把三笔「已知未决」先清掉(用户 2026-10-05 拍板顺序)。三笔都按 先量再说来:能修的先修、涉及取舍的把数据摆出来等拍板。

一、列表的「活迭代」:Rust 的两条实现都改了(已交付)

问题(登记了很久的那条):遍历 x 在 列 期间改 列,规范(Python 的列表 迭代器是按下标活迭代)与 Rust 不一致 —— 而且是静默给不同答案:

形状树遍历(规范)Python VMC VMNodeRust VM(改前)
遍历中 列.追加(99)打出 1 2 99同左同左同左1 2(快照)
遍历中 列.移除(2)打出 1 3同左1 2 3同左1 2 3(快照)

根因:R6.1 为了修 遍历 的 O(n²)(旧实现是 Vec::remove(0)),把迭代器改成 「反转快照 + 尾部 pop()」。顺序对了、每步 O(1) 了,但变成快照。

修法(Rust):新增 Val::ListIter(Rc&lt;RefCell&lt;ListIter&gt;&gt;) = 容器 + 下标, iter_next 每步 if idx &lt; len { 取 idx; idx += 1 } —— 既活的(与 Python 的 list_iterator 同一条规矩),又每步 O(1)(不会退回 remove(0))。 VM 与树遍历参考执行器共用 make_iterator / iter_next(D69 的「内建只有一份 实现」同源),所以一处改动、两条实现同时对齐。

⚠️ 集合 / 字典 / 文本仍是快照 —— 它们的规范行为不是活迭代(见下一节)。

二、遍历中改容器:一族四形状,比登记的那一条严重

顺手把这一族全量对拍了(列表增/删、集合增、字典增),查出四处分叉 (上表 + 下表)。其中两处是「规范自己就有毛病」:

形状树遍历 / Python VM(规范)C VMRust VMNode
集合遍历中 集.添加(99)裸 RuntimeError 逃出来E2000 运行期错误(内部错误(宿主回调):…)1 2(静默快照)1 2 99(静默活迭代)
字典遍历中 字[&quot;丙&quot;]=3裸 RuntimeError 逃出来甲 乙(静默快照)甲 乙(静默快照)甲 乙(静默快照)

三个独立的问题(第 1、2 条已交付,见本节末的「已修」;第 3 条待实施):

  1. 🔴 Python 侧把 RuntimeError 直接漏给用户(树遍历 / Python VM):英文原文

dictionary changed size during iteration、没有错误码、没有行列 —— 违反「报错必须可操作」(§0.5 核心二)。规范这边自己就得先修:包装成基石的 运行期错误(带码 + 行列 + 修法)。

  1. 🔴 C VM 把它包成「内部错误(宿主回调)…」:把内部实现字样露给用户了。
  2. 🔴 C VM 的批量预取会掩盖一切「遍历中改容器」:FOR_ITER 走

iter_next_batch(一次预取一批、省 FFI 往返),批次一取就比宿主迭代器走得远 —— 所以「删掉的元素还会被吐出来」,集合/字典的「改大小报错」也被吞掉。

代价已实测(把 JS_ITER_BUF 从 256 临时改成 1 = 等价「放弃批量」, 同机同时段交错 A/B、每负载 3 轮取最优):

负载批量 256(现状)批量 1(放弃批量)代价
遍历 20 万列表(含建表)1676.1 ms3031.2 ms1.81x 慢
遍历 200 万列表16722.9 ms30376.1 ms1.82x 慢

📌 也就是说「直接放弃批量」要把 C VM 的列表遍历砍掉近一半速度(每元素一次 宿主回路的真实成本 ≈ 6.8 µs,高于早先估的 2.4 µs —— 迭代路径还要 retain/release)。 所以不选「放弃批量」。

两条候选路(B2 选定并已实施,见下):

调用、不传参、不下标赋值 —— 保守判定),才允许批量。需要:GET_ITER 带一个 参数位(指令布局不变、但该位从「恒 0」变成有意义 → 夹具要大范围重冻)、 两个前端各加一段 AST 判定、C VM 读这一位。没选:漏判就退回不批量(性能), 而且它挡不住「循环体调用的函数改了容器」那种间接改动。

一段;容器被改时丢缓冲、按确认下标重新取 —— 拿到的就是变更后的活值。 不需要动字节码/夹具。

📌 另有一条现成的口子:iter_new 本来就返回 batchable 标志(宿主 → C VM), 所以「谁来判定、判定到什么粒度」是设计选择,不是要新造机制。

⚠️ 这一条与上面两处是耦合的:集合/字典「改大小要报错」在 C VM 上只有在不预取 的前提下才报得出来 —— 所以顺序上先定 B1/B2,再统一改五处。

✅ 已修(2026-10-05,五执行器一致):iter_new 那条口子用上了 —— ① 集合 / 字典改大小 → 同一条带码错误(E2000:运行期错误(遍历的过程中改了 容器的大小…) + 提示「先取出来(或复制一份)再遍历」),文案在 errors.ITER_SIZE_CHANGED / Rust ITER_SIZE_CHANGED / Node ITER_SIZE_CHANGED 只有一份; ② 规范侧(树遍历 / Python VM)不再漏裸 RuntimeError; ③ 宿主(C VM)把原生异常换成同一条基石错误 —— 顺带把字典的「键快照批量」去掉 (那正是它「静默跑完」的原因),改成 Python 自己的字典迭代器逐个拉; ④ Rust(VM + 树遍历参考执行器)与 Node 各加大小护栏(快照 + 每步核对 len / size);顺带补上 Node 与 Rust 侧「运行期错误 → E2000」的码(以前只有 Python 给)。 对拍结果:集合增 / 字典增 / 列表增 / 字典改值不改大小 —— 六个实现逐字一致。

✅ B2 本体也交付了(2026-10-05 当天,ABI v2):列表那最后一格补齐 —— 六个实现在六种改法(追加 / 移除 / 清空 / 排序 / 反转 / 下标赋值)上逐字一致。 做法与踩到的坑:

FOR_ITER 不再让宿主「按自己读到哪」继续读,而是每次带 consumed 去要 (iter_next_batch(it, from, …))。丢缓冲后拿同一个 from 重取 —— 于是 「预取超前一截」与「容器被改」解耦了:不需要知道改了什么、改了多少, 也不需要宿主有「回退」能力。活跃迭代器挂一条双向链表,供宿主点名失效。

不能按 C 侧句柄号 —— 宿主每次 py2c 都发新号,对象.属性 / 列[0] 这类 重新求值拿到的容器号与 遍历 那一刻不同,按号比对会漏判,而漏判就是 静默算错。所以:iter_new 时按 id(列表) 登记 → 改动的回调里按 id() 反查 → 调 jsvm_iter_invalidate(vm, host_iter_handle) 让 C 侧丢缓冲。

list_append / list_append_many(延迟合并写的落盘点)、getattr(拿到绑定 方法的唯一门)、call(实参 + 绑定方法的接收者 _BoundMethod.obj)。 ⚠️ 判据是「这个操作可能改了它」,不区分方法名 —— 多失效只是多一次取批, 漏失效是静默算错。

一个 dict 里记「绑定方法 → 列表」—— 推导式 [x 追加 y] 编译出来正是 GET_ATTR + CALL(不是融合指令 METHOD_CALL),那是最热的建表路径, 记一笔就是每元素一次字典写入(实测建表 +18%)。改成调用时从 _BoundMethod.obj 反查,并且整段套在 if self._iter_lists: 里 —— 没有列表在被遍历时一分钱不花。

并补了运行时自报 jsvm_abi_version():装载时核对,不一致就明确报错 (这类错原本不报错、只是算错或崩;本项目踩过两次「改了 C 源、加载的是旧库」)。 结构钉 tests/test_m84_cvm_live_iter.py。

两次读数 -5.2% / +2.4%(符号还翻 → 噪声内);批量仍在(取批次数判据在 m84,用调用次数而不是墙上时间钉住)。

三、Val 的大小:实测 128 字节,文档里的「32」是错的

新增 jishi-rs --dump-val-size(自报家底,与 --dump-stdlib / --dump-builtins 同款)+ tests/test_m82_val_size.py(结构钉)。第一次量就推翻了文档:

项字节说明
Val128文档长期写「32 字节」,是错的(没量过的估计)
Class120撑大它的主因 —— Val::Class(Class) 内联
Func88内联
ExcValue72内联
Module 载荷72内联
ListIter16本轮新加的迭代器状态

收益已实测(N = 200 万,Rust VM 净耗时;三次取最优):

负载净耗时
只求和(遍历 范围(N),不建列表)317 ms
建列表 + 求和(列表载荷 200 万 × 128 B = 244 MB)1460 ms

结论:值得做 → 当天就做了(用户拍板「两件都清完再进 R7」)。把 Class / Func / Module 载荷装箱(保留 Module 的两字段形状,所以大多数 match 不用改),Val 从 128 → 32 字节(剩下的最大载荷是 String 24 + tag)。

交错 A/B 实测(同机同时段、每负载 5 轮交替、取最优;判据纪律见上):

负载(N = 200 万)128 字节32 字节倍数
建列表 + 求和1409.8 ms1149.0 ms1.23x
只求和(对照,不建列表)301.1 ms181.2 ms1.66x
递归斐波(27)340.7 ms360.3 ms0.95x(噪声内 → 函数调用密集持平)

📌 「只求和」没有列表也快了 1.66x —— 因为值栈与局部槽本身就是 Val: 每格从 128 降到 32,缓存命中率立刻改善。这条比「列表载荷变小」更能说明 「每格成本 ÷4」的意义。 📌 函数调用密集没变快(0.95x,噪声内):那条路的瓶颈在帧的分配与参数绑定, 不是 Val 的宽度 —— 与实际相符(D66/D67 量过)。

⚠️ 风险控制:Val 的表示改了,但语义没改(装箱只是多一层间接)。 test_m82_val_size.py 同时钉住「32」与「三个大载荷必须大于 Val」(防有人顺手内联回来)。

📌 教训(通用级):32 这个数在文档里躺了很久、被反复引用,却从没量过 —— 和 D53「自动化工具分不清事实与事故」同源:没有量过的数,不许当事实写。 现在有了 --dump-val-size 与结构钉,「以为」这个口子被堵上了。

四、验收(本轮)

闸门结果
列表活迭代(Rust VM + 树遍历)与规范逐字一致(增/删两形状)✓
test_m64_rust_vm_iter(含 O(n²) 回归护栏 + 计时)全过 ✓(没退回 O(n²))
test_m82_val_size(新结构钉)2 项过 ✓
夹具 / check_engines / check_walk见提交说明(本轮未改字节码,夹具不动)

遍历中改容器:三族全部修好,判据进语料(2026-10-05)——

tests/test_m83_iter_mutation.py);

+ 「改别的列表」对照)→ tools/check_engines.py 不一致 0;

批量还在(按取批次数判,不看墙上时间) / 登记表不泄漏 / 失效后重取不跳元素);

四阶段全一致。

D78. M112:数据持久化(数据库 模块)—— 六条取舍(2026-10-15)

背景:M112 是「新增大类 ①数据持久化」,范围 2026-10-11 已拍板,准入三关也过了 —— 是真实探针项目(examples/绕道探针/记账本/)撞出来的,台账 R001/R002:

「有就加、没有就置」;

验收(路线图 §12.1.2):绕道 0;演示「JSON 文件 → 库」的迁移。

一、Rust 侧捆 rusqlite(用户 2026-10-15 拍板)

rust/Cargo.toml 原来写着「无第三方依赖」,stdlib/mod.rs 的头注还专门列了 「别人有现成实现、我们只能自己写」的五处(正则 / 加密 / 压缩 / 网络 / 表格)。 数据库这一处不自己写:SQLite 是几万行的 C 实现,重写既不现实、也拿不到生态 (文件格式、外部工具链)。

sqlite3、Node 侧内置 node:sqlite —— 只有 Rust 侧要引依赖,所以只在那一处破例。

rust/Cargo.toml 与 stdlib/mod.rs 的「零第三方依赖」表述必须改,不许留着旧话。

不许静默少一个模块(判据钉住)。

二、汇总 在宿主按确定顺序累加,不用 SQL 的 SUM

浮点加法不满足结合律 ⇒ SUM(金额) 的末几位取决于累加顺序,而累加顺序由 查询计划决定 —— 五个引擎的 SQLite 版本各不相同,不能靠「版本一样所以顺序一样」。

⇒ 汇总 一律:SELECT … WHERE … ORDER BY &lt;分组字段&gt;, id(显式确定顺序)把行取回来, 在宿主用同一条左到右累加。这样五引擎逐字节一致是构造出来的,不是碰巧的。 查询 同理:默认 ORDER BY id;给了排序就 &lt;字段&gt;, id —— 永远拿 id 兜底 (否则等值行之间的顺序不确定)。

⚠️ 代价如实说:大表比 SQL 的 SUM 慢(还在宿主解释器里跑)。要 SQL 那种速度, 走 执行查询(库, SQL) 那条逃生口。

三、汇总 与 表格.分组汇总 同形状

{组: {个数, 总和, 平均, 最大, 最小}},组按首次出现顺序(ORDER BY MIN(id), 不按组名排序 —— 键可能是文本也可能是数字,混着排会炸)。学会一个就会另一个, 这是本项目既有的规矩(表格.汇总 与 表格.分组汇总 就是这么定的)。

四、建表 自动加 id,字段不声明类型

建表(库, &quot;账目&quot;, [&quot;金额&quot;, &quot;类别&quot;, &quot;备注&quot;, &quot;日期&quot;]) → CREATE TABLE IF NOT EXISTS 账目 (id INTEGER PRIMARY KEY AUTOINCREMENT, 金额, 类别, 备注, 日期)。

兼容:基石建的库,Python 侧能直接读写,「用任何 SQLite 工具打开」这条才立得住。

五、类型映射(五引擎必须同一张表)

基石存进 SQLite读回来
整数INTEGER整数
浮点REAL浮点
文本TEXT文本
真 / 假INTEGER 1 / 0整数 1 / 0(SQLite 没有布尔,如实说)
空NULL空
字节BLOB字节
精确小数REAL(丢精度!)浮点
列表 / 字典明确报错,附修法(json.文本化 先转文本)—

并给出可操作的替代:要一分不差就把钱存成「整数分」(2550 而不是 25.50)。 这也正是 参考实现.py 里 金额 REAL 的同一个口径(否则两边对不上)。

精确的 i64)。如实登记,不在语料里越界。

D79. M113:字节 —— 一个新的值类型(2026-10-15)

背景:用户提「基石没有「字节」类型」,拍板做成真值类型、摆在 0.2.4、紧接 M112。

它为什么该存在(三处一直在绕它)

位置之前什么样现在什么样
加密.安全随机字节(n)名字带「字节」,返回十六进制文本返回字节(名字终于不撒谎)
编码拿 0-255 整数列表当字节(文本字节 / base64解码 / 十六进制解码 都返列表)一律返回字节
数据库 的 BLOB 格M112 明确报错「基石没有字节类型」字节 ↔ BLOB 往返

一、不加字面量 —— 语法面冻结不动

字节 只能由标准库产生(编码.文本字节 / 编码.十六进制解码 / 编码.base64解码 / 加密.安全随机字节 / 文件.读字节),源码里没有 b&quot;…&quot; 这种写法。

⇒ 换来的两件实事:

  1. 字节码一处不用改:常量池只装标量,四处序列化实现(oracle/jishi/serialize.py /

frontend/src/serialize.rs / oracle/node/index.js / rust/src/lib.rs)全不动、不重冻夹具;

  1. 不碰 M36 的语法冻结 —— 没有新语法、新关键字、新标点。

📌 想要「写在源码里的字节」就写 编码.十六进制解码(&quot;0CFF00&quot;)。真要字面量, 那是另一个(要过准入三关的)决定,不在 M113。

二、显示:字节(0C FF 00)

一个值只有一种写法(§7.15 的规矩):顶层、容器内、REPL 回显全一样。

字节(0C FF 00)                        # 三个字节:大写十六进制、两位、空格分隔
字节()                                # 空字节
字节(0C FF 01 … 共 1024 个字节)        # 超过 16 个:只列前 16 个,再标总数

(对照 集合() 没抄 set()),且一眼看得出这是字节值而不是文本 —— 纯十六进制 0CFF00 与 编码.十六进制编码() 的输出一模一样,反而更容易误认成文本。

编码.字节文本(…)(解不出合法 UTF-8 会明确报错,不返回乱码)。

三、语义表(五引擎必须逐条一致)

操作结果
类型(b)字节
长度(b)字节个数
b[i]整数 0–255(支持负索引;越界报错 —— 与列表同口径)
b[a:c]字节(切片,与列表同口径)
b + c字节(只能字节拼字节;字节+文本明确报错)
b n / n b字节(n ≤ 0 → 空字节,与 文本 * n 一致)
遍历 x 在 b逐个给整数 0–255(与 Python 的 bytes 一致)
真假(b)空 → 假,否则真
b == c逐字节比;跨类型给「假」不报错(b == &quot;甲&quot; → 假,与 Python 一致)
b &lt; c字节之间按字典序;跨类型明确报错(Python 3 也是 TypeError)
x 在 b字节 在 字节 = 子串;整数 在 字节 = 有没有这个字节值(与 Python 一致)
字典键 / 集合成员可以(不可变、可哈希)
排序(…)支持(字典序)
总和 / 平均 / 最大 / 最小明确报错(字节不是数字)
json.文本化(b)明确报错 + 提示「JSON 没有二进制,先用 编码.base64编码 转文本」
数据库存 BLOB、读回字节

有意不做(等真实需求):.方法(编解码仍走 编码 模块)、字节与文本的隐式互转、 内存视图 / 可变字节数组、网络 与 压缩 的字节面(网络.下载 仍只存文件)。

四、🔴 落地时最危险的一点:编译器只抓得住 1 处

实测(往 Val 加一个变体 → cargo check --release):只报 1 处 E0004 —— display,因为它把每个变体都列全、没有兜底臂。

其余「语义助手」全走兜底臂,编译器一声不响:

函数兜底臂不接的后果(全是静默的)
truthy_ =&gt; true空字节被当成真
type_label_ =&gt; &quot;其他&quot;类型() 给「其他」
cmp_vals_ =&gt; Ok(None)比较行为退化
set_key_ =&gt; None字典键退化成线性扫(对,但慢)
py_repr兜底容器里显示错

grep -c &quot;_ =&gt; &quot; rust/src/lib.rs = 61。 ⇒ 加值类型不能靠编译器:要拿上面那张语义表逐条走一遍。这是 「别拿「检查是绿的」当「都实现了」」的又一例,也是 M113 判据的立足点。

五、C VM:零改动(说清为什么)

JVal 是 16 字节定长 struct,复杂值(列表/字典/日期/表格)一律走 HOST 句柄 (cvm_bind.py 的句柄表;py2c 的兜底就是 _jhost(self._new_handle(obj)))。 字节走同一条路 ⇒ JVal 布局不动。

📌 这同时回答了「不做清单」里「元组类型」那条的顾虑 —— 那条写的是 「要动 C VM 的 JVal 布局」,而字节不适用,所以两者不能类比。

六、破坏性变更(如实登记)

四个既有函数的返回类型改了(以前给「0-255 整数列表」,现在给字节):

理由:那正是「没有字节类型」逼出来的形状,现在该还回去了。CHANGELOG.md 要如实写。 编码.字节文本 / base64编码 / 十六进制编码 的入参同时放宽到接受字节。


D80. M114:并发 —— 为什么做成「批量 IO」而不是「语言级线程」(2026-10-16)

用户提「推进 M114」。M114 在路线图里是新增大类 ②并发(线程 / 锁 / 队列), 验收写着「有可复现的加速倍数(先量再判,没有就如实写「不做」)」。 用户拍板:走「批量 IO 型 并发 模块」—— 并发是标准库的实现细节, 不是语言特性。

一、为什么不能做「语言级线程 / 锁 / 队列」(三条硬约束,都实测过)

  1. Rust 侧的 Val 不是 Send。容器是 Rc&lt;RefCell&lt;…&gt;&gt;(M28 有意为之:

基石里的列表/字典是引用类型),函数值 Val::Func(Rc&lt;…&gt;) 同理。 实测 cargo build 直接报 19 条 cannot be sent between threads safely (Rc&lt;Vec&lt;u8&gt;&gt;、Rc&lt;RefCell&lt;Dict&gt;&gt;、Rc&lt;WalkFn&gt;…)。要真并发只有两条路: ① 把全仓 Rc 换成 Arc&lt;Mutex/RwLock&gt; —— 动全部值语义、改性能特征、 还得重走 D79 那条「61 处 _ =&gt; 兜底臂」的静默风险; ② 每个线程起一份独立解释器 —— 那闭包捕获的环境就断了(它不是 Send)。

  1. C VM 是单实例、且宿主回调表是冻结 ABI(JSVM_ABI_VERSION = 2,新回调

只能末尾追加)。从另一个线程回调进同一个 JsVm 就是数据竞争。

  1. wasm 没有线程(wasm/Cargo.toml 那个 budget 特性正是为「无线程」加的)。

⇒ 真并发在五个引擎里至少三个做不到。而这门语言的立身纪律是 「五引擎逐字节一致」—— 为它开三个豁免,代价远大于收益。

二、那为什么还要做?—— 真实需求是真的(关 1)

绕道探针《并发批量》撞出来的 R005:一批几千个小文件只能一个个排队算。 Python 参考实现用线程池快 2.76 倍。这是真实项目逼出来的,不是「Python 有我们也要」。

三、怎么办:并发只做「一批互相独立的 IO」(关 0:能用标准库做的不碰语法)

并发 模块三个函数,函数吃「数据」不吃「函数」:

函数干什么
并发.批量摘要(路径们, 算法=&quot;sha256&quot;, 线程数=4)并行算一批文件的摘要
并发.批量读(路径们, 线程数=4)并行读一批文件(给 字节)
并发.并行取(网址们, 线程数=4)并行取一批网址的正文

关键:线程里一个 Val 都不跨(只跑 String / Vec&lt;u8&gt;), 所以 Rc 那条约束根本用不上,也不用动值表示。 Rust 侧用 std::thread::scope(零第三方依赖,借用外层数据、不需要 &#x27;static/Arc)。

四、三条硬规矩(跨引擎对拍的前提)

  1. 返回顺序 = 输入顺序(严格一一对应)。做法:把项切成最多 线程数 个

连续段,一段一个线程,再按段序拼回来 —— 顺序天然保持, join 的先后完全不影响输出。这是「并发不影响可复现性」的根。

  1. 首个错胜出:段按输入顺序扫、段内也按输入顺序 ⇒ 报的永远是

输入顺序里最靠前的那个失败。不让「谁先跑完」决定报哪句。

  1. 不支持线程的平台顺序执行:wasm 上 concurrent.rs 用 #[cfg] 编另一份

顺序实现(不是运行时判断 —— 这样 wasm 上根本不引用 std::thread); Node 宿主也顺序执行(它的 fs/crypto 在这一层是同步 API,要真并行得上 worker_threads,重且判据基准用不上)。结果一模一样,只是不快 —— 如实标注。

五、实测(不声称)

同一台机、热缓存、交错取样取小值:

宿主场景顺序并发倍数
发行版(Rust)2000 × 4 KB0.74 s0.37 s2.0×
发行版(Rust)40 × 1 MB0.21 s0.05 s4.1×
判据基准(Python)2000 × 4 KB0.95 s0.44 s2.2×
判据基准(Python)40 × 1 MB0.058 s0.065 s0.89×(略慢)

两条要如实说:

3.98 s → 0.37 s = 10.7×)—— 那是把「等磁盘」也并行化了,进了缓存就回到 2.0×。

判据基准用 OpenSSL 的 sha256(快),大文件由顺序读主导; 发行版的 sha256 是自己手写的,40 MB 时算摘要本身才是瓶颈。 📌 顺带记一笔发现的机会:Rust 侧手写 sha256 是发行版跑分的一个已知短板 (本轮不修,留作后续候选)。

六、有意不做

本轮只是把「一批 IO 并行做」这件标准库的事做掉了);

§一 那三条硬约束的形状;


D81. M115:HTTP服务端 —— 为什么拆成「纯协议核心 + 真服务端」两半(2026-10-16)

用户提「推进 M115」。M115 在路线图里是新增大类 ③HTTP 服务端(本地小服务), 验收写着「起得来 / 能访问 / 能干净关闭」。用户拍板:做成两半。

一、需求从哪来(关 1)

绕道探针《本地小服务》撞出来的 R006:想让「增删待办」在浏览器里点一下就行 —— 没有 socket、也没有 HTTP 服务端,只能生成一张静态页让用户手动刷新。 对照的 Python 参考实现 20 来行 http.server 就能做成可交互的。

二、为什么拆两半:平台能做的事不一样(关 0:能用标准库做的不碰语法)

平台纯协议核心(解析请求/构造响应)真服务端(启动/空闲端口)
Python(判据基准)✅✅ http.server
Rust(发行版)✅✅ std::net::TcpListener(零第三方依赖)
C VM✅✅ 标准库调用本来就走宿主(Python)
Node 宿主✅❌ 起不了
wasm(试玩页)✅❌ 没有 socket

Node 为什么起不了:node:http 是异步 + 事件循环,而基石的 VM 是同步的 —— 没有「同步泵事件循环」的 API,真阻塞住了事件循环就跑不动, 处理函数永远等不到。把服务端塞进子进程也不行:那样调不到你的基石函数(它在父进程里)。

⇒ 所以把「只是文本处理」的那一半(解析请求 / 构造响应)单独暴露: 它五个引擎都能跑,于是能进语料、五引擎逐字节对拍; 要 socket 的那一半照实报边界。不假装(与 D78 的 数据库 在 wasm 上同一条思路)。

三、三条约定(跨引擎一致的前提)

  1. 只监听 127.0.0.1 —— 安全默认(局域网连不上),要对外自己套反向代理。
  2. 一问一答就关(Connection: close,不做 keep-alive)—— 本地小服务够用,

而且「一个请求 = 一次 处理函数 调用」最好推理。

  1. 正文按 UTF-8(不合法字节替换成 `);要传二进制先 编码.base64编码`。

四、四个函数

函数是什么
解析请求(请求文本)纯函数:原始请求 → {方法, 路径, 查询, 版本, 请求头, 正文}
构造响应(状态=200, 正文=&quot;&quot;, 内容类型=…, 头=空)纯函数:→ 完整响应文本(Content-Length 按字节算)
启动(端口=8000, 处理函数, 最多请求数=0)阻塞起服务;端口=0 ⇒ 系统分配;最多请求数=0 ⇒ 一直跑
空闲端口()问系统要一个空闲端口(端口=0 时打印 URL 用得到)

五、几条「实现时才想清楚」的规矩

raw_requestline + headers 重拼回原始文本再交给 解析请求 —— 这样只有一份协议实现,不会两边慢慢漂开。

handle_request() 每接受一个连接就返回一次,连上就断(端口探测 / 健康检查) 也算一次 —— 混起来的话探测连接会把配额吃掉、真实请求反而被拒 (M115 自己踩到,测试直接红了)。

一条坏请求不该把整个本地服务带走,浏览器里还能直接看见错在哪。 ⚠️ 正文只用错误对象的 message —— 那个字符串三宿主本来就是逐字对齐的, 用整段渲染(带位置与 ^ 指示)反而各说各的。

请求行按空格 / 制表符切、头名头值去空白也只去空格 / 制表符 —— 各语言的 Unicode 空白表在 U+001C–1F、U+0085 上对不齐,必须自己写死。

(Python 的 int() 会收浮点与 &quot;8000&quot;,那就是「换个引擎就报错」)。

六、一起修掉的既有分叉:文本.按行拆分

写 M115 语料时要「把响应按行拆开打印」(避免黄金文件里出现裸 CRLF), 撞出宿主侧那份 按行拆分 与 Python / Rust 不一致(不是 M115 引入的): 旧实现用 /\r\n|\r|\n/ 切、再拿一个 filter 删空段,两条都错 —— ① 少认 \v \f \x1c-\x1e \x85 \u2028 \u2029; ② 那个 filter 按「数组最后一个是不是空」判断,对每个元素都成立 ⇒ &quot;甲\n\n&quot; 给 [&#x27;甲&#x27;],而 Python / Rust 给 [&#x27;甲&#x27;, &#x27;&#x27;]。 已按 text.rs::split_lines 的同一套重写,并补 tests/cases/35_文本_按行拆分.jsh 钉住。

七、有意不做


D82. M116:语言卡带签名 + 分片 —— 为什么「投影」也要分片(2026-10-17)

用户把 0.2.5 的「AI 深化」提前进 M116(0.2.4 内容太少,0.2.5 之后重排)。 三件里第一件、也是路线图 §12.2.1 标了「🔴 不可推迟」的,是语言卡带签名 + 分片。

一、问题:卡片只有「名字」,模型在猜

--ai-card 的标准库段以前长这样:

- 导入 文本:切成三段、去前缀、去后缀、格式化、按行拆分、截断、填充

模型拿到「有个函数叫 居中」,参数顺序得猜、也看不出哪个参数能省。 而规格(--lang-spec)里早就有一模一样的 sig 与 doc —— 是 render_ai_card() 没渲染出来。标准库从 M105 的 231 函数补到 M115 的 281,这个洞同比放大: 补得越多,模型猜错的越多。

二、两件一起做:带签名 + 分片

带签名后全量卡随模块数线性膨胀(实测 15.8K 字符,约旧卡的 1.9 倍)。 所以分片不是「锦上添花」,是带上签名后必须付的代价:

形态用途实测(字符)
--ai-card全量(一贴就能用)15805
--ai-card 文本单模块3754
--ai-card --任务 表格按任务(若干模块 + 该任务惯用法)5758

三、三条「实现时才想清楚」的规矩

True/False/None 会原样漏出来 —— 等于卡片在教模型写 True(M116 抓到 12 处)。 规则:字面量照写(含 1e-09、负数);True/False/None → 真/假/空; 认不出来的(模块常量 _DEFAULT_TIMEOUT、属性 _py_math.e)写成 参数=… —— 既表明「有默认值、可以省」,又不把内部名字漏给模型(不猜)。

—— 宿主只认必填个数)。卡片不能教一个在发行版上会报错的写法。

这些中文静态文本,由 tools/gen_langdata_rs.py 从 Python 侧投影进 rust/src/langdata.rs(发行版要自包含,不能 import Python)。手抄两遍必漂, test_m90 的「逐字节一致」盯着。

四、--任务 打包:先给 7 个,跑分反推再补

--任务 X = 若干模块 + 该任务的惯用法(就一种正确写法,专治模型易错的形状)。 先定了 7 个:表格 / 文本 / 文件 / 网络 / 数据 / 并发 / 测试。⚠️ 这些惯用法是 手写的,写错会教模型写错 —— 跑分(tools/eval_ai.py --规格 card)能抓出来, D 编号里记了几处(建表 要字段名列表、汇总 的参数、读表格 返回表格对象)。

五、跑分的通道

tools/eval_ai.py 默认喂给模型的是 --lang-spec 的 JSON(30K 字符,与 M45 那组可比)。语言卡是另一条通道(给人/编排器贴提示词用),不是同一次度量 的目标 —— 加了个 --规格 card [--卡分片 X] 以便单独量卡片这条路的有效性。


D83. M119:编码 的 GBK —— 为什么接受第二个第三方依赖,以及 Node 那张码表怎么办(2026-10-18)

背景

台账 R101 之外,M119 还要补 编码.GBK文本 / 文本GBK。这不是「想加个花样」—— 国内大量老的 CSV / TXT 就是 GBK,读进来是乱码,这是真实撞得到的痛点。

为什么 GBK 不能自己写

GBK 是两万多个码位的映射表(实测 21791 个有效双字节码位)。手写码表意味着 两万多行常量,既没法审、也没法维。这与 压缩/加密 那些「十几行能写完」的东西 不是一类 —— 那些自己写是对的,这个自己写是错的。

决定:Rust 侧引 encoding_rs

⚠️ 一个必须处理的行为差异:encoding_rs 的 encode() 遇到编不出来的字符 不报错,而是写成 &amp;#NNNNN;(had_errors 标志位)。所以两个方向都要查那个标志位再 决定报不报错 —— 沿用「不静默给错值」的老规矩。

Node 宿主:TextDecoder 能解、编不了

「静默错」,看起来完全正常。

所以 Node 的编码方向只能自己带表。决定:

签入仓库(与 stdlib_sigs.js 同一类产物,改了才要重跑);

槽位到码号是纯算术,不需要把码号也存一遍。

五引擎对拍**了。而「五个引擎逐字节一致」是这个项目的核心判据,为省 77 KB 破掉它不划算。

判断:「自动编码整段网址」那个顾虑不成立(R101)

绕道台账里当初记着一条反对意见:

自动编码整段网址是错的(路径和查询串口径不同),真要加也应是「查询串专用」的入口。

M119 的做法把它化解了:只转非 ASCII 字符(含空格),结构字符 / ? &amp; = # % 一律保留。

Node 侧要再还原一次 %25XX → %XX);

判据在 test_m107_network.py 的那两条(服务端回显原始请求路径,不只看「没报错」)。


D84. M120:时间.时区 为什么变成 时区偏移,以及「平台相关的值」怎么过判据(2026-10-18)

一、时区名这条路是死的(实测,不是推测)

计划里写的是 时间.时区。真去量了一下三家能拿到什么:

宿主拿到的值API
Python中国标准时间(本地化长名)time.tzname
NodeEtc/GMT-8(IANA 名,且不是 Asia/Shanghai)Intl.DateTimeFormat().resolvedOptions().timeZone
Rust拿不到标准库没有 tz database

三家的值是三个不同种类的东西,而且 Rust 那一格根本没有。 硬要做只能「各宿主给各自的」——那 时间.时区 就进不了五引擎对拍, 等于给核心判据开一个口子。

偏移完全不一样:utcoffset() / getTimezoneOffset() / 「本地时间 vs epoch 的算术差」 三家都算得出来,而且是同一个数(同一台机器上必然相同)。

⇒ 决定:时间.时区偏移() 返回 +08:00。名字也跟着改 —— 「时区」返回偏移是骗人的,「时区偏移」名副其实。

「显示我在哪个时区」这种需求,请自己写死名字。语言不该假装能给一个跨平台一致的名字。

Rust 侧的一个具体决定:不读 tm_gmtoff

Unix 的 struct tm 有 tm_gmtoff,但 Windows 没有这个字段 —— 走那条路就得写两份 平台特判(Windows 要用 GetTimeZoneInformation)。

改成「本地时间 vs epoch 的纯算术差」:两边都从同一个 secs 推出来, 所以没有「取两次时钟、跨了整秒」的竞态,也不需要任何 cfg。 not(any(windows, unix)) 平台会退回 UTC ⇒ 偏移 0(合理)。

二、json 改成严格口径:跟标准走,不跟宿主走

计划里只写了 json.有效。真做之前先量了「什么算合法」:

输入Python json.loadsJS JSON.parseRust 手写 parser
NaN接受拒绝接受
Infinity接受拒绝接受
{&quot;a&quot;: 1,}拒绝拒绝拒绝

NaN / Infinity 是 Python 的非标准扩展,RFC 8259 里没有。同一段文本三家两个答案 ⇒ json.有效 必然对不上。

有两条路:跟宿主(各说各的)或跟标准(全都拒绝)。选标准,理由: ① 那三个词本来就不是 JSON;② 「有效 说有效但发给别的服务被拒」是最难查的一类 bug。

连带把 json.解析 也改成拒绝 —— 否则会出现「有效 说假、解析 却成功」, 那比不一致还糟。

📌 序列化方向不用动:基石语言层造不出 NaN / Infinity —— 除零、负数开方、 非正数求对数全都报错(实测)。所以 json.转文本 永远不会吐出这三个词, 「严进严出」是天然成立的,不用额外加保护。

三、方法论:平台相关的值不能进黄金语料

tests/cases/*.out 是签入仓库的黄金输出,CI 会在 Linux / macOS 上跑。 M120 这批里有三个值是平台/机器相关的:

钉死具体值 = 在别的平台上必红(这正是「判据不许依赖测试跑在哪台机器上」那条的老问题)。

但这不妨碍它们进语料 —— 只要钉的是不变量:

函数钉什么
路径.分隔符长度 == 1 且「是 \ 或 /」
系统.CPU核数&gt;= 1
时间.时区偏移长度 == 6 且「开头是 + 或 -」且「含 :」

五个引擎在同一台机器上跑,所以这些不变量照样能对拍。 ⇒ 「平台无关的判据」和「五引擎一致」并不冲突,只是要选对钉什么。

四、副产品:Node 有两个渲染口径,别混用

Node 侧同时有:

M120 第一版在报错消息和表格渲染里都用了 R.display,被 74_表格_转文本要传表格 这条报错语料当场抓出来(Rust 那一侧用的 crate::display 恰好是打印口径,所以只有 Node 红)。要「文本长什么样」就用 pyStr。

(这条能被抓到,靠的正是 M118 记下的教训:新增报错语料必须进 check_engines。)


D85. M121:把「报错可操作」落到实处,以及模块门面只能是 __all__(2026-10-18)

一、起点:一句空头承诺

模块.名字 找不到时,三个宿主的提示一直是同一句:

可以看看模块里有哪些函数,例如:打印(dir(模块名)) 在后续版本可用

问题有两层:

  1. dir 根本还没实现 —— 让人去用一个不存在的东西,比不说还糟;
  2. 就算有 dir,它答的也是「什么有」,而用户真正卡住的是「我为什么写错了」。

真去撞了一下,发现最坑的一类错是这样的:

导入 文本
文本.去空白(" abc ")       # E0302 模块「文本」里没有「去空白」

去空白 确实存在 —— 它是字符串的方法,写在值后面:&quot; abc &quot;.去空白()。 函数没少,只是不能这么调。这类错模型也常犯(M116 跑分时就撞到过), 而旧提示对它们一个字都没说。

二、改成两段式

模块.名字 找不到
├─ 名字在方法表里? → 「`去空白` 是文本的方法,不是模块函数 ——
│                      要写在值后面:`你的文本.去空白(...)`」
└─ 否则             → 「模块「文本」里有:切成三段、去前缀、…(共 21 个)」

两个细节:

Rust 侧经 langdata::METHODS(生成物)、Node 侧直接读那六张表。 类型顺序固定(不是随便遍历),否则同一个名字可能在多处出现、三边给出不同答案。

判据:error_cases/76/77/78 三条语料进五引擎对拍 ⇒ 三边提示逐字一致(包括 「列函数」那 20 个名字的排序 —— Python 按码点、Rust 按 UTF-8 字节、JS 按 UTF-16 码元, 中文都在 BMP,三者同序,但必须显式排序:HashMap / dict 的遍历序每进程都不同)。

三、顺带撞出来的:28 个模块的门面全是漏的

加「列出模块里有什么」这条提示时,第一版列出来的是:

模块「文本」里有:RunTypeError、annotations、jishi_repr、切成三段、去前缀、…

RunTypeError / annotations / jishi_repr 是 文本.py 里 import 进来的实现细节。

根因在 do_import:

pythonattrs = {k: v for k, v in vars(mod).items() if not k.startswith("_")}

vars(mod) 是模块的整个命名空间,import 进来的一切都在里面。实测 28 个模块 无一例外都漏(json.annotations、表格.显示宽度、加密.PBKDF2_默认迭代、 HTTP服务端.socket…)。

后果不止是提示难看:--lang-spec / --ai-card 读的是 __all__(说 21 个函数), 而运行期能拿到 24 个 —— 投影与实现不一致,而「投影必须是事实的投影」正是核心二。

⇒ 有 __all__ 就按它过滤。

⚠️ 一个必须显式写出来的边界:有状态模块

数据库 / 日志 / 测试 三个模块声明了 __基石新建__(),函数在钩子内部定义、 每次导入新建一份(否则同一进程连跑两个执行器会串,M41 栽过)。

它们的 __all__ 里那些名字(打开、信息、用例…)在 vars(mod) 里根本不存在 —— 光按 __all__ 去 vars 里取,这三个模块会变成空模块;它们能跑,是因为 do_import 里钩子在 attrs 之后覆盖了它。

⇒ 判据必须把这条钉住(test_m121_err_hints.py::test_有状态模块走钩子而不是_vars): 顺序一旦被谁调换,三个模块立刻全空,而单元测试之外几乎发现不了。

四、跑分脚本:慢的真因不是「题多」,是限流姿势错了

同轮按服务商条件(RPM 50 / TPM 2M)量了一下:

事实数
单次请求耗时~7.8 秒(prompt 每次约 1.6 万 token)
74 题串行(1 轮/题)> 9.6 分钟
串行实际速率7.7 次/分钟 —— RPM 50 的额度用不到

而旧脚本的「避限流」只有一句 sleep(题间间隔=1.5),且它只在题目之间生效 —— 同一题的第 2/3 轮是连发的(回灌报错让模型改),一撞 429 就退避 3/6/12/24 秒: 越重试越慢,等待还白占额度。

⇒ 换成两件事:

—— 重试不是「额外机会」,它同样占额度。

实测:74 题全量 458.8 秒(7.6 分钟)(此前同规模串行要 20 分钟以上)。

⚠️ 锁里 sleep 是故意的:排队线程醒来后必须重新算「下次可发时刻」, 否则所有线程会同时醒、同时发 —— 限速器最经典的写错法。