0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

DBもAPIも知らない審議ロジック — deliberation-pure.ts で学ぶ「副作用ゼロ」のステート遷移ガード設計

0
Posted at

この記事の要約(TL;DR)

  • TruthHiveの「多数決+再審議」ロジックは deliberation-pure.ts として、DB・API・トランザクションから完全に切り離された純粋関数で実装されている。
  • 投稿は deliberating(審議中)→ judged(審議済)→ re_deliberating(再審議中)という状態を遷移し、その遷移可否を判定するガード条件は shouldReexamine() という一つの純粋関数に集約されている。
  • 同数時の判定優先順位は incorrect → not_fact → correct。実装自体は素朴な配列操作だが、そこに埋め込まれているのは「疑わしきは罰する」というプロダクト思想の優先順位である。
  • DBからロジックを分離するのは今さら誇るような新規性ではない。だが、AIが破壊的にコードを書き換える時代において、この分離は「壊れたら即座にテストで検知できる」という物理防御バリアとしての価値を持つ。
  • 副作用(Advisory Lock・トランザクション・WebSocket通知)は上位のオーケストレーション層に閉じ込め、純粋関数は「呼ばれて計算するだけ」の存在に徹している。

1. はじめに:なぜ審議ロジックを「純粋関数」として切り出すのか

TruthHive は、コミュニティの投票と AI の調査を組み合わせて真偽を判定する検証プラットフォームです。判定は correct / incorrect / not_fact の3値(shared/constants/judgment-type.ts)で表現され、投稿は deliberating(審議中)→ judged(審議済)→ re_deliberating(再審議中)というステータスを遷移していきます。

この「多数決で確定し、条件が揃えば再審議に戻る」というロジックは、プロダクトのDNAとも言える中核部分です。だからこそ最初に立てた設計方針は一つでした。

判定ロジックを、DBアクセスやAPI呼び出しと同じ関数の中に混ぜない。

当然ながら、「ロジックをDBやAPIから切り離す」こと自体は目新しい話ではありません。ドメイン駆動設計やヘキサゴナルアーキテクチャを知るエンジニアであれば、「Pure Domain Logic」「Hexagonal Core」といった名前で何十年も語られてきた古典中の古典です。これを"発明"であるかのように語るつもりは一切ありません。

しかし、2024年以降のAIネイティブ開発——コード生成AIが数百行単位でファイルを書き換え、時には既存ロジックごと破壊的にリライトしてしまう「バイブコーディング」の現場では、この古典的ルールの意味合いが変わります。DBやAPIから独立した純粋関数は、AIによる破壊的変更に対して入出力の契約というテストで機械的に検知できる物理防御バリアとして機能します。つまり「美しい設計だから分離する」のではなく、「AIに壊されても即座に気づけるようにするために分離する」という、生存戦略としての意味合いが強くなっているのです。

この方針のもとに実装されたのが server/features/kernel/services/deliberation/deliberation-pure.ts です。ファイル冒頭のコメントにもこう明記されています。

審議判定の純粋ロジック(DB / 設定キャッシュ非依存 — ユニットテスト対象)。

以降、このファイルの実コードを見ながら、設計判断を追っていきます。


2. 全体像:真偽判定のライフサイクル

まず、投稿がたどるステータス遷移を Mermaid で示します。TruthHive の審議ステータス(post-deliberation-status.ts)と、プロダクト仕様に定義されたユーザーフローに基づく状態遷移図です。

ポイントは、judged(審議済)が終端ではないことです。審議済になった後も、未投票ユーザーは異議票(予約票)を投じることができ、その票が反映された結果「現行の判定」と「投票データから計算し直した多数派」が食い違えば、re_deliberating(再審議中)へ遷移します。

このライフサイクル全体のうち、「多数決の計算」と「再審議に遷移すべきかどうかのガード判定」を担うのが、これから解説する純粋関数群です。あらかじめ断っておくと、これは複雑な有限状態機械のライブラリを実装しているわけではありません。「今のステータスと投票結果を見て、次に進んでよいかを判定する」という素朴なガード条件の集合体です。その素朴さ自体が、この記事で伝えたいポイントでもあります。


3. コード解説(deliberation-pure.ts)

3.1 入力インターフェース

deliberation-pure.ts が依存する型は、次の3つの外部importに限定されています。

// server/features/kernel/services/deliberation/deliberation-pure.ts
import type { JudgmentType, Post, PostStatusHistoryRecord, Vote } from "@db/schema";
import { JUDGMENT_TYPE } from "@kernel/constants/judgment-type";
import { POST_DELIBERATION_STATUS } from "@kernel/constants/post-deliberation-status";

Post / Vote / PostStatusHistoryRecord はDrizzle由来の型のみの輸入であり、db インスタンスや drizzle-orm のクエリビルダーはどこにも登場しません。つまりこのファイルは「DBの形をした値」を受け取って計算するだけで、DBそのものには一切触れられない設計になっています。

判定ラベルのSSOT(Single Source of Truth)は別ファイル shared/constants/judgment-type.ts にあります。

// shared/constants/judgment-type.ts
export const JUDGMENT_TYPE = {
  CORRECT: "correct",
  INCORRECT: "incorrect",
  NOT_FACT: "not_fact",
} as const;

export const JUDGMENT_TYPE_VALUES = [
  JUDGMENT_TYPE.CORRECT,
  JUDGMENT_TYPE.INCORRECT,
  JUDGMENT_TYPE.NOT_FACT,
] as const;

export type JudgmentType = (typeof JUDGMENT_TYPE_VALUES)[number];

「間違っている(incorrect)」と「そもそも事実ではない(not_fact)」を分離した3値モデルにしている点も設計上の意図で、DBのenum・Zodスキーマ・APIバリデータは全てこの定数から派生します(二重定義を禁止)。

3.2 投票の集計(getVoteCounts / getVoteCountsFromDenormalized)

まず有効票だけを対象に、判定ラベルごとの件数を数える関数です。

// server/features/kernel/services/deliberation/deliberation-pure.ts
/** 有効投票のみから判定別カウントを算出 */
export function getVoteCounts(votesArr: Vote[]): Record<JudgmentType, number> {
  return votesArr
    .filter((v: Vote) => v.is_valid === true)
    .reduce((acc: Record<JudgmentType, number>, v: Vote) => {
      acc[v.judgment] = (acc[v.judgment] || 0) + 1;
      return acc;
    }, {} as Record<JudgmentType, number>);
}

ここで重要なのは is_valid === true によるフィルタです。TruthHiveでは投票を物理削除せず、無効化したい票は is_valid = false にした上で新規の票を previous_vote_id で紐づけてINSERTする、という運用ルール(監査ログとしての透過性)を取っています。そのため「今この瞬間の有効な意思表示」だけを数えるには、この一行のフィルタが不可欠です。

一覧画面など、毎回全投票行を引き直すのが重いケース向けに、非正規化カウンタ(posts.votes_count_*)だけを根拠にする軽量版も用意されています。

// server/features/kernel/services/deliberation/deliberation-pure.ts
/** 一覧などで `posts.votes_count_*` 非正規化カウンタのみを根拠にする場合 */
export function getVoteCountsFromDenormalized(post: {
  votesCountCorrect?: number | null;
  votesCountIncorrect?: number | null;
  votesCountNotFact?: number | null;
}): Record<JudgmentType, number> {
  return {
    [JUDGMENT_TYPE.CORRECT]: Number(post.votesCountCorrect ?? 0),
    [JUDGMENT_TYPE.INCORRECT]: Number(post.votesCountIncorrect ?? 0),
    [JUDGMENT_TYPE.NOT_FACT]: Number(post.votesCountNotFact ?? 0),
  };
}

同じ「Record<JudgmentType, number> を返す」という契約を守ることで、後続の determineFinalJudgment はどちらの集計方式から来た値かを気にせず動かせます。関数の入出力の型を揃えるだけで、呼び出し側のロジックを完全に使い回せるのは、純粋関数設計の地味だが効く恩恵です。

合計票数はさらにシンプルです。

// server/features/kernel/services/deliberation/deliberation-pure.ts
/** 判定別カウントの合計 */
export function getTotalVotes(voteCounts: Record<JudgmentType, number>): number {
  return Object.values(voteCounts).reduce((sum, count) => sum + count, 0);
}

3.3 多数決判定と同数時の優先順位(determineFinalJudgment)

ここが多数決ロジックの核心であり、同時に一番ツッコミを受けやすい箇所でもあります。

// server/features/kernel/services/deliberation/deliberation-pure.ts
/**
 * 有効投票の多数派を最終判定とする。同数の場合は優先順位で決定。
 */
export function determineFinalJudgment(
  voteCounts: Record<JudgmentType, number>,
): JudgmentType {
  const judgments = Object.entries(voteCounts);
  if (judgments.length === 0) return JUDGMENT_TYPE.INCORRECT;
  const priorityOrder: JudgmentType[] = [
    JUDGMENT_TYPE.INCORRECT,
    JUDGMENT_TYPE.NOT_FACT,
    JUDGMENT_TYPE.CORRECT,
  ];
  judgments.sort(([typeA, countA], [typeB, countB]) => {
    if (countB === countA) {
      return (
        priorityOrder.indexOf(typeA as JudgmentType) -
        priorityOrder.indexOf(typeB as JudgmentType)
      );
    }
    return countB - countA;
  });
  return judgments[0][0] as JudgmentType;
}

※注: 判定要素数が3個(correct/incorrect/not_fact)と固定であるため、実実装では直感的な配列操作を採用していますが、本質は「同数時にどちらへ倒すかというプロダクト思想の優先順位がコードに明記されている点」にあります。

注目すべきは2点です。

  1. 票が一つも無い場合のフォールバックは INCORRECT。判定不能を「正しい」側に倒さない、保守的な設計です。
  2. 同数時の優先順位は incorrect → not_fact → correct。つまり票が拮抗した場合、「正しい」と積極的に認定するにはより強い合意(過半数の明確な優位)が必要で、疑わしきは罰する側に倒す設計になっています。ディープフェイクや偽情報対策プラットフォームとして、この「同数なら correct を選ばない」という優先順位づけ自体が思想の表明になっています。

3.4 再審議への遷移判定(shouldReexamine)

最後に、審議済(judged)の投稿を再審議(re_deliberating)に戻すべきかを判定する関数です。

// server/features/kernel/services/deliberation/deliberation-pure.ts
/**
 * 審議済み投稿が「再審議」条件を満たすか(多数派判定が現行判定と異なるか)。
 */
export function shouldReexamine(
  post: Post & { statusHistory?: PostStatusHistoryRecord[]; votes?: Vote[] },
  voteCounts: Record<JudgmentType, number>,
): boolean {
  if (
    !post.statusHistory?.length ||
    post.status !== POST_DELIBERATION_STATUS.judged ||
    !post.judgment
  )
    return false;
  const totalVotes = getTotalVotes(voteCounts);
  if (totalVotes === 0) return false;
  const currentJudgment = post.judgment;
  const newJudgment = determineFinalJudgment(voteCounts);
  return newJudgment !== currentJudgment;
}

ガード条件を上から読むと、この関数がどれだけ「誤発火」を警戒しているかが分かります。

  • statusHistory が空 → まだ一度も状態遷移していない投稿は対象外
  • post.status !== judged → そもそも審議済でなければ再審議もクソもない
  • !post.judgment → 判定が確定していない投稿は比較のしようがない
  • totalVotes === 0 → 票が無いのに「多数派が変わった」と誤判定しない

すべての条件をクリアして初めて、determineFinalJudgment を再計算し、現行判定とぶつけます。この「新しい多数決の結果を、現行判定と純粋に比較するだけ」というシンプルさこそが、Mermaid図で描いた judged → re_deliberating の矢印の正体です。派手なアルゴリズムは何もありません。ガード条件を漏れなく列挙し、比較一発で結論を出す——それだけです。


4. 副作用の分離とDB層でのガード

ここまでの4関数(getVoteCounts / getVoteCountsFromDenormalized / getTotalVotes / determineFinalJudgment / shouldReexamine)には、共通する特徴があります。

  • 引数として渡された値以外を一切参照しない
  • await が一度も出てこない(非同期処理・DBアクセスが存在しない)
  • 例外を投げず、常に値を返す

つまり「この投稿とこの投票データを渡せば、必ず同じ判定が返ってくる」ことが型レベルでも実行レベルでも保証されています。

では、実際に投票を受け付けてDBを更新する層はどうなっているのか。プロダクト仕様(04_product_spec.md)によれば、投票API(POST /api/public/votes)は次のようなミドルウェア連鎖を経由します。

認証 → CSRF → レート制限 → SQL検査 → 投票可否チェック
  → トランザクション内 processVote
    → 集計・ステータス更新(ここで deliberation-pure.ts の関数群を呼ぶ)
  → WebSocket通知

determineFinalJudgmentshouldReexamine が呼ばれるのは、この「トランザクション内 processVote」という副作用の塊の内部からです。純粋関数はあくまで「判定を計算する部品」であり、それをいつ・どうDBに書き込むかは上位のオーケストレーション層(processVote)の責務として明確に分離されています。

審議ロジック側で二重計上・二重確定を防ぐために、プロダクト仕様には次の仕組みが明記されています。

  • Advisory Lock / FOR UPDATE: 二重投票・二重判定確定・SEOのTOCTOU(Time-of-check to time-of-use)を横断的に封じる
  • 予約票と promoteReservedVotes: 審議済への異議は一旦「予約」として保持し、再審議開始のタイミングでまとめて昇格させることで、判定確定の瞬間と票の反映タイミングがズレて不整合を起こすのを防ぐ
  • is_valid の論理無効化+新規INSERT: 票を物理削除せず、previous_vote_id で履歴を辿れる形にすることで、後から「なぜこの判定になったか」をいつでも純粋関数に再入力して検証できる

つまりレイヤー構造としては、以下のようになります。

副作用(DB・ロック・通知)は必ずトランザクション層が抱え、判定計算は純粋関数に問い合わせるだけ。この境界線がある限り、判定ロジックにバグ修正やテストケース追加をしても、DBスキーマやロック戦略には一切手を触れずに済みます。逆にAdvisory Lockの調整やスキーマ変更をしても、determineFinalJudgment のテストスイートは無傷のままです。

なお、「判定・検証を先に行い、条件を満たさなければ書き込み自体を実行しない」という同種のパターンは、審議ロジック固有のものではありません。同じプロダクト内の別ドメイン——X投稿キューの状態更新(server/features/domains/x-poster/core/unified-tweet-transition.ts)——でも同じ単一入口パターンが採用されています。詳細はこの記事の本筋から外れるため割愛しますが、この設計原則がプロダクト全体を貫く一貫した方針であることだけ触れておきます。


5. まとめ:AI時代に「状態遷移のガード」を純粋関数として保護する価値

deliberation-pure.ts は、行数だけ見れば決して大きなファイルではありません。中身も、複雑な有限状態機械のフレームワークではなく、素朴な filter / reduce / sort / 条件分岐の組み合わせです。「これはただのif文の集まりだ」と言われれば、その通りだと認めます。

しかし、この記事で伝えたかったのはアルゴリズムの高度さではありません。この数十行が担っているのは「集団の意思決定をどう機械的に確定させるか」というプロダクトの根幹であり、そして何より重要なのは、この根幹がDB・API・トランザクションのどれとも結合していないという事実そのものです。

この記事で見た設計判断を改めて整理すると次のようになります。

  • 判定ロジックをDB・API・トランザクションから完全に切り離し、入力→出力が決定的な純粋関数として実装した(これ自体は古典的な設計原則であり、目新しさを主張するものではない)
  • 同数時の優先順位(incorrect → not_fact → correct)のようなプロダクト思想そのものを、素朴なコードとして固定した
  • shouldReexamine のガード条件により、再審議への遷移条件を1箇所に集約し、誤発火を構造的に防いだ
  • 副作用(Advisory Lock、トランザクション、WebSocket通知)は上位のオーケストレーション層に閉じ込め、純粋関数はそこから「呼ばれるだけ」の存在にした

AIによるコード生成・自動リファクタが当たり前になった開発現場では、「古典的な設計原則を守っているかどうか」を判定する主体が、レビュアーの目だけでなくAI自身の書き換え行為そのものになりつつあります。副作用を持たない純粋関数であれば、AIがどれだけ大胆にファイルを書き換えても、入出力の対応表と照らし合わせるだけで壊れたかどうかを機械的に検知できます。逆にDBやロックが絡む副作用の塊は、そう簡単には検証できず、人間が設計判断を持って慎重にレビューする必要がある領域のままです。

「状態遷移のガード条件」を純粋関数として切り出し、そこだけは何が起きても計算結果が決定的である——この古典的な境界線を、AIが破壊的に書き換える時代の物理防御バリアとして再定義すること。それが、この設計から得られた実務上の結論です。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?