コンテンツにスキップ

ルールエージェントの判断履歴

作成日: 2026-07-05

Rule-based agent が どの option を、なぜ、どのスコアで選んだか を JSONL に出す仕組み。「壊れている agent の原因特定」「立ち回りが strategy.md 通りか確認」「tuning 効果の可視化」に使う。

対応実装:

関連ドキュメント:

目的

現在の rule agent は RuleDecision.scores: list[float] を返すだけで、 なぜそのスコアになったか を後から追跡できない。壊れている agent (例: mega_venusaur_ex_grass_sustain 勝率 0.045) の原因が「回復ループで END_TURN に至らない」なのか「攻撃可能ターンで sustain を優先している」なのかを、trace なしでは特定できない。

trace はこの説明性を提供する。各 decision で:

  • どの option を選んだか
  • スコアの内訳と reason 文字列
  • どの layer (universal / archetype / deck) が寄与したか
  • turn goal / turn budget

データフロー

flowchart LR
    CLI["CLI: --rule-explain N<br/>--rule-explain-output PATH"]:::cli
    INS["_install_rule_trace_writer(args)"]:::wire
    SW["set_writer(TraceWriter)"]:::wire

    AGT["Agent.act(obs)"]:::agent
    DEC["RuleDecision (with trace)"]:::val
    POL["rule_pool_policy_factory.policy()"]:::policy
    GET["get_writer()"]:::wire
    W["TraceWriter.record(...)"]:::writer

    JSONL["output.jsonl<br/>(one line per decision)"]:::out

    CLI --> INS --> SW
    AGT --> DEC --> POL --> GET --> W --> JSONL

    classDef cli fill:#e3f2fd,stroke:#1976d2,color:#000
    classDef wire fill:#fff3e0,stroke:#f57c00,color:#000
    classDef agent fill:#f3e5f5,stroke:#7b1fa2,color:#000
    classDef val fill:#f1f8e9,stroke:#689f38,color:#000
    classDef policy fill:#fff9c4,stroke:#f9a825,color:#000
    classDef writer fill:#fce4ec,stroke:#c2185b,color:#000
    classDef out fill:#e8f5e9,stroke:#388e3c,color:#000

設計ポイント: writer は プロセス singleton (trace.py の _active_writer)。selfplay のスタック全体に writer 引数を配線しない。default は None で、CLI が明示的にセットした時だけ record される。

公開API

TraceEntry

@dataclass(frozen=True)
class TraceEntry:
    option_index: int
    score: float
    reason: str
    layer: str = "deck"       # "universal" | "archetype:<name>" | "deck"
    tags: tuple[str, ...] = ()

RuleDecision

@dataclass(frozen=True)
class RuleDecision:
    selected: list[int]
    scores: list[float]
    target_policy: list[float] | None = None
    meta: dict[str, Any] = ...
    trace: tuple[TraceEntry, ...] = ()      # NEW
    turn_goal: str | None = None            # NEW
    turn_budget: dict[str, int] | None = None  # NEW

BaseRuleAgent.trace(obs, scores) フックが RuleDecision.trace を埋める。default は空 tuple なので凍結 agent は影響を受けない。

TraceWriter

writer = TraceWriter(output_path, max_games=None)  # max_games=None → 無制限
writer.record(
    game_id="...",
    step_index=0,
    agent_id="raging_bolt_ogerpon",
    select_context=SelectContext.PLAY_ITEM_OR_SUPPORTER,
    decision=rule_decision,      # RuleDecision
    options=obs.select.option,   # 任意 (option の meta サマリを追加保存)
)
writer.close()
  • スレッドセーフ (内部 Lock)
  • close() 後の record は no-op
  • max_games を超える game_id は 静かにスキップ (無制限に肥大化しない)

プロセス singleton

from pca.rule_agents.trace import set_writer, get_writer

set_writer(TraceWriter(...))   # 有効化
get_writer()                    # 呼び出し側で参照
set_writer(None)                # 無効化

コマンドラインからの実行

uv run python -m pca.training.selfplay \
  --rule-agent-deck-pool \
  --rule-agent-config configs/rule_agents.yaml \
  --games 20 \
  --rule-explain 20 \
  --rule-explain-output data/rule_trace/2026-07-05.jsonl

要点:

  • --rule-explain N — 最大 N 個の distinct game_id を記録
  • --rule-explain-output PATH — 出力先 JSONL (親ディレクトリは自動作成)
  • N=0 (default) の時は writer をセットしないので 性能影響ゼロ

JSONL 形式

一行あたり 1 decision:

{
  "game_id": "g001",
  "step_index": 12,
  "agent_id": "raging_bolt_ogerpon",
  "select_context": 3,
  "selected": [1],
  "scores": [1000.0, 22000.0, 8000.0],
  "trace": [
    { "option_index": 0, "score": 1000.0, "reason": "fallback", "layer": "universal", "tags": [] },
    {
      "option_index": 1,
      "score": 22000.0,
      "reason": "Akamatsu: no Lightning in hand",
      "layer": "deck",
      "tags": ["supporter"]
    },
    {
      "option_index": 2,
      "score": 8000.0,
      "reason": "Boss setup pressure",
      "layer": "deck",
      "tags": ["supporter"]
    }
  ],
  "turn_goal": "SETUP",
  "turn_budget": { "supporter_left": 1, "attach_left": 1 },
  "option_summaries": [
    { "type": 5, "cardId": 1198, "area": 0, "index": 3 },
    { "type": 5, "cardId": 1182, "area": 0, "index": 5 }
  ]
}

使い方の典型例

1. 壊れている agent の原因特定

uv run python -m pca.training.selfplay \
    --rule-explain 20 \
    --rule-explain-output data/rule_trace/mega_venusaur.jsonl \
    ... (他の args)

# 「END_TURN が selected になった step」を抽出
jq 'select(.trace[]?.reason | contains("end turn"))' \
    data/rule_trace/mega_venusaur.jsonl | head -50

2. tuning 効果の検証

チューニング前後で trace を録り、選ばれる option の分布 が変わったか確認。数値だけでは見えない挙動変化を追える。

3. golden fixture の種

trace 中の代表 decision をピックし、 tests/rule_agents/golden/<agent>/<scenario>.json に obs snapshot を保存 → expected.yaml で期待挙動を宣言 (Phase 3)。

設計判断

なぜ singleton なのか

writer 引数を関数チェーン全体に配線するのは侵襲的。selfplay の中に policy factory があり、その中に act() があり、深いネスト。singleton なら:

  • 既存 API を変えない (.policy(obs) -> PolicyDecision のシグネチャそのまま)
  • CLI 側で set/close する 2 行だけで済む
  • テストは set_writer(None) で無効化して他テストと干渉しない

なぜ max_games による cap を用意するか

長時間の selfplay で trace を全部書くと JSONL が数百 MB になる。実際に見たいのは最初の数ゲームなので、cap で肥大化を防ぐ。cap 超過の game は静かにスキップ (record はエラーにならない) 挙動にしてある。

なぜ trace は tuple なのか

  • RuleDecisionfrozen=True の dataclass。mutable list を持つと等価性 (hashable ではないが eq) の意味が曖昧になる
  • tuple の方が生成コストがわずかに軽い
  • 呼び出し側で tuple(entries) するだけ

凍結 agent との後方互換

BaseRuleAgent.trace(obs, scores) は default で () を返す。凍結 agent の main.py はこのフックを override しないので trace は常に空。writer がセットされていても "空エントリの record" しか書かれない (trace: [] で 1 行、ただし selected と scores は書かれる)。

テスト方針

tests/rule_agents/test_trace.py (8 tests):

テストクラス 何を確認
TraceWriterTest JSONL 出力 / max_games cap / close 冪等性 / option summaries
SingletonTest set/get の一貫性、None default

既知の制約

  1. game_id 抽出の best-effort: CABT の obs 内 gameId / game_id / matchId を順に見る。どれも無いとき None になり、max_games cap の計算が「不明ゲームは cap 未達なら記録」というルールで動く
  2. 並列 workers 対応が未検証: プロセス singleton なので --workers > 1 だと各 worker プロセスで 別々の writer が独立に動く。同じ output path を指すと file が競合する。当面は --workers 1 前提
  3. step_index の意味: CABT の current.step を使うが、CABT 版の step の意味と食い違う可能性あり (要確認)
  4. trace の中身は agent 依存: 現時点で凍結 agent は空 trace を返す。意味のある trace は redesign 対象 agent (raging_bolt 以降) で埋める

変更履歴

  • 2026-07-05: 初版。TraceEntry / RuleDecision 拡張、TraceWriter、CLI wire。