Quick Match(集団レコメンド)
集まった N 人 (4-6 人) に対して「今からみんなで遊ぶならこれ」を出す枠。Base Scoring + Surface Strategy の集団推薦版で、Base Scoring (basescoring/) は触らず、Strategy 側で Aggregate + PreFilter + PostRerank を組み立てる形になっている。
0. レコメンドの概要
| 項目 | 内容 |
|---|---|
| 入力 | QuickMatchRequest{ GroupUserIDs, K, Candidates?, ScenarioCategoryIDs? } |
| 出力 | Recommendation{ Items, ModelVersion, RecommendationID } (全 Surface 共通)。各 Item に集約スコア + per-user scores + reasons が乗る |
| 本番経路 | mobile が quickMatchStart mutation を叩く → asynq quick_match:matchmake task → usecase.Matchmake → 4-6 人束ごとに Recommender.Recommend(QuickMatchRequest{...}) を呼ぶ (C 案統合で RecommendQuickMatch 専用 method は廃止、strategy-decisions 2026-04-28)。GraphQL からは直接叩かない |
| mobile 画面 | Quick Match 待機 / 投票画面。待機中に作った Room の quick_match_scenario_candidates を表示する |
| Admin Preview | adminQuickMatchScenarios (/recommendation/preview、複数 userID textarea、SuppressLogging=true) |
| 観測 | BigQuery recommendation_assignments に参加者分のレコードを同一 recommendationId で送る。quick_match_rooms.recommendation_id (UUID) で BQ assignment ↔ DB Room を join できる (observation) |
| 新 pipeline 投入状況 | ✅ prd 投入済 (asynq matchmake 経路、SuppressLogging=false) |
1. パイプライン全体像
他の Surface (ForYou / Similar 等) は PreFilter → Base Scoring → Aggregate → PostRerank を surfaces/<surface>.go の method で順に実行する素直な構成だが、Quick Match だけは Aggregate / 履歴ベース加減算 / 多様性 / ハード制約を 1 つの utility 関数で同時評価する統合 greedy strategy に再構築されている (backend/internal/usecase/recommendation/strategy/quick_match/)。Step を分けずに 1 ステップで全部を効かせている。
// strategy/quick_match.Recommend(ctx, deps, req)
func Recommend(ctx, deps, req QuickMatchRequest) (*Response, error) {
// ───── Step 1: PreFilter (人数適合 / 全員未プレイ / ブロック / 言語 / カテゴリ /
// 無料 / 90 分以内 / release only / 2 周目+ 専用キャラ除外) ─
candidateIDs := preFilterForQuickMatch(...)
// ───── Step 2: Base Scoring matrix (matrix[u][s] = <userVec_u, scenarioVec_s>) ─
matrix := scorer.ScoreMatrix(ctx, modelVersion, groupUserIDs, filteredScenarios)
// ───── Step 3: 履歴シグナル一括取得 ─
exposures := meta.RecentExposures(ctx, groupUserIDs, since) // V/P/N 派生用
interested := meta.UserInterestedScenarios(ctx, groupUserIDs) // F 派生用
// ───── Step 4: 統合 greedy で K 件選出 ─
items := selectTopK(...) // utility = Aggregate + V + F - P + 多様性 / N≥1 ハード制約
}selectTopK は K 件出すまで以下のループを回す:
utility(s, picked) =
α × mean(score(u, s) for u in G) + (1-α) × min(score(u, s) for u in G) // base 項 (Aggregate)
+ β × beginner_ratio(G) × is_beginner_tag(s) // 初心者加算
+ Σ_{u ∈ G} VotedBonusBase × exp(-λ × hours_since(u, s)) if (u,s) ∈ V
+ Σ_{u ∈ G} InterestedBonus if s ∈ Interested_u
- Σ_{u ∈ G} ExposurePenaltyBase × exp(-λ × hours_since(u, s)) if (u,s) ∈ P
- (1 - Lambda) × max_sim(s, picked) # MMR 多様性 (Lambda < 1 のとき)各 step で utility が最大の scenario を 1 つ取る。EnsureNeverProposedSlot=true のとき、「残り枠数 = 未充足のハード制約数」になった step では候補を N (never_proposed) カテゴリに絞り込む (= top-K のうち最低 1 枠を新規 scenario で埋める、§5 参照)。N が 1 件も無いヘビーユーザーでは自動的に緩めて、通常 utility 順で埋める。
§2 以降で utility の各項目を見ていく。
2. Aggregate (utility の base 項)
base 項は Least Misery 寄りの重み付き平均:
base = α × mean(score(u, s) for u in G) + (1-α) × min(score(u, s) for u in G)- α は mean / min の比重 (初期値 0.5、spec §6 D9)
- なぜ Least Misery (min) を主軸にするか、Average / Most Pleasure / Fairness を採らない理由は technology-choices §8
追加の文脈項(初心者配慮など)
「初心者がいるグループでは初心者向けシナリオを優遇したい」みたいな話は、if 分岐ではなく連続的な加算項で書く:
base += β × beginner_ratio(G) × is_beginner_tag(s)beginner_ratio(G) = |{u ∈ G | u.PlayedCount < N}| / |G|— 値域 0〜1is_beginner_tag(s) = 1 if ScenarioTag.ID=25 else 0- 初心者が 0 人なら加算項も 0 になり、通常の Quick Match に自然に退化する
TIP
「if 初心者がいる then 別式」みたいな場合分けは避ける。閾値の位置に意味が出てしまうし、境界付近で推薦結果がジャンプしてテストも分岐分だけ増える。base 項は常に連続関数で書くのが原則。
同じ「初心者配慮」でも、以下は base 項ではなく PreFilter 側に置く:
- ハード除外 (
min(PlayedCount) < 3 かつ 高難度を完全除外 → §4 参照) - 強制枠 (上位 N 件に初心者向けを必ず K 件含める等のビジネス制約 → 多様性制約)
3. PreFilter: Quick Match 特有のハード制約
集約スコアだけではどうにもならない条件は、PreFilter で最初に弾く:
| 制約 | 理由 | 強度 |
|---|---|---|
| プレイ人数適合 | 集まった人数に合うシナリオだけ通す (character_count == partySize) | ハード |
| 全員未プレイ | 誰か 1 人でもプレイ済みなら即除外。マダミスはネタバレが致命的 (プレイ済み判定は user_scenario_status.PLAYED を ScenarioSet 単位で展開、spec §3.4) | ハード(最優先) |
| ブロック関係 | 参加者の誰かがブロックしている scenario / 作者の作品を除外 | ハード |
| 無料 | price == 0 のものだけ (Quick Match は「いま無料で遊べる」UX に固定) | ハード |
| 90 分以内 | 「今から遊ぶ」前提なので長尺は外す (play_minutes <= 90) | ハード |
| release only | 公開状態 (released_at IS NOT NULL) のものだけ | ハード |
| 2 周目+ 専用キャラ除外 | PlayCountRestriction=ONLY_SECOND_PLAY のキャラを 1 つでも含む scenario は除外 (Quick Match は初見前提なので、2 周目限定キャラがいると体験が破綻する) | ハード |
| 言語・地域 | 参加者全員の locale に合うもの | ハード |
| ジャンル一致 | 参加者全員が許容するジャンル (scenario_category) の積集合に含まれるシナリオだけ。待機画面で指定した quick_match_waiting_user_category_filters を mobile → recommender で QuickMatchRequest.ScenarioCategoryIDs に積集合マージして渡す (空集合は「絞り込みなし」扱い) | ハード |
| 難易度バランス | 初心者混在なら難解本格を避ける | ソフト+ハード(下記 §4 で詳述) |
WARNING
「全員未プレイ」は集約スコアでは絶対に守れない (誰か 1 人のスコアがどれだけ高くても、その人がプレイ済みなら集団体験として成立しない)。PreFilter として候補生成の最初に適用する。
4. 難易度バランスの実装
「初心者混在なら難解本格を避ける」を具体化する。UZU にすでにあるシグナルだけで組む。
使えるシグナル
| 軸 | シグナル | 備考 |
|---|---|---|
| ユーザー経験量 | User.PlayedCount | 数値。既存カラム |
| 初心者フラグ | User.BeginnerEvent | bool。既存カラム |
| シナリオの初心者向け | ScenarioTag.ID = 25 | 既存。beginners2.go で使用中 |
| シナリオ難易度(派生) | derived_difficulty(s) | タグ 25 が付かない scenario は、データから導出 (下記) |
シナリオ難易度の導出
「難解本格」みたいな明示タグは UZU に無いので、データから派生させる:
derived_difficulty(s) = f(
avg(User.PlayedCount | プレイ且つ Excellent 評価), # 経験者が評価する傾向
PlayTime, # 長尺は総じて難易度高
キャラクター数, # 多い = 複雑
preplay_content 要否 # 事前読み物必須 = 高難度
)最初は「経験者プレイ比率」だけでも十分効く (データ駆動で手堅い)。
3 段構えの補正メカニズム
Aggregate(自然な Least Misery)
quick_match_scoreのmin(score(u, s))が、初心者にとって低スコアな難解シナリオを自動でペナルティ化してくれる- Base Scoring がちゃんと学習されていれば、これだけでだいたい捕まる
- 弱点: 新規ユーザー (
PlayedCount=0) は Base Scoring が不安定。内容ベースサブモデルに頼ることになり精度が落ちる
PostRerank ソフト補正(ペナルティ加算)
final_score = quick_match_score - λ × max(0, derived_difficulty(s) - group_min_experience)group_min_experience = min(u.PlayedCount for u in G)- 経験量と難易度のギャップに比例したペナルティ。λ はチューニング対象
PreFilter ハードフィルタ(安全網)
min(u.PlayedCount for u in G) < 3かつderived_difficulty(s) ≥ 高の組合せは候補から除外- Base Scoring が未熟なコールドスタート期でも破綻しないための下限ルール
- 「誰か 1 人が全くの初心者なのに難解本格を推す」みたいな致命的ミスを防ぐ
実装順序の目安
- P1 初期: ハードフィルタ (3) だけで開始。シンプル
- P2: ペナルティ加算 (2) を入れる。λ は A/B で調整
- P3: Base Scoring の精度が上がれば (2) を弱め、Least Misery (1) の比重を上げる
5. 履歴ベース加減算 (V / F / P) と N≥1 ハード制約
「同じ scenario が出続ける」「投票で『やりたい』と意思表示したのに次回浮上しない」「興味ありに入れた scenario が拾われない」「常に既知のシナリオばかりで未知の発見が無い」といった体験課題を、1 つの utility 関数 + ハード制約でまとめて解く。
exposure 履歴の 3 カテゴリ (V / P / N)
(user, scenario) ペアを quick_match_participant.voted_scenario_id の有無で分類する:
| カテゴリ | 定義 | 効果 |
|---|---|---|
| V (voted) | 過去 quick_match でこの user が voted_scenario_id にこの scenario を選んだ | +VotedBonusBase × decay で加点 |
| P (proposed_not_voted) | 過去 quick_match で提示はされたが、別の scenario に投票 / 棄権 | -ExposurePenaltyBase × decay で減点 |
| N (never_proposed) | 過去 quick_match で 1 度も提示されていない | 加減算なし。N≥1 ハード制約の対象 |
集団推薦の集約ルールは「1 人でも V がいれば V」「全員 P 以下なら P」「全員 N なら N」という多数決寄り。「やりたい」と意思表示した参加者がいる scenario の優先度を最大限尊重する設計。
decay = exp(-λ × hours_since_last_expose(u, s))、λ = ln(20) / ExposurePenaltyDecayHours。ExposurePenaltyDecayHours 時間で base の約 1/20 まで落ちる。未来時刻の exposedAt (時計ずれ / fixture ミス) は差分 0 として扱い、絶対値が > base で暴発するのを防ぐ。
F (interested) ボーナス
提示履歴とは別に、現在 UserScenarioStatus.status=interested の scenario に参加者ごとに +InterestedBonus を加える (時間減衰なし)。「気になる」と能動的に意思表示した scenario を、単に未提示なだけの scenario と同列に扱わないための補正。
参加者全員が興味ありに入れていれば InterestedBonus × |GroupUserIDs| まで効く。
N≥1 ハード制約 (EnsureNeverProposedSlot)
V / F の加点だけだと「過去出た scenario からしか選ばれない」傾向になって発見性が落ちる。そこで EnsureNeverProposedSlot=true のとき top-K の最低 1 枠を N (never_proposed) で確保する:
- greedy の各 step で
残り枠 == 未充足ハード制約数になったら、候補を N に絞って選ぶ - 候補に N が 1 件も無いヘビーユーザーでは自動的に緩めて、通常 utility 順で埋める (= 候補枯渇時はソフト退化)
観測性 (assignment log)
QuickMatchItem.Reasons に各 scenario の構成シグナルを詰めて BigQuery assignment log に流す:
exposure_status:voted/proposed_not_voted/never_proposedのどれかfavorite:true(1 人以上が興味ありにしていれば付く)
これで「V/P/N の構成比」「F 適用率」「N=0 で緩和されたユーザー比率」が観測できる。
データソース
既存の quick_match_scenario_candidate + quick_match_participant.voted_scenario_id + user_scenario_status を使い回す。新規テーブルは作らない。
exposure: user → quick_match_waiting_user → quick_match_participant → quick_match_room → quick_match_scenario_candidate
voted: quick_match_participant.voted_scenario_id
interested: user_scenario_status WHERE status='INTERESTED'BigQuery を使わない理由は recommend が同期処理だから。BQ のストリーミング挿入には遅延があり、「直前に提示したばかり」の exposure を弾けない (= 加減算が効かずハズレを引く)。PostgreSQL 直読みで同一トランザクション境界の整合性を取る。
QuickMatchMetaStore.RecentExposures は (user_id, scenario_id) ペアごとに最新 1 件に集約して、Voted フラグも付けて返す契約になっている。同一ペアで「最新は voted=false だけど過去に voted=true」のケースは最新の Voted を採用する (古い投票履歴は decay で薄まる前提)。
パラメータ (QuickMatchConfig)
dev DB の観測値は aggregate score の実レンジが 20〜25、上位群の差は 0.1〜2.0 程度。これに合わせて係数を強めにスケールしている (旧 0.3 系の値だと aggregate top 群の差を覆せず、加減算がほぼ効かなかった)。
| フィールド | 意味 | default |
|---|---|---|
VotedBonusBase | V (voted) ボーナスの最大加点幅。aggregate top 群の差を超えて「投票した未プレイはほぼ top」を狙う | 5.0 |
InterestedBonus | F (interested) ボーナスの加点 (時間減衰なし)。「興味ありなら 1〜2 ランク上がる」狙い | 2.0 |
ExposurePenaltyBase | P (proposed_not_voted) ペナルティの最大減点幅。「直近に提示したが投票されなかったら下位」 | 3.0 |
ExposurePenaltyDecayHours | V/P の指数減衰の時定数 (時間)。この時間で base の約 1/20 | 48.0 |
ExposureLookbackHours | DB から取得する履歴の最大古さ。これより古い exposure は加減算に含めない | 72.0 |
EnsureNeverProposedSlot | true で「top-K に最低 1 枠は N (never_proposed)」のハード制約を有効化 | true |
decay/lookback は「1 日目が一番強くて、2〜3 日で徐々に減る」curve を狙って 48h / 72h に置いている。48h 減衰 + 72h lookback で 24h 前 ≒ 22% base、48h 前 ≒ 5% base、72h 前 ≒ 1% base で cutoff。
各係数を 0 にすればその項は完全 OFF (V/F は 0、P は 0、N≥1 は false)。ExposurePenaltyDecayHours <= 0 か ExposureLookbackHours <= 0 のときは exposure 由来の加減算 (V/P) を全部止める (favorite は別系統なので生き残る)。
ロールアウト
- Stage 1 (現行): 上の default で本番運用中。dev AlloyDB で aggregate 実レンジを観測した結果から、旧
0.3系より係数を引き上げて実効力を確保した - Stage 2 (必要なら): 「P (提示済み非投票) を絶対に出さない」が要件として確定したら、
preFilterForQuickMatch側で P を hard exclusion に追加する。マッチング成立率が落ちるリスクがあるので、実測してから判断 - Stage 3: A/B で dwell time / vote 完了率を見ながら base / decay / lookback / EnsureNeverProposedSlot をチューニング