Accept first survey batch + add commit-hash post-processing

Spot-check of the three drafts gemini produced in the --max 3
survey smoke test: all cited file paths exist, line numbers and
function names land within 1-2 lines of actual symbols
(game.pyx:217 cdef class GameState, evaluate.py:220 batched-entropy
block, trainer.py:892 _evaluate_parallel, action_distribution at
evaluate.py:238 with cited code at line 250 inside it). Numbers
cross-checked against archives match. Conclusions preserved.

The one systemic weakness was the Last-verified commit field:
gemini left a `<short-hash>` placeholder, a literal `HEAD`, or
omitted the commit entirely depending on the call. Fixed in two
places:

1. Manually patched the three drafts before acceptance and copied
   them into docs/research/.
2. Added _current_commit_sha and _post_process_draft helpers to
   both librarian_survey.py and librarian_promote.py. The drafts
   now go through `**Last verified:**` line normalization that
   substitutes today's date and `git rev-parse --short HEAD`
   before being written to disk. Future runs converge
   deterministically.

Net: docs/research/ gains classic-port-notes.md,
deep-cfr-batched-evaluation.md, and deep-cfr-evaluation-profile.md.
12 archive entries remain unprocessed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-08 01:52:08 +09:00
co-authored by Claude Opus 4.7
parent b0b385591b
commit 5c221fb3c6
6 changed files with 209 additions and 5 deletions
+27
View File
@@ -235,6 +235,33 @@ non-promotable), or `<stem>.ERROR.txt` (CLI failure).
caps the number of archives processed per run, useful for smoke caps the number of archives processed per run, useful for smoke
tests or cost control. tests or cost control.
### Smoke test results (2026-05-08, gemini, --max 3)
3 drafts, 0 skips, 0 errors. Spot-check verified:
- All cited file paths exist; line numbers and function names
resolve to within 12 lines of the actual symbols
(`game.pyx:217` `cdef class GameState`, `evaluate.py:220`
batched-entropy block, `trainer.py:892` `_evaluate_parallel`,
etc.).
- Numbers cross-checked against archives match (61.83/38.92/14.83
eval seconds; 4.05/2.88 advantage train seconds).
- Conclusions preserved.
- One systemic weakness: gemini did not run `git` to resolve HEAD,
leaving `commit <short-hash>` placeholder, literal `commit
\`HEAD\``, or omitting the commit field. Fixed in two places:
the three drafts were patched manually before acceptance, and
both `librarian_promote.py` and `librarian_survey.py` now
post-process the LLM's output to rewrite the `**Last verified:**`
line with the real short SHA from `git rev-parse --short HEAD`
before writing to disk. Future runs converge to the right header
deterministically.
All three drafts accepted into `docs/research/`:
`classic-port-notes.md`, `deep-cfr-batched-evaluation.md`,
`deep-cfr-evaluation-profile.md`. 12 archive entries remain
unprocessed for the next survey run.
## Stage 2 remaining ## Stage 2 remaining
- MEMORY.md drift fixup mode (read drift report, propose one-line - MEMORY.md drift fixup mode (read drift report, propose one-line
+31
View File
@@ -0,0 +1,31 @@
# Classic Game Port Architecture
**Last verified:** 2026-05-08, commit `b0b3855`
**Source:** docs/archive/classic-port-notes.md
The "classic" game port serves as the fundamental layer of the `coolrl-lost-cities` project, representing a focused extraction of the Lost Cities game from the legacy `coolrl` repository. This architectural foundation was established to isolate the core mechanics of the two-player card game from the experimental "tiers" (simplified variants tier0 through tier3) used in earlier research. By centering the repository on the full classic ruleset, the project provides a stable, high-performance API that serves both as a playable game and a rigorous training environment.
## Question: Architectural Scope and Design
How is the Lost Cities "classic" game structured and scoped to serve as the foundation of the repository while remaining decoupled from specific training algorithms?
## Code Reference
- `src/coolrl_lost_cities/games/classic/game.pyx`: The core high-performance game logic and state management.
- `src/coolrl_lost_cities/games/classic/env.py`: The standard environment wrapper for algorithmic interaction.
- `src/coolrl_lost_cities/games/classic/pygame_pvp.py`: The graphical interface for human-to-human play and debugging.
## Analysis and Derivation
The transition from the legacy `coolrl` codebase to this standalone repository involved a deliberate narrowing of scope. The primary design goal was to treat the classic game as the initial concrete implementation under `coolrl_lost_cities.games`, avoiding the complexity of a variant registry or broad abstraction layers before they were strictly necessary.
A key technical decision was the use of an in-process Cython implementation for the game state. By defining `cdef class GameState` (see `src/coolrl_lost_cities/games/classic/game.pyx:217`), the project achieves C-level performance for state transitions, legal action masking, and scoring. This efficiency is critical for compute-intensive search algorithms like Deep Counterfactual Regret Minimization (Deep CFR), where the overhead of pure Python state management would be prohibitive.
The architecture strictly separates the game rules from the training infrastructure. While the classic game provides the necessary hooks for reinforcement learning—such as observation vectors and reward signals—it does not depend on any specific learning library. This separation ensures that the game logic remains verifiable and readable, centered on the rules and state transitions rather than the requirements of a particular neural network architecture.
Furthermore, the "classic" designation is specifically applied to the five-expedition version of the game. This naming convention leaves room for future variants, such as six-expedition versions, without requiring a breaking change to the core package structure. The removal of separate native backends in favor of a single, highly-optimized Cython backend simplifies the build process and ensures consistency between local play and large-scale training runs.
## Practical Implication
The resulting package structure allows developers to interact with the game through multiple interfaces: a raw Cython API for high-performance search, a Gym-like environment for reinforcement learning, and a Pygame-based GUI for manual verification. This modularity means that improvements to the game logic (e.g., scoring optimizations in `score_expedition` at `src/coolrl_lost_cities/games/classic/game.pyx:188`) automatically benefit all downstream consumers, from the training loops to the interactive bots.
## References
- `src/coolrl_lost_cities/games/classic/game.pyx`: Core logic, state cloning, and legal action generation.
- `src/coolrl_lost_cities/games/classic/env.py`: Environment state management and step logic.
- `docs/archive/classic-port-notes.md`: Original design notes regarding the extraction and scoping.
@@ -0,0 +1,33 @@
# Batched and Parallel Evaluation in Deep CFR
**Last verified:** 2026-05-08, commit `b0b3855`
**Source:** docs/archive/deep-cfr-batched-evaluation-2026-05-07.md
## Question
Evaluation of Deep CFR strategies against heuristic opponents can be a major bottleneck during training, especially when using CUDA for network inference. How can batched inference and parallel execution be leveraged to minimize this cost without introducing synchronization overhead?
## Code reference
- `src/coolrl_lost_cities/games/classic/deep_cfr/evaluate.py`, `StrategyNetPolicy.select_actions_batch` (line 174): Implements batched policy network inference, allowing multiple games to share a single GPU forward pass.
- `src/coolrl_lost_cities/games/classic/deep_cfr/evaluate.py` (line 220): Performs batched entropy calculation directly on the GPU using Torch tensors.
- `src/coolrl_lost_cities/games/classic/deep_cfr/trainer.py`, `DeepCFRTrainer._evaluate_parallel` (line 891): Orchestrates parallel evaluation across different opponents using `ProcessPoolExecutor`.
## Analysis
The primary bottleneck in CUDA-based evaluation is the overhead of launching small GPU kernels for single-state network inference. By batching evaluation games, we can saturate the GPU's compute units more effectively. Empirical results from May 2026 show that increasing the evaluation batch size to 64 reduced evaluation time from approximately 61.8 seconds to 14.8 seconds per iteration.
A critical refinement in the batched implementation was the handling of policy entropy. Initial versions that calculated entropy per-row on the CPU incurred significant synchronization penalties because each row required a GPU-to-CPU transfer. Moving the entropy calculation into the Torch post-processing pipeline—specifically calculating it directly on the `probs_tensor` (line 220)—ensures that the computation remains on the device and only the final results are transferred back to the host in bulk.
Once network inference is batched, the remaining bottleneck often shifts to the CPU-bound logic of heuristic opponents (e.g., `safe_heuristic_strict`). Parallelizing the evaluation across multiple workers allows the trainer to evaluate against multiple opponents simultaneously. For a single iteration profile, using 4 parallel workers reduced wall-clock evaluation time from 14.8 seconds to 6.4 seconds, achieving a ~2.3x speedup.
## Practical implication
- **Enable Batching:** For GPU-accelerated training, always configure `evaluation.batch_size` (typically 64 or 128) to minimize kernel launch overhead and maximize throughput.
- **Consolidate Device Operations:** Keep post-inference operations (legal masking, softmax, entropy) in Torch tensors to avoid blocking the GPU with frequent host-device synchronizations.
- **Parallelize Opponents:** Set `evaluation.num_workers` to match the number of opponents being evaluated (up to the available CPU cores). This effectively hides the latency of slower heuristic bots behind the network inference of others.
## References
- `docs/archive/deep-cfr-batched-evaluation-2026-05-07.md`
- `docs/performance.md`
@@ -0,0 +1,52 @@
# Deep CFR Evaluation Performance
**Last verified:** 2026-05-08, commit `b0b3855`
**Source:** `docs/archive/deep-cfr-evaluation-profile-2026-05-07.md`
## Question
Why is CUDA evaluation significantly slower than CPU evaluation in the current Deep CFR implementation, and how can evaluation throughput be improved?
Short answer: **Model forward latency (batch size 1) dominates evaluation wall-clock.** For the current small-model architecture, CUDA kernel launch and synchronization overhead outweighs its parallel processing advantage. Evaluation currently executes serial games with single-sample policy requests, making CPU the faster device by a factor of ~1.6x.
## Code reference
`src/coolrl_lost_cities/games/classic/deep_cfr/evaluate.py`, function `action_distribution` (around line 250):
```python
with torch.inference_mode():
x = torch.as_tensor(info, dtype=torch.float32, device=self.device).unsqueeze(0)
logits = self.strategy_network(x).squeeze(0).detach().cpu().numpy()
```
This single-sample forward pass is the tightest loop in evaluation. In a typical 100-game evaluation run against a variety of opponents, this function is called hundreds of thousands of times (e.g., ~236k calls in the 2026-05-07 profile run).
## Performance Analysis
Profiling data reveals a sharp divide between training and evaluation efficiency when using CUDA:
| Phase | CPU Time | CUDA Time | Speedup (CUDA) |
| :--- | :--- | :--- | :--- |
| **Advantage Train** | 4.05s | 2.88s | 1.40x |
| **Strategy Train** | 1.64s | 1.37s | 1.20x |
| **Evaluation** | 38.92s | 61.83s | **0.63x (Slower)** |
The discrepancy arises because training uses large batches (e.g., `batch_size: 512`), which allows the GPU to saturate and amortizes kernel launch overhead. Evaluation, however, steps through games one action at a time.
On CPU, the `network / turn` cost is approximately **0.074 ms**. On CUDA, this rises to **0.162 ms**. This 2x increase in per-turn latency is typical for small MLP models on CUDA, where the compute time is shorter than the host-to-device synchronization and kernel scheduling latency.
### Secondary Bottlenecks
- **Post-processing:** Moving tensors back to CPU (`.cpu().numpy()`) and calculating entropy adds measurable overhead on CUDA that is largely absent on CPU.
- **Opponent Logic:** Heuristic opponents (e.g., `safe_heuristic`) contribute significant `opponent_act_seconds` (up to 3.5s per eval iteration). Since this logic is pure Python/Cython and runs on the CPU, it does not benefit from GPU acceleration, further diluting any potential CUDA wins.
## Practical Implications
- **Device Choice:** For the current serial evaluation implementation, always use `--device cpu` for evaluation. If training on CUDA, transferring weights to a CPU-based evaluation worker is significantly more efficient than evaluating on the GPU.
- **Batched Evaluation:** To make CUDA evaluation viable, the implementation must be refactored to use `select_actions_batch` across multiple concurrent games. This would move the evaluation pattern closer to the training pattern, allowing the GPU to process multiple info-states in a single kernel launch.
- **Model Scaling:** As the strategy network size increases (e.g., larger hidden layers or more blocks), the relative overhead of CUDA will decrease. At a certain model scale, the compute advantage will eventually overcome the latency penalty even at batch size 1.
## References
- `docs/archive/deep-cfr-evaluation-profile-2026-05-07.md` (Profiling source)
- `docs/performance.md` (Top-level performance log)
- `src/coolrl_lost_cities/games/classic/deep_cfr/evaluate.py` (Implementation)
+33 -3
View File
@@ -24,7 +24,6 @@ from __future__ import annotations
import argparse import argparse
import os import os
import re import re
import shutil
import subprocess import subprocess
import sys import sys
from datetime import datetime from datetime import datetime
@@ -37,6 +36,7 @@ LLM_COMMANDS = {
} }
DATE_SUFFIX = re.compile(r"-\d{4}-\d{2}-\d{2}$") DATE_SUFFIX = re.compile(r"-\d{4}-\d{2}-\d{2}$")
LAST_VERIFIED = re.compile(r"^\*\*Last verified:\*\*[^\n]*$", re.MULTILINE)
def _repo_root() -> Path: def _repo_root() -> Path:
@@ -47,6 +47,34 @@ def _repo_root() -> Path:
raise RuntimeError("pyproject.toml을 찾을 수 없어 repository root를 판정할 수 없습니다.") raise RuntimeError("pyproject.toml을 찾을 수 없어 repository root를 판정할 수 없습니다.")
def _current_commit_sha(root: Path) -> str:
"""Resolve HEAD to a short SHA. Returns 'unknown' if git is unavailable."""
result = subprocess.run(
["git", "rev-parse", "--short", "HEAD"],
capture_output=True,
text=True,
check=False,
cwd=root,
)
if result.returncode != 0:
return "unknown"
return result.stdout.strip() or "unknown"
def _post_process_draft(content: str, commit_sha: str) -> str:
"""Normalize a draft before writing to disk.
The LLM is unreliable about running `git` itself in headless mode; it
tends to leave `<short-hash>` placeholders or literal `HEAD` strings.
Rewrite the `**Last verified:**` line in place with today's date and
the real short SHA. If the line is missing entirely, leave the draft
untouched so the omission stays visible during review.
"""
today = datetime.now().strftime("%Y-%m-%d")
new_line = f"**Last verified:** {today}, commit `{commit_sha}`"
return LAST_VERIFIED.sub(new_line, content, count=1)
def _assemble_prompt( def _assemble_prompt(
system_prompt: str, system_prompt: str,
rel_archive: Path, rel_archive: Path,
@@ -167,7 +195,9 @@ def main() -> int:
print(result.stderr, file=sys.stderr) print(result.stderr, file=sys.stderr)
return result.returncode return result.returncode
draft_path.write_text(result.stdout, encoding="utf-8") commit_sha = _current_commit_sha(root)
draft_text = _post_process_draft(result.stdout, commit_sha)
draft_path.write_text(draft_text, encoding="utf-8")
print(f"Draft written to: {draft_path.relative_to(root)}") print(f"Draft written to: {draft_path.relative_to(root)}")
if args.accept: if args.accept:
@@ -177,7 +207,7 @@ def main() -> int:
file=sys.stderr, file=sys.stderr,
) )
return 1 return 1
shutil.copyfile(draft_path, target) target.write_text(draft_text, encoding="utf-8")
print(f"Copied to: {rel_target}") print(f"Copied to: {rel_target}")
print("Next: review the diff and `git add` + commit if you're satisfied.") print("Next: review the diff and `git add` + commit if you're satisfied.")
else: else:
+33 -2
View File
@@ -40,6 +40,7 @@ LLM_COMMANDS = {
} }
DATE_SUFFIX = re.compile(r"-\d{4}-\d{2}-\d{2}$") DATE_SUFFIX = re.compile(r"-\d{4}-\d{2}-\d{2}$")
LAST_VERIFIED = re.compile(r"^\*\*Last verified:\*\*[^\n]*$", re.MULTILINE)
def _repo_root() -> Path: def _repo_root() -> Path:
@@ -78,6 +79,34 @@ def _assemble_prompt(
) )
def _current_commit_sha(root: Path) -> str:
"""Resolve HEAD to a short SHA. Returns 'unknown' if git is unavailable."""
result = subprocess.run(
["git", "rev-parse", "--short", "HEAD"],
capture_output=True,
text=True,
check=False,
cwd=root,
)
if result.returncode != 0:
return "unknown"
return result.stdout.strip() or "unknown"
def _post_process_draft(content: str, commit_sha: str) -> str:
"""Normalize a draft before writing to disk.
The LLM is unreliable about running `git` itself in headless mode; it
tends to leave `<short-hash>` placeholders or literal `HEAD` strings.
Rewrite the `**Last verified:**` line in place with today's date and
the real short SHA. If the line is missing entirely, leave the draft
untouched so the omission stays visible during review.
"""
today = datetime.now().strftime("%Y-%m-%d")
new_line = f"**Last verified:** {today}, commit `{commit_sha}`"
return LAST_VERIFIED.sub(new_line, content, count=1)
def _has_counterpart(archive_stem_no_date: str, research_stems: set[str]) -> bool: def _has_counterpart(archive_stem_no_date: str, research_stems: set[str]) -> bool:
"""True if a research note already covers this archive entry. """True if a research note already covers this archive entry.
@@ -150,6 +179,7 @@ def main() -> int:
return 1 return 1
system_prompt = (root / "scripts" / "librarian-prompt.md").read_text(encoding="utf-8") system_prompt = (root / "scripts" / "librarian-prompt.md").read_text(encoding="utf-8")
commit_sha = _current_commit_sha(root)
timestamp = datetime.now().strftime("%Y-%m-%d_%H%M%S") timestamp = datetime.now().strftime("%Y-%m-%d_%H%M%S")
out_dir = root / "runs" / "tmp" / f"librarian-survey-{timestamp}" out_dir = root / "runs" / "tmp" / f"librarian-survey-{timestamp}"
@@ -186,9 +216,10 @@ def main() -> int:
skips += 1 skips += 1
print(f" SKIP ({output[:80]})", file=sys.stderr) print(f" SKIP ({output[:80]})", file=sys.stderr)
else: else:
(out_dir / f"{stem_out}.md").write_text(output, encoding="utf-8") normalized = _post_process_draft(output, commit_sha)
(out_dir / f"{stem_out}.md").write_text(normalized, encoding="utf-8")
drafts += 1 drafts += 1
print(f" draft ({len(output)} chars)", file=sys.stderr) print(f" draft ({len(normalized)} chars)", file=sys.stderr)
print() print()
print(f"Survey complete: {drafts} drafts, {skips} skips, {errors} errors.") print(f"Survey complete: {drafts} drafts, {skips} skips, {errors} errors.")