score チュートリアル

このチュートリアルでは、被依存パッケージ、ダウンロード数、リリース日から MoonBit パッケージのインパクトスコアを計算する方法、スコアを Web アプリケーションが表示するランクとモメンタムのラベルに変える方法、評価済みパッケージの一覧を並べ替えて説明する方法を示します。数学は軽めにしてあり、導出は score の設計 にあります。

クイックスタート

モジュールをプロジェクトに追加します。

moon add Luna-Flow/mooncake-impact-factor@0.1.2

使う側のパッケージの moon.pkg でパッケージをインポートします。

import {
  "Luna-Flow/mooncake-impact-factor/score",
}

被依存数 20(うち最近のもの 4)、ダウンロード数 300、40 日前にリリースされたパッケージを評価します。

test "quick start" {
  let score = @score.compute_score(20, 4, 300, 40)
  println("score = \{score}, rank = \{@score.rank_label(score)}")
}
score = 301.7852882193072, rank = S

スコアはただの Double です。260260 以上ならランク S です。

よくある作業

パッケージの一覧を順位付けする

各スコアはそのパッケージ自身のシグナルにしか依存しないので、異なるパッケージのスコアはそのまま比較できます。スコアの高い順に並べ、同順位は名前で決めて順序を再現可能にします。これは Web アプリケーションのトップフィードと同じ順序です。

test "rank a list" {
  let packages = [
    ("alice/json", 40, 6, 1200, 20),
    ("bob/http", 12, 1, 90, 400),
    ("carol/csv", 12, 1, 90, 400),
    ("dave/math", 3, 0, 0, 15),
  ]
  let scored = packages.map(p => {
    let (name, deps, recent, downloads, days) = p
    (name, @score.compute_score(deps, recent, downloads, days))
  })
  scored.sort_by((a, b) => {
    let by_score = b.1.compare(a.1)
    if by_score != 0 { by_score } else { a.0.compare(b.0) }
  })
  for entry in scored {
    let (name, score) = entry
    println("\{name} \{@score.rank_label(score)} \{score}")
  }
}
alice/json S 391.6139680824188
bob/http A 189.57132356978428
carol/csv A 189.57132356978428
dave/math C 59.00068800926255

bob/http と carol/csv はシグナルが同じなのでスコアも同じで、順序は名前で決まります。

スコアの内訳を説明する

基本スコアはシグナルごとの項の和で、リリース乗数がその和全体に掛かります。したがって各シグナルを単独で評価すれば、スコアを部分に分解できます。

test "explain a score" {
  let (deps, recent, downloads, days) = (20, 4, 300, 40)
  let from_deps = @score.compute_score(deps, 0, 0, days)
  let from_recent = @score.compute_score(0, recent, 0, days)
  let from_downloads = @score.compute_score(0, 0, downloads, days)
  let total = @score.compute_score(deps, recent, downloads, days)
  println("dependents \{from_deps}")
  println("recent     \{from_recent}")
  println("downloads  \{from_downloads}")
  println("total      \{total}")
  assert_true((from_deps + from_recent + from_downloads - total).abs() < 1.0e-9)
}
dependents 122.63336379149948
recent     46.06211305386395
downloads  133.08981137394377
total      301.7852882193072

各部分の和は末尾の丸めを除いて合計に一致します。そのためチェックでは == ではなく許容誤差を使っています。

リリースからの経過の影響を見る

activity_multiplier は最近のリリースを最大 12 % 優遇し、古いリリースを最大 12 % 減点します。

test "release age" {
  for days in [10, 60, 120, 300, 500] {
    println("\{days} \{@score.compute_score(20, 4, 300, days)}")
  }
}
10 318.8674743449284
60 301.7852882193072
120 284.703102093686
300 267.6209159680649
500 250.5387298424437

同じシグナルでも、最新リリースから 1 年未満ならランク S、それ以降はランク A です。

30 日間の成長を測る

compute_score_snapshot は 4 つのシグナルを、現在の値と 30 日前の値の 2 回受け取ります。30 日前に存在しなかったパッケージの過去のシグナルは 0 です。

test "new package snapshot" {
  let snapshot = @score.compute_score_snapshot(8, 2, 120, 12, 0, 0, 0, 0)
  println(snapshot.score_growth_ratio_30d)
  println(snapshot.rank_label)
  println(snapshot.momentum_label)
}
1
A
Hot

スコアは 00 から伸びたので、成長率は 11(100 %)と報告されます。Rising には最近の被依存パッケージが 3 つ以上必要で、このパッケージには 2 つしかないため、Rising ではなく Hot です。

スナップショットをほかのツールに渡す

ScoreSnapshot は Json(...) で JSON に変換できます。これは cli コマンド の出力そのものです。

test "snapshot as JSON" {
  let snapshot = @score.compute_score_snapshot(20, 4, 300, 40, 10, 2, 0, 10)
  println(Json(snapshot).stringify())
}
{"score":301.7852882193072,"score_30d_ago":135.2764584196223,"score_growth_30d":166.5088297996849,"score_growth_ratio_30d":1.2308780976744749,"rank_label":"S","momentum_label":"Rising","activity_multiplier":1.06}

@json.from_json で読み戻します。インポートに moonbitlang/core/json が必要です。

test "snapshot from JSON" {
  let snapshot = @score.compute_score_snapshot(20, 4, 300, 40, 10, 2, 0, 10)
  let back : @score.ScoreSnapshot = @json.from_json(Json(snapshot))
  inspect(back.rank_label, content="S")
  assert_eq(Json(back), Json(snapshot))
}

ScoreSnapshot は Eq を実装していないので、チェックでは JSON 形式を比較します。

さらに進んで

インデックスビルダーと同じようにシグナルを用意する。 関数は任意の整数を受け付けますが、Web アプリケーションのスコアは特定の定義に基づいています。dependents はこのパッケージに依存するバージョンを 1 つ以上持つパッケージの数、recent_dependents はそのうち最初の該当バージョンが 180 日以内のものの数で、過去のシグナルは 30 日前時点の同じ数をダウンロード数 0 で数えたものです。パイプライン全体は アーキテクチャガイド で説明しています。自分のスコアを公開されたスコアと比べるなら、同じ定義を使ってください。

ラベルとスコアをいっしょに保つ。 rank_label と compute_momentum_label はほかで計算したスコアにもラベルを付けられるよう別関数になっていますが、ラベルが数値と必ず一致するのは compute_score_snapshot を使った場合だけです。丸めた数値からラベルを計算し直すのではなく、スナップショットを保存してください。

境界でのオーバーフローを防ぐ。 被依存数が 2147483647 の実在パッケージはありませんが、シグナルが信頼できない入力から来るなら先に制限してください。ちょうど 2147483647 のカウントはスコアを NaN にし、ランクは D になります。

test "clamp untrusted counts" {
  let raw = 2147483647
  let safe = if raw > 1000000000 { 1000000000 } else { raw }
  assert_false(@score.compute_score(safe, 0, 0, 0).is_nan())
}

よくある落とし穴

  • 引数の取り違え。 シグナルはすべて Int なので、コンパイラーは downloads と dependents を区別できません。シグナル表 の順序を守ってください。
  • 日付ではなく日数。 days_since_release は経過日数(整数)で、タイムスタンプではありません。負の値は 0 として扱われ、最も高い乗数が付きます。
  • 新しいパッケージの成長率。 比率 1 は「倍になった」とも「何もないところから現れた」とも読めます。区別するには score_30d_ago == 0.0 を確認してください。
  • == での比較。 スコアは浮動小数点数なので、計算した和は許容誤差付きで比較してください。
  • モメンタムにはスナップショットの比率を使う。 compute_momentum_label は渡された比率をそのまま信用します。割合(0.35)の代わりにパーセント(35.0)を渡すと、少しでも伸びていれば比率の条件を満たしてしまいます。

次のステップ