評価対象 (subject) のランキングは「少票で 90 点台に乗せない」ことが唯一かつ最大の設計目標である。 1 票の 😍 が満票 30 票の熱狂と同列に並ぶと、リーダーボードは信用を失う。@event/feedback の ranking.ts は IMDb 型の加重レーティング (真の平均を票数で会場ベースラインへ縮約) を運用目標に校正した 純関数群で、UI も I/O も持たない。中核は rankSubjects (全体) / subjectScore (1 対象) / rankingContext (基準値の導出) の 3 つ。ivs-cms の登壇者評価から speaker→subject / session→unit に一般化した版である。
RatingScale の 2 系統 — values と scores は別物
RatingScale は 1 つの評価キー (例 great) に 2 系統の数値を持たせる。ここを混同すると校正が崩れる。
| 系統 | 用途 | 既定 (DEFAULT_FACE_SCALE) |
|---|---|---|
values | 0–100 格子上の「票の値」。ランキング計算 (quality / 会場平均) の唯一の入力 | 😍 great=100 / 😊 good=50 (中立) / 😐 ok=0 |
scores | 表示用スコア。平均表示 (rawAvg / globalMean) にだけ使う | great=3 / good=2 / ok=1 |
values は「😊=50 を中立、😐=0 をマイナス評価」とする 0–100 スケールで、score 計算は全部こちらを読む。 scores は会場向けに「平均 2.7 点」と出すための表示専用で、ランキング順位には一切効かない。スケールを 差し替えれば段階数も自由 (scale.keys の best→worst 順、😍 = values 最大キー、😐 = 最小キー)。
基本式 — 縮約と貸し付けの二項
subjectScore が返す最終スコアは 3 項の合成である (NEUTRAL = 50 中立を起点に、質と会場基準を票数で乗せる):
score = 50 + (quality − 50) × w(v) + (baseline − 50) × b(v)
第 2 項が「その対象の質を票数で信じる度合い」、第 3 項が「甘い会場ぶんの下駄を票数に応じて貸す」項。 どちらも票数 v が小さいほど 0 に潰れ、score は中立 50 へ縮約される (= IMDb の v/(v+m) 型)。
実定数 (2026-07 確定版)
| 定数 | 値 | 役割 |
|---|---|---|
NEUTRAL | 50 | 中立値。全員 😊 なら quality はここに一致 |
GREAT_EXP | 1.5 | 😍率プレミアムの指数。quality の 😍 項を p😍^1.5 にし、😊 で薄まった 😍率ほど超線形に下がる |
OK_PENALTY | 30 | 😐 の追加減点。quality から √p😐 × 30 を引く (1 件でも明確に効き、以降は逓減の凹型) |
VOTES_M | 9 | 質の信頼係数 w(v)=v/(v+9) の半減点。1 票で w=0.1 → 中身で ±5 だけ動く |
BASE_RATE | 0.35 | 会場基準の貸し付け率の上限 |
BASE_M | 5 | 貸し付けの立ち上がり。b(v)=0.35×(v−1)/(v+5) → 1 票では 0 |
BASELINE_NEUTRAL_MIX | 0.35 | 会場平均を中立側へ引き戻す率。baseline = 50 + (会場平均−50)×(1−0.35) |
quality — 加重平均 + 😍プレミアム − 😐ペナルティ
quality は票の values 加重平均に、😍率の凸プレミアムと 😐率の凹ペナルティを重ねて 0–100 に clamp する:
quality = mean + 50 × (p😍^GREAT_EXP − p😍) − OK_PENALTY × √p😐
= mean + 50 × (p😍^1.5 − p😍) − 30 × √p😐
mean は values の加重平均 (100·p😍 + 50·p😊 + 0·p😐)。純ケース (😍のみ / 😊のみ / 😐のみ) では プレミアム項が 0 になるので校正アンカーは不変。混在時だけ「😊 で薄まった 😍率」が p^1.5 で超線形に沈み、 「😍率95%の21票」が「😍率85%の33票」に票数だけでは抜かれない。
baseline — 会場の甘辛を保守化した基準値
rankingContext(tallies, scale) が母集団全体の values 加重平均 (= 会場平均 = この回の 😍率の甘辛) を出し、 中立 50 へ BASELINE_NEUTRAL_MIX ぶん引き戻して { baseline } を返す。会場平均 83 の甘い回でも基準値は 50 + (83−50)×0.65 ≈ 71.5 に抑えられ、貸し付け項が過剰に効かない。rankSubjects は全対象の tally から この ctx を 1 度だけ導出し、各 subjectScore に渡す (全体 / unit 内で母集団を切り替えれば相対評価になる)。
校正アンカー (会場平均 ~83 のとき)
| 票構成 | 1票 | 2票 | 3票 | 6票 | 10票 | 20票 | 30票 | 50票 |
|---|---|---|---|---|---|---|---|---|
| 😍 のみ | 55 | 60 | 64 | 74 | 81 | 90 | 95 | 98 |
| 1票の内訳 | 😍→55 / 😊→50 / 😐→45 |
1 票は中身で ±5 しか動かず (w=0.1 かつ貸し付け 0)、90 点台は大票数の熱狂だけが到達する。
使い方
import { rankSubjects, subjectScore, rankingContext, VOTES_M } from "@event/feedback";
import type { FeedbackResponse, RankingResult } from "@event/feedback";
// 全 unit 横断でランク付け (minVotes 未満は除外。既定 1)。
const result: RankingResult = rankSubjects(responses, { minVotes: 3 });
result.subjects.forEach((s) => {
// rank / score(0–100) / quality / confidence(0–1) / votes / counts / units
console.log(s.rank, s.subjectId, s.score, `(${s.votes}票)`);
});
result.baselineQuality; // 実データ由来の基準値 / result.meanVotes / globalMean
// 1 対象だけを試算 (プレビュー): ctx を渡さなければ中立基準 (baseline=50)。
const ctx = rankingContext([{ great: 20, good: 3, ok: 1 }]);
subjectScore({ great: 20, good: 3, ok: 1 }, ctx).score; // → 0–100
rankSubjects の戻り RankingResult は subjects (score 降順・rank 採番済) に加え globalMean (scores 平均) / totalVotes / totalResponses / baselineQuality / meanVotes を持つ。同点は 票数 → 😍 (top key) 数の順で割る。各 SubjectRanking の counts は scale.keys 全キーを 0 埋めで 返すので、UI の分布バーはそのまま描ける。全て pure なので server component でも client でも同じ結果になる。 配線手順は recipe add-surveys 参照。