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 应用的 top feed 所用的顺序。

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

同样的信号在最新发布不足一年时为等级 S,之后为等级 A。

衡量 30 天内的增长

compute_score_snapshot 两次接收四个信号:一次是现在的,一次是 30 天前的。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 %)。该包是 Hot 而不是 Rising,因为 Rising 至少需要三个近期依赖方,而它只有两个。

把快照交给其他工具

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 统计至少有一个版本依赖本包的包,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 信任你传入的比值。传入百分数(35.0)而不是小数(0.35)会使比值条件对任何实际增长都成立。

下一步