cli 设计

cli 包是一座桥:它让 Python 索引构建器和其他非 MoonBit 程序能够调用 MoonBit 的评分规则。本页解释这座桥的形态。评分规则本身在 score 设计中推导。

设计目标

分数、等级和势头规则必须只有一份实现,并且 Web 应用提供的数据库必须由它计算。索引构建器用 Python 编写,因为它要处理 SQLite、文件系统和 HTTP;规则用 MoonBit 编写,以便 MoonBit 用户把它们当作库来调用。这座桥必须在不复制规则的前提下,以尽可能少的机制把两者连接起来。

数学背景

该命令计算一个关于八个整数的函数,

f:Z8→R5×{S,A,B,C,D}×{Rising,Hot,Stable},f : \mathbb{Z}^8 \to \mathbb{R}^5 \times \{\texttt{S}, \texttt{A}, \texttt{B}, \texttt{C}, \texttt{D}\} \times \{\texttt{Rising}, \texttt{Hot}, \texttt{Stable}\},

即 compute_score_snapshot。它的输入以 JSON 形式到达,因此命令先应用一个从 JSON 值到 Z8\mathbb{Z}^8 的全解码映射 dd,再输出 f(d(x))f(d(x))。对每个键 kk,

dk(x)={sat⁡(trunc⁡(xk))x is an object and xk is a number0otherwise,d_k(x) = \begin{cases} \operatorname{sat}\bigl(\operatorname{trunc}(x_k)\bigr) & x \text{ is an object and } x_k \text{ is a number} \\ 0 & \text{otherwise,} \end{cases}

其中 trunc⁡\operatorname{trunc} 向零取整,sat⁡\operatorname{sat} 把值截到 [−231,231−1][-2^{31}, 2^{31} - 1]。由于 dd 是全函数,唯一的失败都发生在它之外:参数错误、文件不可读以及文本不是 JSON。又因为评分会把负计数截为 00,复合映射 f∘df \circ d 会把每个 JSON 值映射到一个快照。

由此得出两条性质,也是调用方所依赖的:

  • 确定性。 ff 和 dd 都是纯函数,因此同一个文件总是逐字节地给出相同输出:Json::stringify 按声明顺序写出字段,并且在 JavaScript 目标上以最短往返形式打印每个 Double。
  • 与库一致。 对于值为 Int 范围内整数的输入对象,dd 在这些值上是恒等映射,因此命令输出的恰好是 Json(@score.compute_score_snapshot(...))。

设计决策

每个快照一个进程,通过文件传递

问题。 Python 必须调用 MoonBit 代码。

选项。 用 Python 重新实现公式;从 Python 调用编译为 WebAssembly 的 MoonBit;运行一个常驻的 MoonBit 服务器;每个快照运行一次 MoonBit 程序。

选择。 一个由 Python 为每个包启动一次的 JavaScript 可执行程序,输入放在临时文件中。Python 版本的公式会成为第二个事实来源(scripts/build_index.py 中仍有一份未被使用的副本);WebAssembly 宿主或服务器会为一个每次构建索引只运行一次的任务增加依赖或协议。使用文件可以让命令行保持简短,也让构建器能在 finally 块中删除输入文件。

代价是每个包启动一次 Node.js。启动一个进程需要几十毫秒,而计算分数只需要几微秒,因此对于 NN 个包,构建器的评分阶段需要 Θ(N)\Theta(N) 次进程启动。对于几千个包的注册表,这对离线构建来说是可以接受的。

仅限 JavaScript

该命令通过 extern "js" 函数用 fs.readFileSync 读取文件、用 process.exit 退出,因此该包设置了 supported_targets = "js"。仓库的 Web 应用本来就需要 Node.js,所以不需要其他运行时。

宽松输入,严格报错

问题。 调用方可能并不知道每一个信号,例如构建器未能查到下载量的包。

选择。 缺失和非数字的字段解码为 0,这是每个信号的中性值。格式错误的 JSON 和错误的参数说明调用方有 bug,而不是数据缺失,因此会产生一个 error 对象并以状态 1 退出。错误对象采用 JSON 格式,使调用方在任何情况下都能解析标准输出。

在 MoonBit 中计算标签

该命令返回标签,而不仅仅是数字。如果构建器根据存储的分数自行计算 rank_label,MoonBit 中阈值的修改就会悄无声息地无法反映到数据库中。返回完整快照可以让标签和数字来自同一次求值,因此快照一致性不变量在数据库中同样成立。

正确性与不变量

  • 退出状态为 0 意味着标准输出是一行包含七个 ScoreSnapshot 字段的 JSON 对象。
  • 退出状态为 1 且标准输出为 JSON 对象时,该对象一定带有 error 键。无法读取输入文件时也以 1 退出,但只向标准错误写入内容。
  • 输出不依赖于当前时间、环境或输入之外的任何文件:所有与时间相关的信号都由调用方计算。

被否决的方案

  • 读取标准输入。 它可以省去临时文件,但对路径使用 readFileSync 在 Node.js 支持的每个平台上行为都相同,而且构建出错时还可以检查该文件。
  • 批处理模式可以在一个进程中为多个包评分,从而省去逐包启动的开销。它尚未实现;逐包的约定更简单,对于当前的注册表规模也足够快。
  • 为每个信号提供命令行标志(--dependents 20)会让调用变长,还需要解析器;JSON 与构建器已有的字典直接对应。

边界

  • 该命令只计算一个快照。它不读取注册表、不查询 SQLite、不获取下载量,也不计算日期。
  • 它不验证信号之间是否一致。
  • 它通过 Node.js 而不是以 JSON 报告不可读的文件。
  • 它只在 JavaScript 目标上配合 Node.js 运行。