Skip to content

レコメンデーション仕様 ​

UZU バックエンドにおけるレコメンデーション機能の仕様本体。何を作るか・どう動かすかを記述する。技術選定の経緯・比較検討・採用しなかった代替案の議論は technology-choices 参照。

INFO

コード詳細は backend/internal/usecase/recommendation/ 配下を参照。

1. 現状の実装サマリ ​

UZU バックエンド(Go / Ent / gqlgen)に存在するレコメンデーション機能は、ルールベースのスコアリングと軽度な協調フィルタリングの組み合わせで構成されている。

1.1 シナリオランキング(人気順スコア) ​

  • 実装: backend/internal/usecase/update_ranking_rate.go
  • スコア式(概念):
score = ratingFactor × playCountFactor × daysBonus × priceBonus × playTimeFactor
  • 対象期間: 直近 21 日のイベント・ソロプレイ
  • 更新頻度: 日次バッチ(21:00) — backend/internal/jobs/nightly_batch_process/main.go から UpdateScenariosRantingRate() を呼び出し

1.2 シナリオスクリーンのセクション推薦 ​

  • 実装: backend/internal/usecase/scenarioscreen/
  • セクション種別: 初心者向け・新着(無料/有料)・ランキング・招募中・友人お気に入り・プレイ済み 等
  • 実体は Ent の Where/Order を組み合わせたクエリ群(学習モデルではない)

1.3 ユーザー嗜好プロファイル ​

  • 実装: backend/internal/adapters/graph/schema/user_recommendation_profile_query.gql
  • タグごとのスコア・プレイ回数・有料プレイ数・公開イベント参加数を保持
  • 現状は管理画面表示用途で、推薦そのものには組み込まれていない

1.4 未実装 ​

  • 行動ログ基盤(impression / click / detail view 等の計測)
  • A/B テスト・CTR / CVR 計測
  • ML モデル・Embedding
  • イベント・作者・コレクション・ユーザー(友達)推薦
  • セッション内リアルタイム化
  • コンテキスト(日時・人数・予算・地域)による動的絞り込み

2. UZU バックエンドの世界 ​

2.1 推薦対象になりうるエンティティ ​

エンティティ位置付け現状
scenario作品(核)実装あり
eventマルチプレイ回(募集中・開催予定)未実装
solo_eventsソロプレイ回未実装
author作者未実装
scenario_collectionシリーズ・公式まとめ未実装
user他プレイヤー(友達候補)未実装
additional_content追加コンテンツ(DLC 的)未実装

2.2 ユーザー行動シグナル(既に DB にあるもの) ​

テーブルシグナルの意味
participant / solo_eventsプレイ履歴(implicit feedback)
participant_locale_evaluation評価(Excellent / Good / Poor)
review / favorite_reviewレビューおよびレビューへの共感
user_scenario_statusプレイ済み・気になる 等のステータス
user_subscribe_author作者フォロー
user_subscribe_scenario_collectionコレクション購読
user_relationshipユーザー同士の関係
tipping投げ銭(非常に強い positive signal)
chatroom_user / chatroom_event共参加の共起グラフ

TIP

現状のランキング式では tipping や favorite_review が未活用である。これらは「お金や手間をかけてまで支持している」という強いシグナルなので、学習データに組み込む価値が高い。

3. 推薦パイプラインのアーキテクチャ ​

UZU の推薦は Base Scoring + Surface Strategy の 2 層構造 で、Surface Strategy が業界標準の Multi-stage pipeline (Retrieval → Ranking → Re-ranking) を Surface ごとに組み立てる (設計経緯は strategy-decisions 2026-04-23)。

┌──────────────────────────────────────────────────────────┐
│ Base Scoring (全 Surface 共通)                           │
│   学習済みモデル (iALS 内積)                              │
│   user × scenario のスコア行列を返す                     │
└──────────────────────────────────────────────────────────┘
                         │
                         ▼ (matrix[viewer][scenario])
┌──────────────────────────────────────────────────────────┐
│ Surface Strategy (Surface ごとに 1 struct)               │
│  ┌────────────────────────────────────────────────────┐  │
│  │ PreFilter  — Retrieval / Filtering                 │  │
│  │   ハード制約で候補を絞る                           │  │
│  ├────────────────────────────────────────────────────┤  │
│  │ Aggregate  — Ranking                               │  │
│  │   Surface 固有の集約式で最終スコアを計算           │  │
│  ├────────────────────────────────────────────────────┤  │
│  │ PostRerank — Re-ranking                            │  │
│  │   ソフト調整・多様性確保                           │  │
│  └────────────────────────────────────────────────────┘  │
└──────────────────────────────────────────────────────────┘

3.1 2 層 + 3 段階の役割 ​

区分数性質業界用語
Base Scoring抽象として 1 本 (user × scenario)user × scenario のスコアを返す学習モデル群Base Scoring / Pre-ranking
Strategy.PreFilterSurface ごとに 1 実装ハード制約で候補宇宙を絞るRetrieval / Candidate Pruning
Strategy.AggregateSurface ごとに 1 実装Base Score を Surface 固有の式で集約Ranking / Contextual Scoring
Strategy.PostRerankSurface ごとに 1 実装ソフト調整・多様性確保Re-ranking / Post-processing

UZU 実装名 ↔ 業界標準語の対応

UZU の実装名 (Base Scoring / Strategy.PreFilter / Aggregate / PostRerank) は、recsys の業界標準語彙 (Retrieval → Ranking → Re-ranking + Base/embedding model) に 1:1 で対応する。外部の recsys 人材・コーディング AI が読み替えられるよう、対応表を橋として残す:

UZU 実装名業界標準語実装上の配置
Base ScoringBase Scoring / Pre-rankingbasescoring/ パッケージ (全 Surface 共通)
Strategy.PreFilterRetrieval / Candidate Pruning / Filteringsurfaces/<surface>.go の PreFilter method
Strategy.AggregateRanking / Contextual Scoringsurfaces/<surface>.go の Aggregate method
Strategy.PostRerankRe-ranking / Post-processingsurfaces/<surface>.go の PostRerank method

本 spec 初版の「3 層モデル / L1・L2・L3」呼称は廃止した。実行順 (PreFilter → Base Scoring → Aggregate → PostRerank) と番号が逆で誤解を生むのが理由。外部資料との突き合わせ用にこの注記にのみ旧称を残す。

3.2 Base Scoring ​

Base Scoring は「user × scenario の関係をスコア化する抽象」であり、全 Surface 共通。Aggregate から見えるのは スカラーのスコアのみで、内部の user / scenario embedding ベクトルは外に出さない。

score(u, s) : (User, Scenario) → R   # スカラー
  • 内部は行動ベース(iALS)+ 内容ベース(content embedding)の複数サブモデルで構成しうるが、外部契約は score(u, s) → float 1 本に固定する
  • この契約を固定することで、内部実装(iALS / LightFM / content-embedding / hybrid 等)を入れ替えても Aggregate / PreFilter / PostRerank / resolver は書き換え不要になる

コアモデルの内部実装(シグナル重み・negative confidence・サブモデル統合・学習・Cold-start・実装インフラ・学習データ)は Base Scoring(コアモデル) に分離した。本節は Aggregate / PreFilter / PostRerank から見える外部契約のみを定義する。

3.3 Aggregate (Ranking): Derived Scores ​

Base Scoring のスコアを集約するだけなので 新機能は式の追加で実現できる。合成式は以下 3 パターンの使い分けを標準とし、追加時もこのパターンに沿って設計する。

集約関数の使い分け ​

関数意味適用原則
maxエンティティを「最良要素」で代表する構成要素が少数かつ独立に評価されるもの(event)
meanエンティティを「全体通し体験」で代表する構成要素が少数で、セットとして消費されるもの(collection)
topK_mean (K=5)外れ値に頑健な代表値構成要素が多く品質にばらつきがあるもの(author)

標準合成式 ​

推薦対象合成式
event_scoremax(score(u, s) for s in event.scenarios) × time_proximity
author_scoretopK_mean(score(u, s) for s in author.scenarios, k=5)
collection_scoremean(score(u, s) for s in collection.scenarios)
additional_content_score親シナリオの user × scenario スコアをそのまま利用
quick_match_score集合 G に対して α × mean + (1-α) × min の Least Misery 重み付き平均(集約は group 次元)
friend_score別軸: user × user の共参加・共通高評価で独立に算出

Context Multiplier(時間・距離等)の扱い ​

  • 乗算(ハード減衰): base × factor。factor=0 で完全に消える。期限切れ・非対応言語など「落とすべき」文脈
    • 例: event_score の time_proximity
  • 加算(ソフト調整): α × base + β × context。緩やかに順位調整
    • 例: 新作ボーナス、地域ブースト

ハードな条件は乗算、ソフトな順位調整は加算、が原則。ハードフィルタは PreFilter に寄せる(Aggregate で乗算→0 にするより、PreFilter で先に除外した方が計算効率が良い)。

WARNING

friend_score は user × scenario では表現できないため、Base Scoring にもう 1 本モデルが必要になる。Base Scoring を増やす判断基準は「既存の合成で表現できない新しいシグナル次元が必要になった時」に限定する。

3.4 PreFilter / PostRerank: Context Gate ​

学習も合成もしない、純粋なルールベースの制約層。Context Gate は 2 フェーズに分かれており、Base Scoring の前後に配置される。

Context Gate の 2 フェーズ ​

全シナリオ宇宙 (16.8K)
    │
    ▼ PreFilter(候補生成 / Retrieval)  ← フェーズ 1
候補集合 (数百〜数千件)
    │
    ▼ Base Scoring: ScoreMatrix / ScoreBatch
スコア付き候補
    │
    ▼ Aggregate(集約 / Ranking: max / mean / topK_mean / Least Misery 等)
集約スコア付き候補
    │
    ▼ PostRerank(Post-filter / Re-ranking)  ← フェーズ 2
top-K 最終結果
フェーズ役割典型的な処理
Pre-filter(候補生成)ハード制約で宇宙を絞るプレイ済み除外、言語・人数適合、開催予定、ネタバレ回避、GM 有無
Post-filter / Rerankスコア確定後の調整ソフトなデブースト(難易度ペナルティ)、多様性確保(MMR)、ビジネスブースト

2 フェーズに分ける根拠(効率・正確性・surface 別候補空間)は technology-choices §7 参照。

Surface 別の候補生成ロジック ​

SurfacePre-filter で絞る条件
ForYouプレイ済み除外、言語適合(UZU 規模なら省略して全件スコアも可)
Event開催予定の event が紐付ける scenarios
Author対象 author に属する scenarios
Collection対象 collection に属する scenarios
QuickMatch人数適合 ∧ 全員未プレイ ∧ 言語適合 ∧ GM 条件 ∧ 2 周目+ 専用キャラ非含有
SimilarScenario同カテゴリ or 同タグを持つ scenarios(絞り込みで高速化)

「プレイ済み除外」のソース ​

ネタバレ回避目的のプレイ済み除外 (ForYou / QuickMatch 共通) は user_scenario_status.status = 'PLAYED' を単一ソースとする。実装は usecase.Usecase.PlayedScenarioIDs (backend/internal/usecase/played_scenario_ids.go)。

  • event.finished_at / solo_event.finished_at はここでは使わない。理由: 進行中のまま放置されたマルチイベントや canceled_at 入りのレコードはプレイ済みシグナルを漏らす。user_scenario_status は event 開始時の自動マーク + ユーザー手動マークの両方を集約しており、ネタバレ済みの網羅性が最も高い。
  • 除外単位は ScenarioSet。user_scenario_status は scenario_set_id を持つため、ScenarioSet 配下の全 scenario (再演・翻訳版を含む) を展開して一括除外する。
  • WATCHED は現状プレイ済み除外の対象にしない (別 issue で扱う)。
  • base-scoring §4.3 の「プレイ完了定義 (30 分閾値)」は集計・分析用途の別軸の定義で、本フィルタとは別ソース。

4. 強化ロードマップ ​

Phase内容目的
P1行動ログ基盤の整備(impression / click / CVR)効果測定なしに前進できない。Base Scoring 導入効果を検証する前提条件
P1Base Scoring: user × scenario の iALS + negative confidence 導入現行の人気順(非パーソナライズ)を個人化へ置き換え。Poor を負の confidence として学習(base-scoring §1)
P2BPR / LogMF をオフライン評価iALS との NDCG@K 比較。有意差で勝てば切替検討
P2Aggregate 展開(event / author / collection)既存 Base Scoring を流用して推薦対象を横展開
P2Aggregate: Quick Match(集団レコメンド)集まった N 人にシナリオを推薦する新機能。Least Misery 集約 + PreFilter の全員未プレイ制約
P2Base Scoring に内容ベースサブモデル追加(コンテンツ Embedding)新作シナリオで行動ベースが効かない問題を解消(Item cold-start、base-scoring §2.3)
P2User cold-start 対応(segment q̄ 事前計算 + fold-in)新規ユーザーで q_u が学習できない問題を解消(base-scoring §2.2)。ranking_rate の代替基盤
P3PostRerank の多様性・再ランキング強化「同じような本格系ばかり」の体感を改善
P3A/B テスト・バンディット基盤アルゴリズムの自動最適化
P4friend 推薦用の Base Scoring 追加(user × user)ソーシャル軸の本格推薦

5. 設計上の原則 ​

  • Base Scoring の抽象を増やさない: Base Scoring は関係性(user × scenario 等)ごとの抽象として数える。同一抽象内にサブモデル(行動ベース・内容ベース等)を足すのは OK だが、新しい関係性を持ち出す前に Aggregate の合成で表現できないか検討する
  • Context Gate (PreFilter / PostRerank) はロジックを持たない: スコアは作らず、フィルタと並び替えのみ
  • 計測を最優先: ログ基盤(Phase 1)なしに他の改善を進めない
  • 既存シグナルを活用: tipping / favorite_review / user_recommendation_profile など、DB にあるが活用されていないシグナルを優先的に入力へ組み込む
  • ranking_rate は Base Scoring 完成後に廃止: 統計要素は ALS の scenario bias b_s に自動吸収される。廃止後の非パーソナライズランキングは base-scoring §2.2 の scenario-only スコア(Aggregate)で代替する

6. 未決定事項(Open Decisions) ​

実装着手前のチェックリスト。base-scoring §3 で基盤選定(iALS / BigQuery / GKE CronJob / AlloyDB pgvector / Go in-memory 推論)は確定済みなので、ここでは残りの合意事項を列挙する。各項目は合意され次第、本ドキュメントを更新する。

6.1 P1 着手前に決めるべき(必須合意) ​

P1 着手前の必須合意事項はすべて解消済み。残る事項は以下の「P1 実装中に決めれば良い」および「P2 以降」に分類される。

6.2 P1 実装中に決めれば良い(初期値仮置き可) ​

#項目仮置き
D8全シグナルの重みテーブル(Poor 含む)仮値: tipping=+50 / Excellent=+30 / プレイ=+20 / 気になる=+15 / favorite_review=+10 / 閲覧=+3 / Poor=-30(base-scoring §4.2 の confidence 値、α=10 相当)。オフライン NDCG@K とオンライン KPI を見ながら検証で調整
D9Quick Match の α(mean vs min 比重)0.5
D10初心者判定 N(PlayedCount < N)3
D11初心者ブースト β未定
D12難易度ペナルティ λ未定
D13Item cold-start 閾値20 プレイ
D14GraphQL スキーマ命名・設計既存 similarScenarios との整合含む

6.3 P2 以降で決めて良い ​

#項目
D16内容ベースサブモデルの embedding 技術(OpenAI / Cohere / Sentence Transformers / 自前)
D17新作ボーナス・地域ブーストの幅
D18Explore / Exploit の多様性比率(MMR の λ)
D19A/B テスト基盤の技術選定(GrowthBook / 自前)
D20モデル drift 監視・可観測性の実装範囲
D21LLM embedding コスト予算(base-scoring §2.3 / Item cold-start 導入時)
D22ranking_rate 併走期間とロールバック経路(base-scoring §2.4 の移行マッピングは定義済み、実運用での併走期間と段階的切替の計画。P1 では旧実装を残したまま新実装を並列導入 → P2 で切替・廃止判断)

7. API 設計とレイヤー境界 ​

Base Scoring と Surface Strategy はすべて同一 Go プロセス内の内部階層であり、関数呼び出しで連鎖する。GraphQL / gRPC はその境界層で、Strategy.PostRerank の出力だけを外部に公開する。Base Scoring の内積スコアや Strategy.Aggregate の中間合成値は外部に露出しない。

7.1 レイヤー境界 ​

┌───────────────────────────────────────────────────┐
│ Client (Mobile / Web / Admin)                     │
└──────────────┬────────────────────────────────────┘
               │ GraphQL / gRPC
               ▼
┌───────────────────────────────────────────────────┐
│ Go Backend (同一プロセス)                          │
│  ┌─────────────────────────────────────────────┐  │
│  │ adapters/graph: resolver                    │  │
│  │   adminForYouScenarios (FOR_YOU)                 │  │
│  │   adminQuickMatchScenarios (QuickMatch)     │  │
│  │   adminSimilarScenarios (SIMILAR)           │  │
│  └──────┬──────────────────────────────────────┘  │
│         ▼ 関数呼び出し                             │
│  ┌─────────────────────────────────────────────┐  │
│  │ usecase/recommendation/pipeline              │  │
│  │   Recommender.Recommend(ctx, SurfaceRequest) │  │
│  │     (全 Surface を 1 入口に統一)             │  │
│  │      ↓ registry.Build(req) で Strategy new   │  │
│  │  ┌─────────────────────────────────────────┐ │  │
│  │  │ surfaces/ (Surface ごとに Strategy 1 個)│ │  │
│  │  │   PostRerank (Re-ranking)               │ │  │
│  │  │    ↑                                    │ │  │
│  │  │   Aggregate (Ranking)                   │ │  │
│  │  │    ↑                                    │ │  │
│  │  │   Base Scoring (basescoring、共有)      │ │  │
│  │  │    ↑                                    │ │  │
│  │  │   PreFilter (Retrieval / Filtering)     │ │  │
│  │  └─────────────────────────────────────────┘ │  │
│  └─────────────────────────────────────────────┘  │
└───────────────────────────────────────────────────┘

原則:

  • Base Scoring はベクトルを外に出さない: score(u, s) → float の契約だけ公開(§3.2 参照)
  • Strategy.Aggregate は中間スコアを外に出さない: 最終スコア配列を PostRerank に渡すのみ
  • GraphQL には Strategy.PostRerank の出力だけが見える: 推薦結果 + 説明理由(reasons)
  • 将来 Base Scoring / 他 Surface Strategy の実装を差し替えても resolver / クライアント側は変更不要

7.2 Go 内部のモジュール構成 ​

§3 の Base Scoring + Surface Strategy アーキテクチャは 共通 Core (basescoring/) + Surface 別 Strategy (surfaces/) として実装される。Strategy 内部で業界標準の Retrieval → Ranking → Re-ranking の 3 段階が PreFilter / Aggregate / PostRerank メソッドとして組み立てられる (詳細は strategy-decisions 2026-04-23)。新しい Surface を追加するときは surfaces/ に Strategy struct のファイルを 1 つ足し、Registry.Build の switch に登録するだけで済む。

backend/internal/usecase/recommendation/
├── types.go                # 共通型 (Surface / SurfaceRequest / Response / Item / Reason / PerUserScore)
├── basescoring/            # Base Scoring (iALS 内積、scenario / user embedding、全 Surface 共通)
│   ├── scorer.go           #   Scorer interface (ScoreMatrix)
│   ├── score_matrix.go     #   DotScorer (pgvector 内積実装)
│   ├── cache.go            #   ScenarioCache + Snapshot (model_version 固定)
│   ├── store.go            #   Store (AlloyDB からの embedding 読み込み)
│   ├── pgvector.go         #   pgvector 文字列パース
│   └── ials/               #   iALS 自前学習 (technology-choices §1.4)
├── surfaces/               # staged な PreFilter → Aggregate → PostRerank に乗る Surface 用 Strategy struct を 1 ファイル
│   ├── strategy.go         #   SurfaceStrategy interface + AggregateOutput + AlreadyPlayedOracle
│   ├── registry.go         #   Registry + Build(req) factory (QuickMatch は対象外)
│   ├── for_you.go          #   ForYouStrategy (FOR_YOU、Identity 集約)
│   ├── similar.go          #   SimilarStrategy (mean + half-half mix)
│   └── (event_detail.go / author.go / collection.go は各 Surface 実装 issue で追加)
├── pipeline/               # 入口。SurfaceRequest を見て surfaces.Registry または strategy/quick_match へ振り分ける
│   ├── recommender.go      #   Recommender.Recommend(ctx, SurfaceRequest)
│   │                       #     - QuickMatchRequest は strategy/quick_match.Recommend に early-dispatch
│   │                       #     - それ以外は registry.Build → PreFilter → Aggregate → PostRerank
│   └── logger.go           #   assignment ログ書き出し (N 行ログ)
├── strategy/               # staged な PreFilter→Aggregate→PostRerank に乗らない Surface 専用の統合戦略
│   └── quick_match/        #   QUICK_MATCH 統合 greedy: utility = Aggregate + V/F ボーナス - P ペナルティ + 多様性 / N≥1 ハード制約
│       ├── strategy.go     #     Recommend(ctx, Deps, QuickMatchRequest) — PreFilter → Base Scoring → 履歴取得 → greedy 委譲
│       │                   #     preFilterForQuickMatch も同居 (人数 / 全員未プレイ / locale / category / GM / 90 分以内 / 2 周目+ 専用キャラ非含有)
│       └── greedy.go       #     selectTopK + utility 関数 (Least Misery + 履歴シグナル + MMR + N≥1)
├── schema/                 # BigQuery assignment log スキーマ
└── training/               # 学習ジョブ (loader / run / eval / writer)

Base Scoring の実装パッケージは basescoring/ (旧称 l1_core/ を UZU-5395 で rename)。配下の ials/ サブパッケージはそのまま。

入口 API (pipeline.Recommender) ​

resolver / usecase 層からの呼び出し入口は pipeline.Recommender.Recommend(ctx, SurfaceRequest) の 1 method に統一する (MOBL-4289 で B 折衷解消、C 案一貫化)。QuickMatch / SIMILAR も同じ入口で扱い、per-user 内訳は Item.PerUserScores で返す。

Surface 別の分岐は surfaces.Registry.Build(req) が concrete 型に応じた Strategy instance を request-scope で生成し、pipeline が PreFilter / Aggregate / PostRerank を順に呼ぶだけ。pipeline 本体には Surface if 文を持たない。

go
// ─── 共通型 (recommendation/types.go) ─────────────
type Surface string
const (
    SurfaceForYou      Surface = "for_you"
    SurfaceEventDetail Surface = "event_detail"
    SurfaceQuickMatch  Surface = "quick_match"
    SurfaceSimilar     Surface = "similar"
    SurfaceAuthor      Surface = "author"
    SurfaceCollection  Surface = "collection"
)

// CommonOptions は 5 surface (FOR_YOU / EventDetail / Author / Collection /
// Similar) の Request 型で共有される汎用オプション。Surface 固有の入力
// (EventID / AuthorID / CollectionID / ScenarioID) は各 Request 型のフィールドに
// 置き、ここに混ぜない。
type CommonOptions struct {
    K                    int
    Candidates           []int   // nil → 全シナリオ、非 nil → 指定集合に絞る (PreFilter)
    ExcludeAlreadyPlayed bool    // PreFilter
    ExperimentID         string  // A/B 割当 (assignment ログ用)
    ExperimentArm        string
    SuppressLogging      bool    // admin preview 等、本番統計を汚染したくない呼び出し
}

// SurfaceRequest は 5 surface の Request 型が共通で実装する
// インターフェース (isSurfaceRequest が unexported でパッケージ外からは実装不能)。
// 必須パラメータを Surface ごとにコンパイル時強制するための入口型で、
// god struct (旧 `Request`) を置き換える。
type SurfaceRequest interface {
    isSurfaceRequest()
    GetSurface() Surface
    GetViewerUserID() int     // FOR_YOU は UserID、他 Surface は ViewerID
    GetCommon() CommonOptions
}

// ─── Surface 別 Request 型 ────────────────────────
type ForYouRequest struct {
    UserID int
    Common CommonOptions
}
type EventDetailRequest struct {
    EventID  int
    ViewerID int
    Common   CommonOptions
}
type AuthorRequest struct {
    AuthorID int
    ViewerID int
    Common   CommonOptions
}
type CollectionRequest struct {
    CollectionID int
    ViewerID     int
    Common       CommonOptions
}
type SimilarRequest struct {
    ScenarioID int
    ViewerIDs  []int   // 複数 viewer 対応 (感想戦パーティ全員)
    Common     CommonOptions
}
// QuickMatchRequest も SurfaceRequest を実装する (MOBL-4289 で B 折衷廃止)。
type QuickMatchRequest struct {
    GroupUserIDs        []int
    ScenarioCategoryIDs []int
    Common              CommonOptions
}

type Response struct {
    RecommendationID string
    ModelVersion     string
    Items            []Item
}

type Item struct {
    ScenarioID    int
    Score         float64           // FOR_YOU は Base Score、QuickMatch は aggregate、SIMILAR は mean
    Position      int
    Reasons       []Reason
    PerUserScores []PerUserScore    // QuickMatch のみ値、FOR_YOU / SIMILAR は nil
}

type PerUserScore struct { UserID int; Score float64 }

// ─── 入口 (pipeline/recommender.go) ──────────────
// registry.Build(req) で Strategy を new して順に呼ぶだけ。
// Common.SuppressLogging=false なら viewer 数分の N 行 assignment ログを送出する。
func (r *Recommender) Recommend(ctx context.Context, req recommendation.SurfaceRequest) (*recommendation.Response, error)

呼び元は concrete 型で明示する:

go
// FOR_YOU
r.Recommend(ctx, recommendation.ForYouRequest{
    UserID: 42,
    Common: recommendation.CommonOptions{K: 10, ExcludeAlreadyPlayed: true},
})

// QuickMatch (集団推薦、viewer 軸が N 人)
r.Recommend(ctx, recommendation.QuickMatchRequest{
    GroupUserIDs:        []int{1, 2, 3},
    ScenarioCategoryIDs: []int{5, 7},
    Common:              recommendation.CommonOptions{K: 3},
})

// SIMILAR (感想戦、viewer 集団で mean)
r.Recommend(ctx, recommendation.SimilarRequest{
    ScenarioID: 100,
    ViewerIDs:  []int{1, 2, 3},
    Common:     recommendation.CommonOptions{K: 10, ExcludeAlreadyPlayed: true},
})

technology-choices §9 の god struct 回避方針はこの構造で満たしている:

  • 全 Surface が SurfaceRequest 共通入口で吸収され、共通処理 (version 固定 / assignment ログ / ポジション採番) を 1 箇所に集約。
  • 複数 viewer を伴う Surface (QuickMatch / SIMILAR) は GetViewerUserIDs() と Item.PerUserScores の拡張で吸収。B 折衷の独立 method は MOBL-4289 で廃止済み。
  • 「全 Surface 共通 default filter」を持ち込まない (strategy-decisions 2026-04-23 の真のデフォルト不在論)。各 Strategy が必要な制約を明示的に組み立てる。

Base Scoring の契約 (Scorer) ​

Base Scoring はスカラーだけを返し、user / scenario の embedding ベクトルは外に出さない(§3.2)。

go
// basescoring/scorer.go
type Scorer interface {
    // matrix[i][j] = <userVec(userIDs[i]), scenarios[j].Vector> + scenarios[j].Bias
    // FOR_YOU (len(userIDs)=1) と QUICK_MATCH (複数 user) の両方を吸収する
    ScoreMatrix(ctx context.Context, modelVersion string, userIDs []int, scenarios []ScenarioEmbedding) ([][]float64, error)

    // <p_context, p_candidate> を candidates と同順で返す scenario-to-scenario 類似度。
    // SIMILAR Surface の Aggregate で viewer 非依存の scoring に使う。context scenario
    // が未学習なら ErrScenarioEmbeddingNotFound を返し、Strategy は viewer mean に退化。
    ScenarioSimilarityBatch(ctx context.Context, modelVersion string, contextScenarioID int, candidates []ScenarioEmbedding) ([]float64, error)
}

実装は DotScorer (pgvector 内積) 1 本。ScenarioCache.Snapshot() が返す (modelVersion, scenarios) を呼び出し側で固定し、version を Scorer の外から渡すことで、推論中に cache.Refresh() が走っても scenarios と user embedding の version が乖離しないようにしている。ScenarioSimilarityBatch のみ context scenario の embedding を ID で引く必要があるため、DotScorer 内部で ScenarioCache を参照する (Snapshot ではなく Get 単発引きで、version 整合は引数 modelVersion と snapshot.ModelVersion() の一致チェックで担保)。

spec §7.2 初版で計画していた他メソッドは、現状必要分が出揃うまで未実装。必要になった時点で Scorer に追加する:

メソッド追加タイミング
ScoreForColdStartSegment(segment, candidates)base-scoring §2.2 の segment-based cold-start (新規ユーザー) を導入するとき

Surface ごとの Strategy (surfaces パッケージ) ​

各 Surface は SurfaceStrategy interface を実装する struct として 1 ファイルにまとまる。Aggregate / PreFilter / PostRerank の独立 interface は廃止し、Surface 固有のロジックと request-scope state を struct 内部に閉じ込める (strategy-decisions 2026-04-23)。

go
// surfaces/strategy.go
type SurfaceStrategy interface {
    PreFilter(ctx, req, scenarios) ([]basescoring.ScenarioEmbedding, error)
    Aggregate(ctx, matrix, scenarios, req) (AggregateOutput, error)
    PostRerank(items []recommendation.Item, req) []recommendation.Item
}

type AggregateOutput struct {
    Scores        []float64                          // Scenarios と同順の集約スコア
    PerUserScores [][]recommendation.PerUserScore    // 複数 viewer Surface のみ nil 以外
}

Strategy 実装は request-scope で new されるため、PreFilter で取得した scenarioMeta / beginnerRatio / playedCountByScenario などを struct field に保持して Aggregate / PostRerank で参照する設計ができる (QuickMatchStrategy / SimilarStrategy がこの形)。

SurfaceStrategyPreFilterAggregatePostRerank
FOR_YOUForYouStrategyCandidates + ExcludeAlreadyPlayedIdentity (matrix[0])no-op
QUICK_MATCHQuickMatchStrategyCandidates + 人数一致 / 全員未プレイ / 無料 / 90分 / release / locale / categoryLeast Misery + beginner bonus + PerUserScoresMMR
SIMILARSimilarStrategyCandidates + self-exclusion / CharacterCount ±2 / 1 人用除外 / locale 積集合 (viewer 0 で skip) / 2 人以上プレイ済み除外context scenario × candidate の embedding 内積 (ScenarioSimilarityBatch)、未学習時は viewer 集団 mean にフォールバックhalf-half mix (全員未プレイ / 1 人プレイ済みを交互に)
EVENT_DETAIL / AUTHOR / COLLECTION各 Aggregate 実装 issue で追加—max / topK_mean / mean (spec §3.3)—

Surface 固有のフィールド (EventID / AuthorID / GroupUserIDs / ViewerIDs 等) を使う実装は req を concrete 型 (recommendation.QuickMatchRequest / recommendation.SimilarRequest 等) に type assertion してから取り出す。未実装 Surface は Registry.Build で error を返す (panic ではない)。

AlreadyPlayedOracle / QuickMatchMetaStore / QuickMatchConfig は Registry 構築時に注入する依存で、pipeline.Recommender が起動時に 1 回だけ解決する。Played は nil 許容で、nil のとき ForYouStrategy の ExcludeAlreadyPlayed は no-op、QuickMatchStrategy の PreFilter はエラーを返す、SimilarStrategy は played ベースのフィルタを skip する fail-safe 設計。

内部で透過的に扱う事項(インターフェースに出さない) ​

  • Item cold-start(新規シナリオ、ALS p_s 未学習): ScoreMatrix が内部で content embedding にフォールバック(base-scoring §2.3、未実装)
  • User 部分 cold-start(既存ユーザーだがプレイ数少): fold-in q̂_u を内部で合成(base-scoring §2.2、未実装)
  • これらは呼び出し側が判定しない。Scorer インターフェースの一貫性のため実装に閉じ込める

この契約で Base Scoring 実装を差し替える(iALS → hybrid → two-tower 等)時も、Aggregate / PreFilter / PostRerank / resolver / GraphQL は変更不要。

7.3 外部 API (GraphQL) ​

現状: admin preview 3 本 (FOR_YOU / QuickMatch / SIMILAR) の計 3 query。汎用 recommendedScenarios (client 向け) は MOBL-4275 で削除、quickMatchScenarios (production) は MOBL-4285 で削除済み。admin preview 用 query は MOBL-4277 で Surface 別 input に分割、MOBL-4289 で Quick Match の戻り値型を Recommendation に統合済み (items[].perUserScores に集約前スコア内訳が入る)。SIMILAR は MOBL-4293 で Recommendation を返す query として追加。

graphql
extend type Query {
  # admin preview 専用 (FOR_YOU)。assignment ログには記録しない。surface は
  # 型で FOR_YOU 固定のため入力フィールドに含めない。
  adminForYouScenarios(input: AdminForYouInput!): Recommendation!

  # admin preview 専用 (Quick Match)。参加者 N 人 (N ≥ 1) の Least Misery 集約
  # 結果を返す。SuppressLogging=true + candidates 指定で叩く admin 専用 query。
  # items[i].perUserScores に集約前の各参加者スコア内訳が入る。
  adminQuickMatchScenarios(input: AdminQuickMatchScenariosInput!): Recommendation!

  # admin preview 専用 (SIMILAR)。MOBL-4293 で追加。
  adminSimilarScenarios(input: AdminSimilarInput!): Recommendation!
}

input AdminForYouInput {
  userId: Int!
  k: Int! = 10
  candidates: [Int!]
  excludeAlreadyPlayed: Boolean! = true
}

type Recommendation {
  recommendationId: String!
  modelVersion: String!
  items: [RecommendedScenario!]!
}

type RecommendedScenario {
  scenario: Scenario!
  score: Float!
  reasons: [String!]!
  # 単一 viewer Surface (FOR_YOU 等) では null。複数 viewer Surface (QuickMatch) でのみ
  # 集約前の各参加者スコア内訳が入る。
  perUserScores: [PerUserScore!]
}

type PerUserScore {
  userId: Int!
  score: Float!
}

他 surface (EventDetail / Author / Collection) の admin query は各 Surface 実装 issue で追加する想定:

  • adminEventDetailScenarios(input: AdminEventDetailInput!) — EventDetail Surface 実装 issue
  • adminAuthorScenarios / adminCollectionScenarios — 各 Surface 実装 issue

client 向け recommendedScenarios (FOR_YOU / EVENT_DETAIL / AUTHOR / COLLECTION / SIMILAR 汎用) を保留している理由:

  • Surface Strategy が整う前に wrapper 型・recommendationId 返却方法・reasons 露出・click ログ送信規約を議論してもズレるだけ
  • 実需(具体的なモバイル画面)が出てから決める方が、expose の形を誤って固定化しない
  • adminForYouScenarios は usecase / Base Scoring / Surface Strategy / assignment ログの end-to-end 動作確認 に必要なので admin だけ先に開けている

Quick Match の production client expose は MOBL-4285 で quickMatchScenarios(production) を削除済み: production の集団推薦呼び出しはマッチング成立時に backend が内部で実行し、結果は room state に同期する経路に切り替わった。client が直接 quickMatchScenarios を叩く GraphQL は現状開いていない (admin preview のみ)。

7.4 他 Surface (FOR_YOU / EVENT_DETAIL / AUTHOR / COLLECTION) の client 展開(将来タスク) ​

各 Surface Strategy が整い reasons の中身が充実した段階で、既存 topRankedScenarios / scenarioscreen/* の 内部実装を recommendation usecase 呼び出しに差し替える 形で client に展開する。Quick Match のように新 query を新設する方針はこれら surface には適用しない (返り値型が既存 [Scenario!]! で済むため)。

既存 API将来の内部実装差し替え先
topRankedScenarios(limit)recommendation usecase の SurfaceForYou 呼び出し
scenarioscreen/recommended_for_you.go の各セクション各 surface に対応する usecase 呼び出し

SIMILAR は MOBL-4294 で Scenario.similarScenarios / Query.similarScenarios(limit, scenarioId, targetUserIDs) の 両 production resolver を pipeline 経由に差し替え済み (旧 Redis cache + タグ類似度ベースは削除)。本節は SIMILAR を対象から外す。

この差し替えは本 issue のスコープ外で、Surface Strategy 整備完了後に別途 issue を切る。

7.5 SLO(base-scoring §3.5 の再掲) ​

API レイヤーの契約として再掲:

対象p50p95p99
各 surface の GraphQL Query< 50 ms< 150 ms< 300 ms

SLO を超える surface は surface 別の in-memory キャッシュや pre-computed top-N 等を検討する(base-scoring §3.5 の escalation path 参照)。

8. 評価指標・A/B 基盤 ​

Base Scoring 導入効果を定量評価できなければ、アルゴリズム改善も継続できない。リリース前に計測枠組みを固定するのが本章の目的。具体値(K 値・劣化閾値・最低サンプルサイズ)は §6 D2 で合意する。

本章のオンライン KPI を「いつでも見られる場所」に落とした観測 surface として、admin の /recommendation/metrics ページを提供している。1 位選択率・MRR・個人 vs グループ一致率・プレイ後 Good 率・assignment 流量・model_version 別 1 位選択率の 6 パネルを期間 / surface / model_version filter 付きで表示する。詳細・saved query は observation.md を参照。quick_match_rooms.recommendation_id (UUID NULL) を経由して BQ recommendation_assignments と DB を join するので、「どの 3 件推薦のうちどれが選ばれたか」を後追いで集計できる。

8.1 オフライン評価 ​

学習ジョブ完了時にホールドアウトで自動計測し、前回モデルと比較する。

メトリクスK用途
NDCG@1010Primary。順位考慮の相関度(0〜1)。UI 上位(モバイル ForYou 画面のファーストビュー相当)を測る業界標準
Recall@5050候補生成の網羅性。後段 re-ranking の余地を測る
HitRate@1010上位 10 件に 1 件でも正解が入るユーザー比率。解釈しやすい補助指標
Coverage-推薦に登場する scenario のユニーク数 / 全 scenario 数。ロングテール対応

K=10 は「ForYou 画面のファーストビュー〜1 スクロール目」相当。モバイル UI の実表示件数に合わせた。

Holdout 方針:

  • 時間分割: 直近 14 日を test、それ以前を train(リアルな利用シナリオと整合)
  • ユーザー分割(cold-start 評価用): 全ユーザーの 5% をランダム選定、学習から除外して別途 NDCG/Recall を算出
  • 評価対象: train 期間にプレイ履歴 ≥ 5 件のユーザー(cold-start ノイズを避ける)

8.2 オンライン KPI ​

層KPI算出式ローンチ条件即ロールバック条件
Primary推薦経由プレイ完了率推薦 click 後 7 日以内にプレイ完了した数 / assignment 数p < 0.05 改善、または CI 下限 > -2%(非劣化容認)p < 0.05 で -5% 以上劣化
Primary推薦経由 Excellent 評価率推薦 click 後 14 日以内に Excellent 評価した数 / assignment 数非劣化(CI 下限 > -5%)-10% 以上劣化
Secondary推薦 CTRclick 数 / assignment 数非劣化目標-10% 以上で警告
Secondary7 日リテンション実験 arm ユーザーの 7 日後再訪率非劣化-5% 以上劣化
Guardrail推薦経由 Poor 評価率Poor 評価した数 / assignment 数悪化 +10% 未満+10% 以上悪化
GuardrailRecommend() p95 レイテンシrecommend() GraphQL の p95< 150ms超過
Guardrailエラー率recommend() のエラーレスポンス率< baseline × 1.5baseline × 2 超

運用ルール:

  • ローンチ条件はすべて満たす必要あり
  • 即ロールバック条件はいずれか 1 つでも満たしたら停止
  • Guardrail の劣化時は Primary の改善があってもローンチしない

8.3 A/B テスト方針 ​

Split 戦略:

  • ユーザー ID ハッシュで固定割当(セッション間で一貫、離脱時に arm が変わらない)
  • surface 別独立 split: FOR_YOU / QUICK_MATCH / SIMILAR_SCENARIO は独立に実験(相互汚染回避)
  • 割当ロジック: §8.7 の AssignExperimentArm() 参照

実験期間とサンプルサイズ:

項目値
有意水準 α0.05(両側)
検出力 1-β0.80
最低実験期間14 日(曜日変動 7 日 + attribution window 7 日)
最大実験期間56 日(8 週間)超えたら設計見直し
検出効果(MDE)Primary KPI で +10% relative(ベースライン 5% なら +0.5% absolute)
最低サンプルサイズPower Analysis で都度逆算

サンプルサイズ逆算式:

n_per_arm = 2 × (z_α/2 + z_β)² × p(1-p) / Δ²
  z_α/2 = 1.96 (α=0.05 両側)
  z_β   = 0.84 (1-β=0.80)
  p     = ベースライン率(例: 0.05)
  Δ     = 検出したい絶対効果(例: 0.005)

例: p=0.05、Δ=0.005 → n ≈ 29,800/arm、2 arm で約 60,000 ユーザー

段階的 rollout:

Phaseトラフィック期間判定
Dogfood内部 5 人任意手動確認で破綻なし
Canary5%3 日Guardrail 監視、異常なければ次へ
Expansion50%14 日KPI 比較、ローンチ条件チェック
Full rollout100%14 日観察旧モデル停止、実験クローズ

P1 期間の基盤: 既存 config 機構を使った簡易 Feature Flag で arm 切替。専用 A/B プラットフォーム(GrowthBook 等)は §4 P3 で導入検討(§8.6)。

ベースライン測定:

  • P1 実装の最初の 2 週間で recommendation_assignments / clicks ログ基盤を先行リリースし、現行 ranking_rate の CTR / CVR を 1 週間測定してベースラインを確定
  • 代替として既存 mobile_events.scenario_views から「詳細閲覧 → プレイ完了」の近似 CTR / CVR も参考値として併用

8.4 ロールバック基準 ​

以下のいずれかを満たしたら新モデル・新アルゴを即 revert(§8.2 の「即ロールバック条件」と整合):

条件閾値判定タイミング
オフライン NDCG@10 が前回比劣化5% 以上低下学習ジョブ完了時(swap 前)
Primary KPI が旧版比で統計的有意に劣化p < 0.05 かつ 5% 以上 relative 低下A/B 実験中・終了後
推薦経由 Poor 評価率の悪化+10% 以上 relative監視ダッシュボード
Recommend() p95 レイテンシ SLO 違反150ms 超Cloud Monitoring アラート
エラー率baseline × 2 超Cloud Monitoring アラート
モデル出力異常NaN / 極端な score 分布学習ジョブ完了時のバリデーション

8.4.1 P2 以降のモデル間比較 ​

iALS vs BPR / LogMF の切替判定(technology-choices §6.2 と整合):

条件行動
オフライン NDCG@10 が +5% 以上改善A/B テスト実施
A/B で Primary KPI 有意改善 かつ Guardrail 範囲内切替
それ以外現行モデル継続、ハイパラチューニング優先

8.5 計測に必要なデータ ​

オンライン KPI を計測するには、推薦結果をどこで何位に出したかのログが必須になる。

設計の基本方針 ​

サーバーサイド impression(返却時点で記録)は position bias を持つため採用しない。代わりに クライアント側で VisibilityDetector を使って実際に画面表示された scenario を追跡し、クリック時に「クリックされた scenario + クリック直前まで visible だった scenario 一覧」をセットで送る方式を採る。

これにより:

  1. Position bias 排除: impression = 「本当に見られたもの」に限定される
  2. 学習データの質向上: visible-but-not-clicked は「見た上で選ばなかった」弱い negative として将来活用可能(P2 以降)
  3. 送信コスト削減: スクロール中は送らず、click 時に 1 回だけ
  4. backend 側 impression ログが不要 or A/B 追跡用の軽量 assignment ログだけで済む

確定スキーマ ​

既存の mobile_events.scenario_views と同じパターンで以下 2 テーブルを追加。Terraform は infra/terraform/modules/bigquery/ の既存モジュールに追加。

sql
-- A/B 追跡用: backend が Recommend() 実行時に goroutine で非同期 insert(軽量)
CREATE TABLE mobile_events.recommendation_assignments (
    recommendation_id      STRING NOT NULL,   -- UUID、1 Recommend() 呼び出しで 1 行
    user_id                STRING NOT NULL,
    surface                STRING NOT NULL,
    model_version          STRING NOT NULL,   -- "ials-v1" 等
    experiment_id          STRING,            -- 実験対象外なら NULL
    experiment_arm         STRING,
    returned_scenario_ids  ARRAY<STRING>,     -- 返した scenario 一覧(debug / 後追い分析用)
    reasons                ARRAY<STRUCT<type STRING, value STRING>>,  -- scenario_id 順と対応
    assigned_at            TIMESTAMP NOT NULL
)
PARTITION BY DATE(assigned_at)
CLUSTER BY user_id, surface;

-- クリックログ: クライアントが click 時に visible list 同梱で送る
CREATE TABLE mobile_events.recommendation_clicks (
    recommendation_id    STRING NOT NULL,
    user_id              STRING NOT NULL,
    clicked_scenario_id  STRING NOT NULL,
    clicked_position     INT64 NOT NULL,
    visible_scenarios    ARRAY<STRUCT<
        scenario_id      STRING,
        position         INT64,
        first_visible_at TIMESTAMP
    >>,                                     -- click 直前までに画面に出た全 scenario
    clicked_at           TIMESTAMP NOT NULL
)
PARTITION BY DATE(clicked_at)
CLUSTER BY user_id, clicked_scenario_id;

設計判断メモ ​

項目決定理由
assignment の生成元Go backend の Recommend()どの arm / model_version でどのユーザーに何を返したかを記録。クリックが無くても残る
click + visibility の生成元モバイル / Web クライアント実 visibility と click を取れるのはクライアントだけ
送信経路BigQuery Streaming Insert API (tabledata.insertAll) を直接呼ぶ既存 mobile_events.scenario_views と同じ。pub/sub / Dataflow は未導入
backend 側の実装goroutine で非同期 insert、Recommend() の応答には待たないSLO (p95 < 150ms) を守る
client 側の実装既存 dart/apps/mobile/lib/helpers/bigquery.dart を拡張。VisibilityDetector で buffer、click 時に flushアプリ動作には影響させない
visibility 判定visibleFraction > 0.5 で「見られた」扱い一般的な閾値、スクロール中の瞬間的露出を除外
visible buffer のリセット画面遷移 or recommendation_id が変わったタイミング別の Recommend() 結果が混ざらないようにする
recommendation_id の粒度1 Recommend() 呼び出しで 1 UUID同一リクエストで返す scenario 群は同 ID、position で識別
attribution windowclick → play_completed: 7 日、click → Excellent 評価: 14 日§8.2 の KPI 算出で使う
conversion 紐付けrecommendation_clicks × participants / user_scenario_status を時間窓 JOIN冗長テーブルを作らず既存テーブル活用
保存期間当面無期限、GDPR 退会要請時は個別削除別途データ保持ポリシー策定(本章のスコープ外)

Flutter 実装スケッチ ​

dart
class RecommendationImpressionTracker {
  final String recommendationId;
  final Map<String, VisibleScenarioEntry> _visibleBuffer = {};

  void onVisible(String scenarioId, int position) {
    _visibleBuffer.putIfAbsent(
      scenarioId,
      () => VisibleScenarioEntry(
        scenarioId: scenarioId,
        position: position,
        firstVisibleAt: DateTime.now().toUtc(),
      ),
    );
  }

  Future<void> onClick(String clickedScenarioId, int clickedPosition) async {
    await BigQueryHelper.logRecommendationClick(
      recommendationId: recommendationId,
      clickedScenarioId: clickedScenarioId,
      clickedPosition: clickedPosition,
      visibleScenarios: _visibleBuffer.values.toList(),
    );
  }
}

// UI 側
VisibilityDetector(
  key: Key('rec_${tracker.recommendationId}_$position'),
  onVisibilityChanged: (info) {
    if (info.visibleFraction > 0.5) {
      tracker.onVisible(scenarioId, position);
    }
  },
  child: ScenarioCard(
    onTap: () async {
      await tracker.onClick(scenarioId, position);
      // navigation
    },
  ),
);

学習への活用(段階的) ​

Phasevisible-but-not-clicked の扱い
P1学習には使わない(未観測扱い)。ただしログは取る
P2 以降弱い negative confidence(例: -5〜-10)として iALS に投入。position bias 補正済みの negative として機能

P1 から visibility ログを取っておくことで、P2 以降に学習投入の判断ができる。

Streaming Insert 失敗時の扱い ​

分析用のため失敗は logger に記録するのみで本推薦動作への影響は出さない(既存 BigQueryHelper と同方針)。失敗率 > 0.1% が継続したら監視アラート → pub/sub バッファリング導入を検討。

conversion 集計クエリ例(参考) ​

sql
-- 推薦経由プレイ完了率(Primary KPI)
WITH assignments AS (
    SELECT * FROM mobile_events.recommendation_assignments
    WHERE DATE(assigned_at) BETWEEN @start AND @end
),
clicks AS (
    SELECT * FROM mobile_events.recommendation_clicks
    WHERE DATE(clicked_at) BETWEEN @start AND DATE_ADD(@end, INTERVAL 1 DAY)
),
clicked_with_arm AS (
    SELECT a.experiment_arm, a.model_version, c.user_id, c.clicked_scenario_id, c.clicked_at
    FROM assignments a
    JOIN clicks c USING (recommendation_id)
),
completed AS (
    SELECT cw.experiment_arm, cw.model_version, cw.user_id, cw.clicked_scenario_id
    FROM clicked_with_arm cw
    JOIN app_public.participants p
      ON p.user_id = cw.user_id AND p.scenario_id = cw.clicked_scenario_id
    WHERE p.finished_at BETWEEN cw.clicked_at AND TIMESTAMP_ADD(cw.clicked_at, INTERVAL 7 DAY)
)
SELECT
    experiment_arm,
    model_version,
    COUNT(DISTINCT (user_id, clicked_scenario_id)) AS completions,
    (SELECT COUNT(DISTINCT recommendation_id) FROM assignments) AS total_assignments,
    SAFE_DIVIDE(COUNT(DISTINCT (user_id, clicked_scenario_id)), (SELECT COUNT(DISTINCT recommendation_id) FROM assignments)) AS completion_rate
FROM completed
GROUP BY experiment_arm, model_version;

スキーマ変更は後方互換を維持(カラム追加のみ、NULL 許容)。

8.6 プロダクト組み込み vs ワンショット分析 ​

A/B 関連要素のうち、「プロダクトに組み込む = バックエンドの恒常機能」と「ワンショット分析 = 実験ごとの人手作業(BigQuery SQL + ダッシュボード)」を明確に切り分ける。GrowthBook / Optimizely 等の専用 A/B プラットフォーム導入は当面不要。

プロダクト組み込み(恒常機能) ​

継続的に必要なもの・推論時に決定する必要があるもの。

要素実装場所
impression / click ログ記録backend → BigQuery(mobile_events 相乗り)
experiment_arm 割当関数(deterministic hash)usecase/recommendation/experiment.go(AssignExperimentBucket)
model_version / experiment_id の付与本番経路(FOR_YOU は recommended_for_you.go)→ CommonOptions
Feature Flag による arm 切替・ロールバックAppSetting key recommendation_foryou_experiment(jsonb、60s TTL キャッシュ。再デプロイ不要)
オフライン評価指標(NDCG@K / Recall@K)の計算学習ジョブ(Go)内で自動実行、異常検知連動

ワンショット分析(実験ごとの人手) ​

実験のたびに人が判断するもの。BigQuery SQL + 簡易ダッシュボード(Looker Studio / Data Studio)で運用。

要素手段頻度
Power Analysis(必要サンプルサイズ計算)Python notebook / スプレッドシート実験設計時に 1 回
A/B 結果の集計・可視化BigQuery SQL + Looker Studio実験期間中 daily
統計検定(t-test / 信頼区間)notebook or SQL実験終了時
多重検定補正(Bonferroni / BH)人手で適用実験終了時
ロールバック最終判定Guardrail ダッシュボードを見て SRE / 担当が判断異常時のみ

進化段階 ​

Phaseプロダクト組み込みワンショット分析
P1assignment ログ(backend)/ click+visibility ログ(client)/ arm 割当 / Feature Flag / オフライン NDCG 計算Power Analysis / 結果集計 SQL / Looker Studio ダッシュボード
P3自動 Guardrail 監視・アラート(Cloud Monitoring)/ 自動ロールバック(異常時)引き続き実験ごとの検定
P4+バンディット基盤(自動最適化)/ 専用 A/B プラットフォーム導入検討-

8.7 P1 で組み込む最小セット ​

P1 着手時点で backend に実装すべき項目と想定工数:

項目行数目安工数実装場所
AssignExperimentArm(userID, experimentID, surface) int~30 行1husecase/recommendation/experiment.go
impression / click ログ記録(gRPC or GraphQL mutation)~100 行半日usecase/recommendation/log.go + resolver
BigQuery 永続化(mobile_events パイプライン相乗り)設定のみ半日Terraform / pub-sub
Feature Flag による arm 切替既存 config 機構に追加半日adapters/config/
学習ジョブ内の NDCG / Recall 評価と Slack 通知~50 行 Go半日backend/internal/usecase/recommendation/training/eval.go
合計2〜3 日

Power Analysis / 集計 SQL / ダッシュボード構築はプロダクトコード外の作業(データ分析担当の仕事)。

実装 (UZU-4268 で着地) ​

実コードの user PK は int なので、bucket 割当は recommendation.AssignExperimentBucket として実装した (usecase/recommendation/experiment.go)。

go
func AssignExperimentBucket(userID int, experimentID string, surface Surface) int {
    h := fnv.New32a()
    _, _ = h.Write([]byte(experimentID))
    _, _ = h.Write([]byte(surface))
    _, _ = h.Write([]byte(strconv.Itoa(userID)))
    return int(h.Sum32() % 100)
}

arm への変換 + ロールアウト比率 + override は ExperimentConfig.ResolveForYouRecipe が担い、可変設定は AppSetting key recommendation_foryou_experiment (jsonb) から 60s TTL キャッシュで読む (再デプロイ無しで段階ロールアウト可)。pipeline の Scorer 選択は CommonOptions.Recipe (baseline / pop_content) 駆動で、surface 特殊ケースを持たない。運用とクエリは observation.md「FOR_YOU A/B 実験」を参照。

重要な特性:

  • deterministic(同じ入力なら同じ arm、セッション間で一貫。比率固定中は安定)
  • experimentID と surface を hash に混ぜる → 実験ごと・surface ごとに独立 split(過去実験の汚染回避)