Skip to content

技術選定の経緯と理由 ​

レコメンデーション機能の各レイヤー・コンポーネントについて、なぜその技術を選んだか・何を比較検討したか・採用しなかった代替案と理由を記録する。

仕様本体は spec.md を参照。本ドキュメントは背景情報であり、実装コードの契約にはならない。

1. Base Scoring モデル: iALS を採用、Deep Learning を採用しない ​

1.1 Deep Learning を使わない理由 ​

Neural CF / two-tower / BERT4Rec などの DL モデルは P4 以降まで検討しない。理由は複合的:

#理由詳細
1データ規模が線形 MF で頭打ち16.8K scenarios × 356K users、~500 万 interaction は iALS/BPR で性能が飽和する範囲。DL の表現力が活きるのは 1 億 interaction 以上
2実測性能差が小さいRendle "Neural Collaborative Filtering vs. Matrix Factorization Revisited" (2020) で、NCF はチューニング済み iALS に負けていたことが公式訂正されている
3運用コストGPU ノード月数万円、TF/PyTorch Docker image 5GB+、学習時間数時間。iALS は CPU 数分・Docker 300MB
4反復サイクルiALS は数分で学習 → A/B 結果で即改善。DL は数時間〜1 日でチューニング待ち
5専任 ML エンジニア不在DL はハイパラ探索・収束判定・リソース管理が複雑。非 ML 専門チームが運用するには負担が大
6解釈性線形 MF は reasons 生成が自然(「お気に入り作者の作品だから」等)。DL は post-hoc 説明(SHAP 等)が必要で UI に出しづらい
7Cold-start は DL でも解決しない内容ベース embedding(base-scoring §2.3)が直接的な解。アーキテクチャ複雑化では cold-start は改善しない
8段階導入の原則ベースライン(iALS)なしに DL を入れても効果が切り分けられない

DL を検討する閾値(= 将来の判断基準):

  • users が 1,000 万超え(現 35.6 万 → 30 倍)
  • interaction が 1 億超え(現 ~500 万 → 20 倍)
  • sequential 推薦(プレイ順序が意味を持つ)の明確な要件
  • 専任 ML チーム組成

1.2 iALS / BPR / LogMF の比較 ​

Poor 評価を含む negative signal を扱うため、線形 MF の中で 3 モデルを比較する。

iALS (Hu 2008 + 拡張)BPR (Rendle 2009)LogMF (Johnson 2014)
目的関数重み付き最小二乗(confidence MF)pairwise ranking lossBernoulli 対数尤度
入力(u, i, confidence) — 負値も可(u, i_pos, i_neg) 三つ組(u, i, y, confidence)
スコアの意味非キャリブレーション相対順位のみ確率(P(like))
明示的 negative の反映◎ 負の confidence で直接△ pairwise 拡張が必要◎ 強度を含めて反映
ranking 性能 (NDCG)◎(Rendle 2022 で DL に匹敵と立証)◎○
Aggregate 合成との相性△× スコア同士が比較不可◎ 絶対値がキャリブレート
チューニング難度低低中(学習率・正則化・負例サンプリング率に敏感)
実装事例◎ 最多(Netflix thumbs down 等)◎ 豊富(ただし素では negative 弱)△ 少ない(Spotify 事例が主)
UZU での実装方針自前 Go 実装(§1.4)未採用P2 以降で評価

iALS は「positive-only」ではない(よくある誤解) ​

素の Hu 2008 論文は positive preference(p=1)と未観測(p=0)のみを扱うが、negative confidence 拡張は業界で広く使われているバリアント。Python implicit ライブラリの公式ドキュメントも:

"Negative items can be passed with a higher confidence value by passing a negative value, indicating that the user disliked the item."

と明記しており、UZU の自前 Go 実装もこの拡張仕様に従う。c_{ui} < 0 のセルは p_ui = 0 + confidence |c_{ui}| として解釈するコア数行の修正で実現できる。pairwise 再設計は不要。

BPR が明示的 negative に向かない理由 ​

  • 素の BPR は「正例 > 未観測」を学習する pairwise ranking loss
  • 明示的 negative(Poor)を扱うには pairwise 拡張が必要:
    • 正例 > 負例、未観測 > 強い負例、強い正例 > 弱い正例、などのペア設計
    • negative sampling 戦略のチューニングが複雑化
    • どの比較ペアをどれだけ学習させるかで結果がぶれやすい
  • 2024 年の研究でも Enhanced BPR / IPS-weighted BPR / Hard Negative Sampling BPR などの拡張が主流 = 素の BPR は negative に弱いという裏付け

LogMF の位置付け ​

  • 理論的には最もクリーン: 確率モデル、Aggregate 合成との相性が良い、Poor の強度を自然に表現
  • Spotify が社内で skip の扱いに採用した実績
  • ただし実装事例が少なく、ハイパラ感度が高い
  • P2 で iALS との比較対象候補

業界動向のエビデンス ​

  • Rendle 2022 "Revisiting the Performance of iALS on Item Recommendation Benchmarks" (Google, RecSys '22):適切にチューニングした iALS が DL 系を outperform、最近の deep 系は iALS を超えていないと再検証
  • Netflix: thumbs up/down の dislike feedback を重み付き MF で直接反映
  • Spotify: skip を LogMF の negative として扱う(BPR ではない)
  • LightFM 公式: explicit negative より implicit feedback の方が性能が良い傾向 → 逆に言えば、negative を扱うなら LightFM の pairwise (BPR/WARP) より iALS 系の weighted MF

1.3 導入方針 ​

P1 から iALS + negative confidence を本番採用する。Go 自前実装(§1.4)で Poor を負値のまま confidence 行列に入れる。

Phase本番モデル並行/オフライン評価
P1 (MVP)iALS + negative confidence-
P2iALS 継続BPR / LogMF をオフライン学習、NDCG@K で比較
P2 後半以降有意差で勝ったモデルに切替-

判断の経緯 ​

検討過程で 2 度方針が揺れたので、最終判断の根拠を記録する:

  1. 初期案(誤): iALS は positive-only だから Poor 反映に弱い → PostRerank デブーストで補う案

    • 却下理由: 複雑さの割に効果が不確実、Poor の情報が間接的にしか効かない
  2. 中間案(誤): BPR で Poor を明示的 negative として扱う

    • 却下理由: 素の BPR は「正例 > 未観測」の表現で、明示的 negative を扱うには pairwise 設計の拡張が必要。研究は活発だがプロダクション事例は少ない
  3. 最終判断(採用): iALS + negative confidence(Go 自前実装)

    • negative confidence 拡張は業界で実績多数(Python implicit の公式サポート、Netflix thumbs down 等)で、Go 自前実装にも安全に取り込める
    • Netflix の thumbs down、Spotify の LogMF など、明示的 negative を扱う業界標準は iALS 系 or 確率 MF(LogMF)
    • Rendle 2022 で iALS は DL に匹敵する性能が再立証されている
    • pairwise 再設計が不要で、コア実装は Hu 2008 iALS を数行拡張するだけ

iALS 系と BPR の使い分け(一般論) ​

データの性質推奨モデル
明示的 negative(dislike / block / Poor)があるiALS 系(重み付き MF)
正例のみで ranking を最適化したいBPR
正例 + 未観測 + 明示的 negative すべてあり、ranking 最適化も重要負例対応の pairwise / listwise 拡張(Enhanced BPR 等)、または LogMF

UZU は 1 つ目のケース(Poor が明示的にある)なので iALS 系が第一選択。

1.4 学習言語・ライブラリの選定 ​

採用: Go で自前実装 ​

UZU は Go で iALS を自前実装する(backend/internal/usecase/recommendation/basescoring/ials/、目安 ~500 LOC)。 主要候補との比較:

候補実装言語negative confidenceper-feedback 重み運用言語統一総合評価
Go 自前実装Go◎ 自分で設計◎ 自分で設計◎ UZU 統一採用
Python + implicitPython◎ ネイティブ対応◎ 行列に直接入力× Python toolchain 追加次善
Go + gorseGo✗ API なし✗ 単一 α のみ◎不採用(仕様を満たせない)
Go + CGo で implicit 呼出Go + C◎◎△ CGo 負担過剰複雑

gorse を不採用にした理由 ​

gorse-io/gorse は pure Go で BPR / ALS / FM を実装する有力候補だったが、コードを精査した結果 UZU 要件を満たせない:

  • AddFeedback(userId, itemId, timestamp) は per-feedback の重み・評価値を持たない(positive のみ)
  • BPR の negative は「ユーザー履歴にない item からランダムサンプリング」。Poor を explicit negative として渡せない
  • ALS の weight は単一グローバル α(Hu 2008 の α)で、tipping=50 / Excellent=30 / プレイ=20 の差を区別できない

UZU の base-scoring §1 が要求する「per-feedback 重み + Poor の負 confidence」を通すには gorse を fork するしかなく、運用負担が自分側に落ちる。

Python + implicit と比較した Go 自前の判断 ​

implicit(Cython + OpenMP)は最も成熟した選択肢で negative confidence もネイティブサポート。それでも Go を選んだ理由:

観点Python + implicitGo 自前実装
実装時間0.5-1 日(import するだけ)2-3 日(~500 LOC)
実装正しさ検証ライブラリが保証toy data で implicit と NDCG 差 < 1% を確認(半日)
プロダクト調整1-2 週(共通)1-2 週(共通)
Docker image~300MB(Python + numpy/scipy)~50MB(Go static binary)
運用言語Python toolchain 追加UZU Go 基盤と統一
依存numpy / scipy / implicit の CVE 追従gonum のみ
デバッグ推論は Go、学習は Python でスタック分断全スタック Go
将来の拡張LightFM / TFRS に pip で即切替自前実装の改修(数百 LOC)

決定要因:

  • UZU の P1-P3 ロードマップは MF 系で完結(iALS → BPR / LogMF 比較)。LightFM / Neural CF の機動力を捨てても痛くない
  • 実質的な実装コスト差は 2-3 日(implicit 0.5-1 日 vs 自前 2-3 日)。プロダクト調整 1-2 週が支配項なので全体工数はほぼ同等
  • UZU チームは Go 中心。Python 専任 ML エンジニアがいない現状、Python toolchain の長期メンテ負担が大
  • Docker image 軽量化・デプロイ統一・CI/CD 簡素化の運用メリットが実装差を上回る

Go 実装の構成(目安) ​

項目行数
重み付き CSR sparse matrix 構築~80
Alternating Least Squares コアループ~150
Cholesky 解(gonum/mat 呼び出し)~50
並列化(goroutine + errgroup)~50
BigQuery 読み込み + AlloyDB 書き込み~100
ユニットテスト~100

negative confidence の扱い: c_{ui} < 0 のセルは p_ui = 0 + confidence |c_{ui}| として解釈する Hu 2008 iALS の拡張。コア数行の修正で実現できる。

実装正しさ検証の手順 ​

「自分が書いた iALS が論文通りの式を計算しているか」をプロダクト調整とは別作業として切り出す:

  1. 小さな合成データ(100 users × 50 items 程度)で Python implicit と Go 自前実装を同じ seed・同じハイパラで学習
  2. 各 (user, item) の予測スコアを突き合わせ、相関 0.99 以上・NDCG@10 差 1% 以内を合格基準とする
  3. 合格したら本番データへ。所要は半日〜1 日

検証用の Python スクリプトは一度書けば以降の改修でリグレッションチェックに流用できる。

将来 Python に戻す発火条件 ​

以下のいずれかが明確になったら Python + library への移行を再評価する:

  • LightFM の hybrid MF / content-aware 機能を本気で使いたい
  • TensorFlow Recommenders / Neural CF の実験ニーズが強くなる
  • Python ML エンジニアを雇って専任メンテ体制が組める

2. 学習ジョブ実行環境: GKE CronJob を採用 ​

2.1 比較した選択肢 ​

観点GKE CronJobCloud Run JobsVertex AIBigQuery ML
セットアップ工数中低高最低
ランニングコスト中(既存クラスタ共有)低(起動時のみ)高最低(クエリ課金)
既存運用との整合◎(nightly_batch_process と同居)△△○
ライブラリ自由度◎◎◎× (SQL のみ)
アルゴ拡張性◎◎◎△(MF 系のみ)
BigQuery アクセス○○○◎(ネイティブ)
Postgres / Redis 書き戻し◎(同一 VPC)△(VPC Connector 必要)△(Private SC 必要)△(エクスポート必要)
16.8K × 356K の処理時間数分数分数分数十秒

2.2 GKE を選んだ理由 ​

  • 既存 nightly_batch_process と同一運用パターンに乗せられる。新しい実行基盤を増やさない
  • 同一 VPC で AlloyDB / BigQuery / Redis にアクセスできる(追加設定不要)
  • Docker image の CI/CD パターンが既に整備済み(Go 基盤で統一)
  • Go 学習ジョブ(§1.4)は static binary で数十 MB、既存クラスタの空きノードで数分完了

2.3 採用しなかった理由 ​

  • Cloud Run Jobs: 最大メモリ 32GB / タイムアウト 60 分の制約が将来的にキツい可能性。さらに GKE があるので新基盤を増やす理由が弱い
  • Vertex AI: iALS レベルには完全にオーバースペック。MLOps パイプラインが必要になる段階で再評価する
  • BigQuery ML: ETL コード不要で最もシンプルだが、per-feedback 重み・negative confidence・time decay のカスタマイズ自由度が失われる。SQL 縛りで MF 系限定になり P2 以降の選択肢が狭まる

3. ベクトル保存: AlloyDB + pgvector を採用 ​

3.1 保存先の統一判断 ​

当初案: scenario = pgvector、user = Redis で使い分け → 却下。UZU 規模では分ける合理性が薄く、運用が複雑化する。

採用案: AlloyDB + pgvector 単一ストア。

観点pgvector 単一Redis 単一両方使い分け
運用シンプルさ◎○×
既存基盤との整合◎(Ent / マイグレーション)△(asynq 用と混在)△
ACID / backup◎△(RDB/AOF 設定必要)△
scenario 16.8K × 1288.6MB、余裕同左同左
user 356K × 128182MB、余裕同左、ただしホット DB 化同左
point lookup レイテンシ1-3 ms~µs-

pgvector の単一ストアで ms 単位の lookup が確保できる ため、Redis の µs 単位のアドバンテージは UZU の SLO(p95 < 150ms)に対して過剰最適化。

3.2 pgvector を最初から有効化する理由 ​

「scenarios 16.8K は線形スキャンで十分なので pgvector 拡張は不要」ではなく、最初から vector(128) 型で保存するのが得:

観点pgvector 使うfloat[] / BYTEA で自前
スキーマvector(128) の 1 カラム自前 encode / decode
距離演算<#>(内積), <=>(cos)を SQL でGo 側で自前実装
ANN 移行時CREATE INDEX ... USING hnsw を追加するだけスキーマ変換が必要
Ent 統合コミュニティの pgvector プラグイン自前 type 定義
SQL レベル top-KORDER BY user_vec <#> scenario_vec LIMIT N不可、全件フェッチして Go 側

拡張有効化コストは CREATE EXTENSION vector; の 1 回きり。

3.3 ANN インデックスを最初から張らない理由 ​

scenarios 16.8K 規模では、線形スキャンと ANN の速度差が埋もれる:

観点ANN なし(線形)HNSW / ScaNN
16.8K での検索時間2〜5 ms1〜3 ms
インデックス構築コストゼロ毎更新時に再構築 or incremental maintenance
チューニングパラメータなしef_construction, m, ef_search 等
運用知見不要必要

→ 速度差が小さく、運用コスト差が大きい。scenarios が 100 万件超えた時点、または user × user のような大規模検索が必要になった時点で CREATE INDEX ... USING hnsw を追加する。

3.4 AlloyDB 選定の追加情報 ​

AlloyDB は既に UZU 本番で稼働(.github/workflows/check_alloydb_high_availability.yml が存在)。

  • pgvector 拡張: サポート済み(v0.8.1)、2023 年から継続提供
  • ScaNN 拡張: 2024 年 10 月リリース、pgvector より高速・高精度
  • 4 層ツリーインデックス: 最大 100 億ベクトル対応(2026 アップデート)
  • 対応 PostgreSQL: 14 / 15 / 16 / 17

将来 scenario 数が数百万〜になった際、ScaNN 拡張に切り替える経路が確保されている。

4. 推論: Go プロセス in-memory 線形スキャン ​

4.1 推論レイテンシの内訳 ​

処理時間
user vector を 1 件取得(AlloyDB 同一 VPC)1〜3 ms
内積 1 回(128 次元、SIMD あり)~100 ns
scenario 16.8K 件との内積~2 ms
sort + top-K~1 ms
PreFilter(既プレイ除外等)1〜3 ms

Base Scoring + PreFilter 合計: 5〜10 ms(p50)、15〜20 ms(p95)

4.2 方式比較 ​

方式p50p95特徴
Go in-memory 線形スキャン5〜10 ms15〜20 msscenario 全件をプロセスに保持、SIMD 内積
pgvector SQL (<#>) 線形5〜15 ms15〜30 msAlloyDB 内で完結、fallback に使う
pgvector + HNSW3〜10 ms10〜20 ms16.8K では速度差が埋もれる
リモート ML サービス (Python gRPC)30〜100 ms100〜300 msネットワーク往復 2 回、不要

4.3 Go in-memory を選ぶ理由 ​

  • scenarios は 8.6MB で Go プロセスに全載せ可能(日次 reload)
  • SIMD 内積で 16.8K 件が 2 ms、pgvector SQL や ANN と実測差が小さい
  • リモート ML サービス化はネットワーク往復がボトルネック化するので不採用

ただし pgvector SQL 経由を fallback として用意(Go プロセスの起動時 reload 失敗時など)。

5. ranking_rate の廃止と scenario bias への吸収 ​

5.1 廃止判断の根拠 ​

現行 ranking_rate の各要素は、ALS モデルの内部表現に自然に吸収される:

implicit ALS の予測式:

score(u, s) = μ + b_u + b_s + <q_u, p_s>
              ↑    ↑    ↑
              全体  ユーザー  シナリオ
              平均  バイアス  バイアス

ここで b_s(scenario bias)は「このシナリオは全体的に受けやすい」係数で、これが ranking_rate の ratingFactor / playCountFactor / playTimeFactor に対応する。ALS 学習時に自動で得られる。

5.2 新規ユーザー向けスコアの再導出 ​

ALS 予測式で user 情報を「平均ユーザー」で代替すれば、非パーソナライズランキングが得られる:

score(new_user, s) = μ + b_s + <q̄, p_s>

これは現行 ranking_rate と同等の軽さで計算でき、かつ:

  • 係数の恣意性が消える(学習ベース)
  • 言語・地域・年齢セグメント別の q̄ で出し分けが可能
  • プレイ数が蓄積した時点で自動的にパーソナライズに切り替わる(fold-in)

5.3 残す要素 ​

ranking_rate の全要素が Base Scoring に吸収されるわけではない:

要素移行先
ratingFactor / playCountFactor / playTimeFactorBase Scoring の b_s に吸収
daysBonus(直近 21 日ブースト)Aggregate の recency decay として残す
priceBonus(価格ブースト)PostRerank のビジネスブーストとして残す

recency と business boost は ALS が扱わない概念なので、レイヤー分離が必要。

6. 評価の進め方 ​

6.1 iALS の効果を検証する指標 ​

P1 本番投入後、以下で iALS + negative confidence 採用の妥当性を検証:

  • Poor 評価作品群への推薦率: Poor 評価シナリオそのもの・およびそれと類似する作品が推薦に出る頻度。低いほど negative confidence が効いている
  • 推薦経由プレイ完了率(Primary KPI): 個人化の効果
  • Guardrail(Poor 率): 推薦経由プレイにおける Poor 評価比率が旧手法(ranking_rate)より悪化していないか
  • Coverage: ロングテールのカバー率、多様性の副次指標

6.2 モデル評価の判断フロー ​

P1: iALS + negative confidence を本番投入、ログ基盤整備
  ↓
P1 後半: 重み(Poor の負値、α 等)をチューニング、オフライン NDCG@K で最適化
  ↓
P2: BPR / LogMF をオフライン学習、iALS と NDCG@K / Recall@K / Coverage を比較
  ↓
  - BPR / LogMF が有意差で改善 (+x%) → A/B テスト実施
    - A/B で Primary KPI 改善確認 → 切替
    - Guardrail 劣化 → ロールバック
  - 有意差なし → iALS 継続、他の改善(Aggregate 展開・content embedding 等)へ

比較の閾値(有意改善 x%)は spec §6 D5 で合意する。

7. Context Gate を 2 フェーズに分ける根拠 ​

spec §3.4 では Context Gate を PreFilter(候補生成) と PostRerank(Post-filter / Rerank) の 2 フェーズに分けている。1 本の post-filter でまとめて処理する設計ではなく、あえて分離する理由:

7.1 効率 ​

「全員未プレイ」「人数適合」「言語適合」のようなハード制約は、score 計算の前に弾く方が無駄な計算を避けられる。

  • 全 16.8K 件に score を計算 → 95% がハード制約違反で除外、という使い方は計算の無駄
  • QuickMatch では候補が人数適合+全員未プレイで数百件に絞られる。N 人分の score 計算を 16.8K → 数百に減らす効果は大きい

7.2 正確性 ​

ハード制約違反は score がどれだけ高くても出してはいけない。score 後の filter は事故が起きやすい:

  • 浮動小数点誤差で top-K にハード違反が混入する
  • 多様性 rerank の途中でハード制約を忘れる実装ミスが発生する
  • 候補生成時点で除外しておけば、以降の処理で違反を心配する必要がない

7.3 Surface 別の候補空間 ​

Surface ごとに「候補となり得るシナリオ集合」が根本的に違う:

Surface候補空間
ForYou全シナリオ(− プレイ済み)
Event開催予定の event が紐付ける scenarios
Author対象 author に属する scenarios
QuickMatch人数適合 ∧ 全員未プレイ ∧ 言語適合 ∧ GM 条件

この違いを「post-filter でまとめて扱う」と surface ごとに異なる defensive 条件が散らばる。Pre-filter として先に絞る設計なら、各 surface の候補空間が関数として明示的になる。

7.4 業界事例 ​

Netflix / YouTube / Pinterest の推薦系は候補生成(Candidate Generation)と ranking を明確に分離している。UZU 規模では Candidate Generation を独立サービスにするまでは不要だが、2 フェーズの分離という概念的な切り分けは最初から持ち込む。

8. Quick Match 集約: Least Misery を主軸にする ​

Quick Match §2 では α × mean + (1-α) × min の重み付き合成を採用している。他の集団推薦戦略との比較:

集約戦略式性質Quick Match 適性
Averagemean(score_i)全員平均。最も素直ベースライン
Least Miserymin(score_i)誰か 1 人が嫌がる作品を推さない本命。集団プレイ体験は最低満足度が支配する
Most Pleasuremax(score_i)誰か 1 人が激ハマりすれば OK不向き(他が白ける)
Fairness分散を加味満足度のばらつきを抑える副次指標

8.1 Least Misery を選ぶ理由 ​

マダミスの集団プレイは「全員が一定以上楽しめて初めて成功」する体験。1 人でも不満足だと:

  • その 1 人がゲーム進行に集中できず、グループ全体の体験が劣化する
  • 次回の集団プレイへの参加意欲が下がる(guardrail 指標でも追跡)
  • Average や Most Pleasure では「平均は高いが 1 人が嫌がる」作品を推薦してしまう

集団プレイの体験品質は最低満足度によって支配されるという原則から、min が主軸として最も適切。

8.2 なぜ純粋な min ではなく α × mean + (1-α) × min か ​

純粋な min だと「全員がそこそこ好き」だが誰も熱狂しない凡庸な作品が選ばれがち。mean 成分を加えることで「全員そこそこ好きかつ一部は熱狂」な作品を優位にできる。α のチューニングで両者のバランスを調整する(初期値 0.5、spec §6 D9)。

8.3 Most Pleasure / Fairness を採用しない理由 ​

  • Most Pleasure: マダミスでは 1 人のスコア最大化は他メンバーの犠牲と直結する。採用すると「この作品を 1 人が猛烈に推して他は乗らない」状況を助長
  • Fairness(分散を加味): 「全員の満足度が同じくらい低い」作品を選びかねない。副次指標として diversity 確保には使えるが主軸には向かない

8.4 初心者ブーストを case 分岐で書かない ​

Quick Match §2 の「追加の文脈項」参照。「初心者がいるなら別式」という場合分けは境界付近でスコアがジャンプし、閾値の位置に意味が出てしまう。連続関数 β × beginner_ratio(G) × is_beginner_tag(s) で表現する。

9. API 設計: god struct ではなく Surface 別 Request struct + 共通入口 (C 案) ​

god struct (単一 Request struct に Surface discriminator + 全 surface の optional field を全部載せる設計) は QuickMatch に UserID を渡したり EventDetail に GroupUserIDs を渡したりといった違法状態が型上表現できてしまう。採用方針は Surface 別 Request struct を共通の SurfaceRequest interface で束ねる C 案 に全 Surface を一本化する構造 (MOBL-4289)。QuickMatch / SIMILAR のように viewer が複数いる Surface も同じ入口で扱い、per-user 内訳は Item.PerUserScores で返す。これで型安全と入口 1 本化を両立する。

観点god struct 版Surface 別 Request + 共通入口 (採用、C 案)
必須パラメータoptional field + 実行時検証型でコンパイル時強制 (EventDetail には EventID + ViewerID が必須)
Surface 追加のしやすさgod struct にフィールド追加+分岐増新しい Request 型と surfaces/ 配下の Strategy ファイルを 1 本追加するだけ
Surface Strategy 構造との整合god 関数内で N 本の Strategy が隠れるStrategy が surface 数だけ並ぶ(合致)
入口の共通処理god 関数内で混在共通入口 Recommend(ctx, SurfaceRequest) に集約
複数 viewer Surfacediscriminator で if 分岐GetViewerUserIDs() と Item.PerUserScores で吸収 (QuickMatch / SIMILAR が該当)

9.1 型安全の具体例 ​

god struct 設計だと以下のような違法状態がコンパイル通ってしまう:

go
// god struct 設計では違法状態が表現可能
Recommend(ctx, RecommendInput{
    Surface: SurfaceQuickMatch,
    // GroupUserIDs が nil でもコンパイル通る → runtime エラー
})

Surface 別 Request + 共通入口 (C 案) では、concrete 型の必須フィールドが型レベルで強制されるため違法状態が表現不可能:

go
r.Recommend(ctx, recommendation.EventDetailRequest{
    EventID:  7,          // ← フィールド未指定ならコンパイル通るが、値が 0 で意味ある検証になる
    ViewerID: 42,         // ← 同上
    Common:   recommendation.CommonOptions{K: 10},
})

// QuickMatch も同じ Recommend に統合。GroupUserIDs が GetViewerUserIDs() に
// そのまま出て、返り値は共通の *Response (items[].perUserScores に per-user 内訳)。
r.Recommend(ctx, recommendation.QuickMatchRequest{
    GroupUserIDs: []int{1, 2, 3},
    Common:       recommendation.CommonOptions{K: 10},
})

9.2 Surface Strategy 構造との整合 ​

spec §3 で「Strategy.Aggregate は Surface ごとに N 本」と定義している。god struct 設計だと入口は 1 関数で「N 本の Strategy」が隠れてしまい、アーキテクチャと実装が乖離する。C 案では ForYouRequest / EventDetailRequest / AuthorRequest / CollectionRequest / SimilarRequest / QuickMatchRequest が surface 数だけ並び、視覚的に整合する。共通入口 Recommend(ctx, SurfaceRequest) は surfaces.Registry.Build(req) にだけディスパッチを押しつけ、pipeline 本体には Surface if 文を置かない。

9.3 scenario 以外を返す Surface は別 method に逃がす ​

Recommend(ctx, SurfaceRequest) (*Response, error) は 1 本の Response (シナリオ列) を返す契約。QuickMatch / SIMILAR のように「集約スコア + per-user scores を伴う」Surface は Item.PerUserScores を optional にして Response に吸収する (MOBL-4289 で統合済み)。将来「scenario 以外を返す Surface」(event / author / user を推薦対象として返す等) が追加された場合は、Response に optional フィールドを足すのではなく別 method を切り出す:

Surface返り値型対応
FOR_YOU / QuickMatch / SIMILAR / EventDetail / Author / Collection*Response (シナリオ列 + optional PerUserScores)共通入口 Recommend(SurfaceRequest)
(将来) event / author / collection を推薦対象として返す[]EventRecommendation 等別 method (別 package も視野)
(将来) 友達推薦[]UserRecommendation別 package に分離 (Base Scoring から大改修)

返り値が同型 (シナリオ列) である限りは C 案の共通入口に載せる。Item に optional フィールドを追加する形で吸収できる差異 (per-user 内訳のような付加情報) は別 method に逃がさず、横断処理 (Strategy.PostRerank / assignment log / model version 固定) を 1 箇所にまとめる利点を優先する。

9.3.1 過去の B 折衷方針 (MOBL-4273 → MOBL-4289 で撤回) ​

MOBL-4273 で QuickMatch を導入した当初は、返り値型が他 Surface (*Response) と異なる *QuickMatchResponse を返す必要があるため、Recommender.RecommendQuickMatch という独立 method に切り出す B 折衷 を採用していた。MOBL-4289 で全 C 案に統一した経緯と判断基準は strategy-decisions の 2026-04-23 節を参照。

再び B 折衷を検討する基準: Item / Response への optional フィールド追加で吸収できない差分 (scenario ではなく event/author を返す、items の並びが N-best ではなくペア列、など) が出た場合のみ、その Surface だけ別 method に切り出す。

9.4 GraphQL Query も Surface 別 input に分ける ​

admin preview の query も同じ思想で Surface 別 input に分ける。god struct input (単一 AdminRecommendedScenariosInput に surface discriminator + 全 optional field を載せる形) は避け、adminForYouScenarios(input: AdminForYouInput!) / adminQuickMatchScenarios(input: AdminQuickMatchScenariosInput!) のように surface 別に独立 query を定義する。EventDetail / Author / Collection / Similar の admin query は各 Surface の Aggregate / PreFilter / PostRerank 実装 issue で追加する予定。

将来 client へ展開するときは、新しい surface 別 query を expose するのではなく 既存 topRankedScenarios / Scenario.similarScenarios 等の内部実装を recommendation usecase 呼び出しに差し替える 形で進める(spec §7.4)。client 側の expose 粒度は expose 時点で実需を見て決める。