cli の設計

cli パッケージは橋渡し役です。Python のインデックスビルダーや MoonBit 以外のプログラムが MoonBit のスコア規則を評価できるようにします。このページではその橋渡しの形を説明します。スコア規則そのものは score の設計 で導出しています。

設計目標

スコア、ランク、モメンタムの規則の実装はちょうど 1 つでなければならず、Web アプリケーションが配信するデータベースもその実装で計算されなければなりません。インデックスビルダーは SQLite、ファイルシステム、HTTP を扱うため Python で書かれ、規則は MoonBit ユーザーがライブラリとして呼べるよう MoonBit で書かれています。橋渡しは、規則を複製せずに、できるだけ少ない仕組みで両者をつなぐ必要があります。

数学的背景

このコマンドは 8 つの整数の関数

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 値をスナップショットに写します。

ここから次の 2 つの性質が従い、呼び出し側はこれに依存しています。

  • 決定性。 ff と dd は純粋なので、同じファイルからは常にバイト単位で同じ出力が得られます。Json::stringify はフィールドを宣言順に書き出し、JavaScript ターゲットでは各 Double を往復可能な最短形式で出力します。
  • ライブラリとの一致。 値が Int の範囲内の整数である入力オブジェクトでは、dd はそれらの値に対して恒等写像なので、コマンドの出力はちょうど Json(@score.compute_score_snapshot(...)) になります。

設計上の判断

スナップショットごとに 1 プロセス、ファイル経由

課題。 Python から MoonBit のコードを呼び出す必要があります。

選択肢。 式を Python で実装し直す、WebAssembly にコンパイルした MoonBit を Python から呼ぶ、常駐する MoonBit サーバーを動かす、スナップショットごとに MoonBit プログラムを実行する。

採用。 Python がパッケージごとに 1 回起動する JavaScript の実行可能プログラムで、入力は一時ファイルで渡します。Python 版の式は 2 つ目の正となる実装になってしまいます(使われていないものが scripts/build_index.py にまだあります)。WebAssembly のホストやサーバーは、インデックスのビルドごとに 1 回しか走らない処理のために依存関係やプロトコルを増やします。ファイルを使えばコマンドラインが短く済み、ビルダーは finally ブロックで入力を削除できます。

代償はパッケージごとの Node.js の起動です。プロセスの起動には数十ミリ秒かかる一方、スコアの計算は数マイクロ秒なので、NN 個のパッケージに対するビルダーの評価段階は Θ(N)\Theta(N) 回のプロセス起動になります。数千パッケージ規模のレジストリなら、オフラインのビルドとして許容範囲です。

JavaScript 専用

コマンドは extern "js" 関数を通じて fs.readFileSync でファイルを読み、process.exit で終了するため、パッケージは supported_targets = "js" を設定しています。リポジトリは Web アプリケーションのためにいずれにせよ Node.js を必要とするので、ほかのランタイムは要りません。

入力は寛容に、エラーは厳格に

課題。 呼び出し側がすべてのシグナルを知っているとは限りません。たとえばビルダーがダウンロード数を取得できなかったパッケージです。

採用。 欠落したフィールドと数値でないフィールドは、どのシグナルにとっても中立な値である 0 にデコードします。不正な JSON や誤った引数はデータの欠落ではなく呼び出し側のバグを示すので、error オブジェクトを出力してステータス 1 で終了します。エラーオブジェクトが JSON なので、呼び出し側はどの場合でも標準出力を解析できます。

ラベルは MoonBit で計算する

コマンドは数値だけでなくラベルも返します。ビルダーが保存済みのスコアから rank_label を自前で計算すると、MoonBit でしきい値を変えてもデータベースには黙って反映されません。スナップショット全体を返すことでラベルと数値が 1 回の評価から得られるため、スナップショットの一貫性 という不変条件がデータベースでも成り立ちます。

正しさと不変条件

  • 終了ステータス 0 は、標準出力が ScoreSnapshot の 7 つのフィールドを持つ JSON オブジェクト 1 行であることを意味します。
  • 終了ステータスが 1 で標準出力が JSON オブジェクトなら、それは error キーを持ちます。入力ファイルを読み込めない場合も 1 で終了しますが、書き出すのは標準エラーだけです。
  • 出力は現在時刻、環境、入力以外のファイルに依存しません。時刻に関わるシグナルはすべて呼び出し側が計算します。

採用しなかった案

  • 標準入力から読む。 一時ファイルは不要になりますが、パスに対する readFileSync は Node.js が対応するどのプラットフォームでも同じように動き、ビルドがうまくいかないときにファイルを調べることもできます。
  • バッチモードで 1 プロセスあたり多数のパッケージを評価すれば、パッケージごとの起動コストはなくなります。これは実装されていません。パッケージごとの約束のほうが単純で、現在のレジストリ規模には十分な速さです。
  • シグナルごとのコマンドラインフラグ(--dependents 20)は呼び出しを長くし、パーサーも必要になります。JSON ならビルダーがすでに持っている辞書とそのまま対応します。

境界

  • コマンドが評価するのは 1 つのスナップショットだけです。レジストリの読み込み、SQLite への問い合わせ、ダウンロード数の取得、日付の計算は行いません。
  • シグナル同士の整合性は検証しません。
  • 読み込めないファイルは JSON ではなく Node.js を通じて報告されます。
  • JavaScript ターゲットと Node.js でのみ動作します。