diff --git a/docs/superpowers/plans/2026-07-05-human-play-difficulty-and-db-tag.md b/docs/superpowers/plans/2026-07-05-human-play-difficulty-and-db-tag.md new file mode 100644 index 0000000..ae51a2d --- /dev/null +++ b/docs/superpowers/plans/2026-07-05-human-play-difficulty-and-db-tag.md @@ -0,0 +1,757 @@ +# Human-Play 난이도 + 게임별 DB 난이도 태그 구현 계획 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 워크트리를 최신 main 위로 rebase(main 불변)한 뒤, `sessions` 테이블에 게임별 난이도 태그를 추가하고 human-play UI에 캠페인 단위 난이도 선택기를 배선한다. + +**Architecture:** 3-phase. Phase 0은 순수 git 통합(rebase). Phase 1은 저장 계층(SQLite+Postgres 병행)에 `difficulty` 컬럼을 멱등 마이그레이션으로 추가하고 두 write-site(human=`api.py`, LLM=`seeding.py`)에서 `SeasonResult.difficulty`로 채운다. Phase 2는 프론트엔드 `playScreen`에 난이도 상태·전송·체크포인트를 배선하고 `/api/new_game`의 검증 갭(500→400)을 고친다. + +**Tech Stack:** Python 3.12, FastAPI + Pydantic, sqlite3 / psycopg, Alpine.js + vanilla JS, pytest, git rebase. + +**Spec:** `docs/superpowers/specs/2026-07-05-human-play-difficulty-and-db-tag-design.md` + +## Global Constraints + +- 작업 디렉토리는 워크트리 `.claude/worktrees/signal-game-difficulty-arena` (브랜치 `worktree-signal-game-difficulty-arena`). **`main` 브랜치 ref는 절대 이동/수정하지 않는다.** +- 난이도 값 어휘: 엔진은 `easy`/`hard`/`expert`, UI 라벨은 Easy/Normal/Hard. MEDIUM은 노출하지 않는다(아레나와 동일). +- 모든 신규 필드 기본값은 `"easy"` (하위호환). +- DB 마이그레이션은 **멱등**(있으면 skip). SQLite는 `PRAGMA table_info` 가드 + `ALTER TABLE ADD COLUMN`, Postgres는 `ADD COLUMN IF NOT EXISTS`. 기존 행은 `NOT NULL DEFAULT 'easy'`로 백필. +- SQLite/Postgres **병행 반영**(스키마·INSERT·SELECT·row-map 모두 양쪽). 누락 시 리더보드 NULL 표기 회귀. +- 테스트 전 iCloud `.pth` 숨김 이슈 발생 시 `chflags nohidden` 선처리 후 pytest(프로젝트 메모리 참조). +- Phase 1·2 코드는 **반드시 Phase 0(rebase) 완료 후** 재배치된 tip 위에 쌓는다. + +--- + +### Task 0: 워크트리를 최신 main 위로 Rebase (main 불변) + +**Files:** +- Git 작업만. 코드 편집은 충돌 해결 시에만. + +**Interfaces:** +- Produces: 워크트리 브랜치 tip = 최신 main(`769a075`) + difficulty 커밋들(재배치) + 스펙/플랜 docs 커밋. 이후 모든 Task가 이 tip 위에서 동작. + +- [ ] **Step 1: 워크트리가 클린 상태인지 확인** + +Run: +```bash +cd ".claude/worktrees/signal-game-difficulty-arena" +git status --porcelain +git log --oneline -1 # 현재 tip 확인 (docs 스펙/플랜 커밋이 최상단) +``` +Expected: 커밋 안 된 변경 없음(있으면 stash 또는 commit). 미추적 `.playwright-mcp/`, `outputs/web_arena/`는 무시 가능. + +- [ ] **Step 2: main 위로 rebase 시작** + +Run: +```bash +git rebase main +``` +Expected: `interface/arena.py`, `tests/integration/test_arena.py`는 자동 적용. `interface/api.py`, `web/app.js`, `web/index.html`, `tests/unit/test_api_web_arena.py`에서 충돌 발생 가능. + +- [ ] **Step 3: `interface/api.py` 충돌 해결 — arena import 블록** + +충돌 지점은 `from interface.arena import (...)` 블록. **union**으로 해결(main의 세 심볼 + `VALID_DIFFICULTIES`): + +```python +from interface.arena import ( + VALID_DIFFICULTIES, + VALID_FORFEITS, + VALID_FRAMINGS, + run_arena_session, +) +``` + +`ArenaRunRequest.difficulty` 필드, `arena_run`의 400 검증 등 나머지 difficulty 추가분은 main 코드를 베이스로 두고 그대로 재적용(삭제 금지). main이 추가한 주변 코드도 유지. + +- [ ] **Step 4: `web/app.js` / `web/index.html` / `tests/unit/test_api_web_arena.py` 충돌 해결** + +모든 difficulty 변경은 **추가(additive)**다. 각 충돌 hunk에서 **main 쪽 코드를 유지하고 difficulty 추가분(DIFFICULTY_OPTIONS, `squidArenaHelpers.difficultyOptions`, `arenaScreen().difficulty`, `launch()`의 `difficulty`, index.html 아레나 셀렉터 마크업, test 2개)만 다시 얹는다**. main 코드를 지우지 않는다. + +각 파일 해결 후: +```bash +git add interface/api.py web/app.js web/index.html tests/unit/test_api_web_arena.py +git rebase --continue +``` +반복하여 rebase 완료. + +- [ ] **Step 5: 의존성 동기화 + 검증 게이트** + +Run: +```bash +find .venv -name "*.pth" -exec chflags nohidden {} \; 2>/dev/null; chflags -R nohidden .venv/lib 2>/dev/null +uv sync --extra dev +uv run pytest tests/integration/test_arena.py tests/unit/test_api_web_arena.py tests/integration/test_web_arena_api.py -q +node --check web/app.js +``` +Expected: difficulty 6개 테스트 포함 그린(신규 실패 0; 기존 pre-existing 실패는 허용 — "no NEW failures" 기준). `node --check` exit 0. + +- [ ] **Step 6: rebase 결과 확인 (커밋 불필요 — rebase가 tip을 이동시킴)** + +Run: +```bash +git log --oneline -8 +git merge-base --is-ancestor main HEAD && echo "main is now an ancestor ✓" +``` +Expected: 최신 main 커밋들이 difficulty 커밋 아래에 있고, `main is now an ancestor ✓` 출력. main 브랜치 tip은 불변. + +--- + +### Task 1: `SessionRecord.difficulty` + SQLite 컬럼/마이그레이션/저장 (양쪽 backend 중 SQLite) + +**Files:** +- Modify: `interface/persistence/models.py` (`SessionRecord` dataclass) +- Modify: `interface/persistence/sqlite_repository.py` (`_SCHEMA`, `init_schema`, `create_session`, `_row_to_session`) +- Test: `tests/unit/test_persistence.py` + +**Interfaces:** +- Produces: `SessionRecord.difficulty: str = "easy"` — 새 필드, 기본값 있음(기존 생성자 하위호환). +- Produces: `sessions` 테이블에 `difficulty TEXT NOT NULL DEFAULT 'easy'` 컬럼; `create_session`이 값 저장, `get_session`/`list_sessions`가 값 복원. + +- [ ] **Step 1: 실패 테스트 작성 — round-trip + 마이그레이션** + +`tests/unit/test_persistence.py`에 추가(기존 `repo` fixture와 `_session` 헬퍼 재사용): + +```python +def test_session_round_trips_difficulty(repo: Repository) -> None: + sid = repo.create_session(_session(difficulty="hard")) + fetched = repo.get_session(sid) + assert fetched is not None + assert fetched.difficulty == "hard" + + +def test_session_difficulty_defaults_to_easy(repo: Repository) -> None: + sid = repo.create_session(_session()) # helper omits difficulty + assert repo.get_session(sid).difficulty == "easy" + + +def test_sessions_difficulty_migration_adds_column_to_old_db(tmp_path) -> None: + import sqlite3 + from interface.persistence.sqlite_repository import SQLiteRepository + + db = str(tmp_path / "old.db") + # Simulate a pre-migration DB: sessions without the difficulty column. + conn = sqlite3.connect(db) + conn.execute( + "CREATE TABLE sessions (id TEXT PRIMARY KEY, nickname TEXT NOT NULL, " + "task TEXT NOT NULL, framing TEXT NOT NULL, forfeit TEXT NOT NULL, " + "seed INTEGER NOT NULL, final_score REAL NOT NULL, forfeited INTEGER NOT NULL, " + "source TEXT NOT NULL, created_at TEXT NOT NULL, campaign_id TEXT)" + ) + conn.execute( + "INSERT INTO sessions (id, nickname, task, framing, forfeit, seed, " + "final_score, forfeited, source, created_at, campaign_id) VALUES " + "('old1','bob','signal_game','flagship_corruption','allowed',1,5.0,0,'human','2026-01-01T00:00:00+00:00',NULL)" + ) + conn.commit() + conn.close() + + repo = SQLiteRepository(db) # __init__ -> init_schema() -> migration + try: + cols = {r["name"] for r in repo._conn.execute("PRAGMA table_info(sessions)")} + assert "difficulty" in cols + # Existing row backfilled to 'easy'. + assert repo.get_session("old1").difficulty == "easy" + finally: + repo.close() +``` + +- [ ] **Step 2: 테스트 실패 확인** + +Run: `uv run pytest tests/unit/test_persistence.py -k difficulty -v` +Expected: FAIL — `SessionRecord`에 `difficulty` 인자 없음 / `difficulty` 컬럼 없음. + +- [ ] **Step 3: `SessionRecord`에 `difficulty` 필드 추가** + +`interface/persistence/models.py` — `SessionRecord`의 `campaign_id` 필드 아래에 추가: + +```python + campaign_id: str | None = None + # Signal Game difficulty this session was played at (easy | hard | expert). + # Defaults to "easy" for legacy rows written before this column existed. + difficulty: str = "easy" +``` + +- [ ] **Step 4: SQLite `_SCHEMA`에 컬럼 추가** + +`interface/persistence/sqlite_repository.py` — `CREATE TABLE ... sessions`의 `campaign_id TEXT` 줄 뒤에 추가: + +```python + campaign_id TEXT, + difficulty TEXT NOT NULL DEFAULT 'easy' +``` + +- [ ] **Step 5: SQLite `init_schema` 마이그레이션 가드 추가** + +`init_schema`의 기존 `if "campaign_id" not in session_cols:` 블록 바로 뒤에 추가: + +```python + if "difficulty" not in session_cols: + self._conn.execute( + "ALTER TABLE sessions ADD COLUMN difficulty TEXT NOT NULL DEFAULT 'easy'" + ) +``` + +- [ ] **Step 6: SQLite `create_session` INSERT에 컬럼/값 추가** + +INSERT 컬럼 목록·placeholder·params에 difficulty 추가: + +```python + INSERT INTO sessions + (id, nickname, task, framing, forfeit, seed, + final_score, forfeited, source, created_at, campaign_id, difficulty) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +``` + +params 튜플에서 `session.campaign_id,` 뒤에 추가: + +```python + session.campaign_id, + session.difficulty, +``` + +- [ ] **Step 7: SQLite `_row_to_session`에 매핑 추가** + +`_row_to_session`의 `campaign_id=...` 줄 뒤에 추가(migration 컬럼과 동일한 `in row.keys()` 가드): + +```python + campaign_id=row["campaign_id"] if "campaign_id" in row.keys() else None, + difficulty=row["difficulty"] if "difficulty" in row.keys() else "easy", +``` + +- [ ] **Step 8: 테스트 통과 확인** + +Run: `uv run pytest tests/unit/test_persistence.py -k difficulty -v` +Expected: PASS — 3개 테스트 그린. + +- [ ] **Step 9: 커밋** + +```bash +git add interface/persistence/models.py interface/persistence/sqlite_repository.py tests/unit/test_persistence.py +git commit -m "feat(persistence): sessions.difficulty column + SQLite round-trip/migration + +Add SessionRecord.difficulty (default easy). SQLite schema, idempotent +ADD COLUMN migration (backfill 'easy'), INSERT and row-map wired. + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +### Task 2: Postgres 병행 반영 (스키마/마이그레이션/INSERT/SELECT/row-map) + +**Files:** +- Modify: `interface/persistence/postgres_repository.py` (`_SCHEMA`, `init_schema`, `create_session`, `get_session`, `list_sessions`, `_row_to_session`) +- Test: `tests/unit/test_persistence.py` + +**Interfaces:** +- Consumes: `SessionRecord.difficulty` (Task 1). +- Produces: Postgres `sessions.difficulty` 컬럼 + INSERT/SELECT/row-map 병행. SELECT 컬럼 순서와 `_row_to_session` 언패킹 순서는 **difficulty를 끝에 append**하여 일치. + +- [ ] **Step 1: 실패 테스트 작성 — SQL 문자열 파리티(오프라인 검증)** + +`tests/unit/test_persistence.py`에 추가: + +```python +def test_postgres_schema_and_sql_include_difficulty() -> None: + import interface.persistence.postgres_repository as pg + + # Schema declares the column with the easy default. + assert "difficulty TEXT NOT NULL DEFAULT 'easy'" in pg._SCHEMA + # Idempotent migration present in init_schema source. + src = inspect.getsource(pg.PostgresRepository.init_schema) + assert "ADD COLUMN IF NOT EXISTS difficulty" in src + # INSERT and both SELECT column lists carry difficulty. + ins = inspect.getsource(pg.PostgresRepository.create_session) + assert "difficulty" in ins + get_src = inspect.getsource(pg.PostgresRepository.get_session) + list_src = inspect.getsource(pg.PostgresRepository.list_sessions) + assert "difficulty" in get_src and "difficulty" in list_src + # Row-mapper unpacks difficulty. + assert "difficulty" in inspect.getsource(pg._row_to_session) +``` + +파일 상단 import에 `import inspect` 없으면 추가. + +- [ ] **Step 2: 테스트 실패 확인** + +Run: `uv run pytest tests/unit/test_persistence.py::test_postgres_schema_and_sql_include_difficulty -v` +Expected: FAIL — postgres 소스에 difficulty 부재. + +- [ ] **Step 3: Postgres `_SCHEMA` 컬럼 추가** + +`interface/persistence/postgres_repository.py` — `CREATE TABLE ... sessions`의 `campaign_id TEXT` 줄 뒤: + +```python + campaign_id TEXT, + difficulty TEXT NOT NULL DEFAULT 'easy' +``` + +- [ ] **Step 4: Postgres `init_schema` 마이그레이션 추가** + +기존 `ALTER TABLE sessions ADD COLUMN IF NOT EXISTS campaign_id TEXT` cur.execute 뒤에 추가: + +```python + cur.execute( + "ALTER TABLE sessions ADD COLUMN IF NOT EXISTS difficulty " + "TEXT NOT NULL DEFAULT 'easy'" + ) +``` + +- [ ] **Step 5: Postgres `create_session` INSERT 추가** + +컬럼 목록·placeholder·params: + +```python + INSERT INTO sessions + (id, nickname, task, framing, forfeit, seed, + final_score, forfeited, source, created_at, campaign_id, difficulty) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, + COALESCE(%s::timestamptz, now()), %s, %s) +``` + +params 튜플의 `session.campaign_id,` 뒤에: + +```python + session.campaign_id, + session.difficulty, +``` + +- [ ] **Step 6: Postgres 두 SELECT에 difficulty 추가 (끝에 append)** + +`get_session`과 `list_sessions`의 SELECT 컬럼 문자열을 각각 수정 — `campaign_id ` 뒤에 `, difficulty` (row-map 언패킹 순서와 일치하도록 **맨 끝**): + +`get_session`: +```python + "SELECT id, nickname, task, framing, forfeit, seed, " + "final_score, forfeited, source, created_at, campaign_id, difficulty " + "FROM sessions WHERE id = %s", +``` + +`list_sessions`: +```python + "SELECT id, nickname, task, framing, forfeit, seed, " + "final_score, forfeited, source, created_at, campaign_id, difficulty " + f"FROM sessions {where} ORDER BY {order}" +``` + +- [ ] **Step 7: Postgres `_row_to_session` 언패킹/생성 추가** + +튜플 언패킹 끝에 `difficulty` 추가하고 SessionRecord에 전달: + +```python +def _row_to_session(row: tuple) -> SessionRecord: + ( + id_, nickname, task, framing, forfeit, seed, + final_score, forfeited, source, created_at, campaign_id, difficulty, + ) = row + return SessionRecord( + id=id_, + nickname=nickname, + task=task, + framing=framing, + forfeit=forfeit, + seed=seed, + final_score=final_score, + forfeited=bool(forfeited), + source=source, + created_at=created_at.isoformat() if hasattr(created_at, "isoformat") else created_at, + campaign_id=campaign_id, + difficulty=difficulty, + ) +``` + +- [ ] **Step 8: 테스트 통과 확인** + +Run: `uv run pytest tests/unit/test_persistence.py -k "difficulty or postgres" -v` +Expected: PASS — Task 1 SQLite 테스트 + Postgres 파리티 테스트 그린. + +- [ ] **Step 9: 커밋** + +```bash +git add interface/persistence/postgres_repository.py tests/unit/test_persistence.py +git commit -m "feat(persistence): Postgres parity for sessions.difficulty + +Column, idempotent ADD COLUMN IF NOT EXISTS migration, INSERT, both +SELECT column lists, and positional row-map unpack all carry difficulty. + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +### Task 3: 두 write-site에서 `SeasonResult.difficulty`로 채우기 (LLM seeding + human api) + +**Files:** +- Modify: `interface/seeding.py` (`build_session_record`, ~line 128) +- Modify: `interface/api.py` (human 세션 `SessionRecord(...)`, ~line 712 부근 — Phase 0 후 위치 이동 가능; 스니펫으로 매칭) +- Test: `tests/unit/test_seed_web_arena.py`, `tests/unit/test_human_game.py` + +**Interfaces:** +- Consumes: `SessionRecord.difficulty` (Task 1); `SeasonResult.difficulty` (엔진); season dict의 top-level `"difficulty"` 키. +- Produces: LLM/human 세션 레코드가 자신의 난이도로 채워짐. + +- [ ] **Step 1: LLM 실패 테스트 작성** + +`tests/unit/test_seed_web_arena.py`에 추가(기존 `_season`/`_turn` 헬퍼 재사용 — `_season`은 이미 `"difficulty": "medium"` 포함): + +```python +def test_build_session_record_carries_difficulty() -> None: + season = _season("seasD", final_score=10.0, forfeited=False, turns=[_turn(1)]) + session = build_session_record(season, "Test-Model", fallback_created_at="2026-01-01T00:00:00+00:00") + assert session.difficulty == "medium" + + +def test_build_session_record_difficulty_defaults_when_absent() -> None: + season = _season("seasE", final_score=10.0, forfeited=False, turns=[_turn(1)]) + del season["difficulty"] + session = build_session_record(season, "Test-Model", fallback_created_at="2026-01-01T00:00:00+00:00") + assert session.difficulty == "easy" +``` + +- [ ] **Step 2: 테스트 실패 확인** + +Run: `uv run pytest tests/unit/test_seed_web_arena.py -k difficulty -v` +Expected: FAIL — `build_session_record`가 difficulty를 세팅하지 않음(기본 "easy"만 나와 첫 테스트 실패). + +- [ ] **Step 3: `build_session_record`에 difficulty 추가** + +`interface/seeding.py` — `build_session_record`의 `SessionRecord(...)` 반환에서 `source="llm",` 뒤에 추가: + +```python + source="llm", + created_at=created_at, + difficulty=season.get("difficulty", "easy"), +``` + +(주의: 기존에 `created_at=created_at,`가 `source="llm",` 뒤에 있으므로 그 뒤에 difficulty 추가.) + +- [ ] **Step 4: LLM 테스트 통과 확인** + +Run: `uv run pytest tests/unit/test_seed_web_arena.py -k difficulty -v` +Expected: PASS. + +- [ ] **Step 5: human write-site 수정 — `interface/api.py`** + +human 세션 `SessionRecord(...)` 생성부(다음 스니펫으로 매칭; Phase 0 후 라인 이동 가능)에서 `source="human",` 뒤에 difficulty 추가: + +find: +```python + source="human", + campaign_id=_campaigns.get(session_id), + ) +``` +replace: +```python + source="human", + campaign_id=_campaigns.get(session_id), + difficulty=result.difficulty.value, + ) +``` + +(`result`는 `HumanGameSession.get_result()`가 반환한 `SeasonResult`로, `.difficulty`는 `Difficulty` enum → `.value`로 문자열화.) + +- [ ] **Step 6: human 값 흐름 검증 테스트 — `tests/unit/test_human_game.py`** + +`HumanGameSession.get_result().difficulty`가 선택 난이도를 반영하는지 확인(이 값이 Step 5에서 SessionRecord로 전달됨). 파일 스타일에 맞춰 추가: + +```python +def test_get_result_reflects_selected_difficulty() -> None: + from interface.human_game import HumanGameSession + + game = HumanGameSession( + task_name="signal_game", difficulty="hard", + framing="flagship_corruption", forfeit_condition="allowed", + seed=7, total_turns=3, + ) + assert game.get_result().difficulty.value == "hard" +``` + +- [ ] **Step 7: human 테스트 통과 확인** + +Run: `uv run pytest tests/unit/test_human_game.py -k difficulty -v` +Expected: PASS. + +- [ ] **Step 8: 커밋** + +```bash +git add interface/seeding.py interface/api.py tests/unit/test_seed_web_arena.py tests/unit/test_human_game.py +git commit -m "feat(persistence): tag each session with its difficulty + +LLM (seeding.build_session_record) reads season['difficulty']; human +(api.new_game finish) reads SeasonResult.difficulty.value. Both flow +into sessions.difficulty. + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +### Task 4: `/api/new_game` 난이도 검증 (500 → 400) + +**Files:** +- Modify: `interface/api.py` (`new_game` 핸들러; `VALID_DIFFICULTIES`는 Phase 0 후 import되어 있음) +- Test: `tests/unit/test_api_web_arena.py` + +**Interfaces:** +- Consumes: `VALID_DIFFICULTIES` (Phase 0 후 `interface.arena`에서 import됨). +- Produces: 잘못된 difficulty에 `HTTPException(400)` (현재 `Difficulty("banana")` → 500 크래시 교정). + +- [ ] **Step 1: 실패 테스트 작성** + +`tests/unit/test_api_web_arena.py`에 추가(기존 `client` fixture 재사용): + +```python +def test_new_game_rejects_unknown_difficulty(client: TestClient) -> None: + resp = client.post( + "/api/new_game", + json={ + "task_name": "signal_game", + "framing": "flagship_corruption", + "forfeit_condition": "allowed", + "nickname": "diff_bad", + "password": "pw", + "difficulty": "banana", + }, + ) + assert resp.status_code == 400 + + +def test_new_game_accepts_valid_difficulty(client: TestClient) -> None: + resp = client.post( + "/api/new_game", + json={ + "task_name": "signal_game", + "framing": "flagship_corruption", + "forfeit_condition": "allowed", + "nickname": "diff_ok", + "password": "pw", + "difficulty": "hard", + }, + ) + assert resp.status_code == 200 +``` + +- [ ] **Step 2: 테스트 실패 확인** + +Run: `uv run pytest tests/unit/test_api_web_arena.py -k "new_game and difficulty" -v` +Expected: FAIL — `difficulty="banana"`가 500(400 아님)을 반환. + +- [ ] **Step 3: `new_game` 핸들러에 검증 추가** + +`interface/api.py` `new_game` 함수 — 기존 닉네임/비밀번호 검증(400) 블록들 뒤, `HumanGameSession(...)` 생성 앞에 추가: + +```python + if req.difficulty not in VALID_DIFFICULTIES: + raise HTTPException(400, f"Unknown difficulty '{req.difficulty}'.") +``` + +(아레나 `arena_run`의 `if req.forfeit not in VALID_FORFEITS: raise HTTPException(400, ...)`와 동일 스타일.) + +- [ ] **Step 4: 테스트 통과 확인** + +Run: `uv run pytest tests/unit/test_api_web_arena.py -k "new_game and difficulty" -v` +Expected: PASS. + +- [ ] **Step 5: 커밋** + +```bash +git add interface/api.py tests/unit/test_api_web_arena.py +git commit -m "fix(api): /api/new_game returns 400 (not 500) on unknown difficulty + +Validate req.difficulty against VALID_DIFFICULTIES before constructing +HumanGameSession, matching /api/arena/run's style. + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +### Task 5: `web/app.js` — playScreen 난이도 상태 + startGame 전송 + 체크포인트 + +**Files:** +- Modify: `web/app.js` (`playScreen` data, `startGame`, `_saveCheckpoint`, `_loadCheckpoint`) + +**Interfaces:** +- Consumes: `squidArenaHelpers.difficultyOptions` (Phase 0 후 존재, 아레나가 export). +- Produces: `playScreen().difficulty` 상태(기본 `"easy"`), `/api/new_game` 바디에 `difficulty` 포함, 체크포인트에 difficulty 저장/복원. Task 6(index.html)이 `difficulty` 바인딩을 소비. + +- [ ] **Step 1: `playScreen` data에 difficulty 상태 추가** + +`Alpine.data("playScreen", () => ({` 블록 내부, `task: window.WEB_ARENA_DEFAULT_TASK,` 줄 뒤에 추가: + +```javascript + // Campaign-level Signal Game difficulty (engine easy|hard|expert; + // labelled Easy/Normal/Hard). Chosen once on the setup screen and held + // constant across all 6 games of the campaign. + difficulty: "easy", +``` + +- [ ] **Step 2: `startGame()` POST 바디에 difficulty 추가** + +`startGame()`의 `/api/new_game` `body: JSON.stringify({ ... })`에서 `campaign_id: this.campaignId,` 뒤에 추가: + +```javascript + campaign_id: this.campaignId, + difficulty: this.difficulty, +``` + +- [ ] **Step 3: 체크포인트 저장/복원에 difficulty 반영** + +`_saveCheckpoint()`의 `data` 객체에서 `v: 1,`을 `v: 2,`로 올리고 `campaignId: this.campaignId,` 뒤에 `difficulty: this.difficulty,` 추가: + +```javascript + const data = { + v: 2, + nickname: this.nickname, + password: this.password, + campaignId: this.campaignId, + difficulty: this.difficulty, +``` + +`_loadCheckpoint()`의 버전 가드를 v1/v2 모두 허용하도록 완화(구버전 체크포인트 하위호환): + +find: +```javascript + if (!d || d.v !== 1 || d.campaignIndex >= 6) return null; + return d; +``` +replace: +```javascript + if (!d || (d.v !== 1 && d.v !== 2) || d.campaignIndex >= 6) return null; + return d; +``` + +- [ ] **Step 4: 체크포인트 복원 시 difficulty 적용** + +체크포인트에서 캠페인을 재개하는 지점(`resume`/`resumeCampaign` 계열 메서드에서 `this.nickname = ck.nickname` 등 필드를 복원하는 곳)에 difficulty 복원을 추가. 복원 필드 세팅 근처에 삽입(구버전 체크포인트는 difficulty 부재 → `'easy'` 폴백): + +```javascript + this.difficulty = ck.difficulty || "easy"; +``` + +(정확한 메서드는 `_loadCheckpoint()` 반환값 `ck`를 소비하는 곳 — `this.nickname = ck.nickname`이 있는 블록. 없으면 resume 진입점에서 nickname 복원 직후 추가.) + +- [ ] **Step 5: JS 구문 검증** + +Run: `node --check web/app.js` +Expected: exit 0, 출력 없음. + +- [ ] **Step 6: 커밋** + +```bash +git add web/app.js +git commit -m "feat(play-ui): campaign-level difficulty state, launch payload, checkpoint + +playScreen.difficulty (default easy) sent in /api/new_game body and +persisted in the resume checkpoint (schema v2, easy fallback for v1). + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +### Task 6: `web/index.html` — Play 셋업 카드에 난이도 셀렉터 + +**Files:** +- Modify: `web/index.html` (Play `setup-card`, task 선택 `game-cards`와 `scenario-box` 사이) + +**Interfaces:** +- Consumes: `squidArenaHelpers.difficultyOptions`, `playScreen().difficulty` (Task 5). +- Produces: 셋업 화면(`x-show="!started"` 카드 내부)의 난이도 셀렉터. + +- [ ] **Step 1: 셀렉터 마크업 삽입** + +`web/index.html`의 Play `setup-card` 내부, task 선택 `game-cards` 블록의 닫는 `` 뒤이자 `
Difficulty +
+ +
+``` + +셀렉터가 `setup-card`(`x-show="!started"`) 안에 있으므로 캠페인 시작 후 자동으로 숨겨진다(난이도 캠페인 단위 고정 보장). + +- [ ] **Step 2: 수동 렌더 검증 (오프라인 정적 확인)** + +Run: +```bash +grep -n "squidArenaHelpers.difficultyOptions" web/index.html +``` +Expected: Play 셋업 카드 내 새 셀렉터 1개 매칭(아레나 것과 합쳐 총 2개 매칭). + +- [ ] **Step 3: 커밋** + +```bash +git add web/index.html +git commit -m "feat(play-ui): difficulty selector on the human Play setup card + +Reuses squidArenaHelpers.difficultyOptions + cond-card classes; lives +inside the !started setup card so difficulty is locked once a campaign +begins. + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +### Task 7: 전체 회귀 + 브라우저 스모크 (end-to-end 검증) + +**Files:** +- Verify: 전체 테스트 스위트 + 임시 백엔드/프론트 수동 확인 + +- [ ] **Step 1: 관련 스위트 회귀** + +Run: +```bash +uv run pytest tests/unit/test_persistence.py tests/unit/test_seed_web_arena.py \ + tests/unit/test_human_game.py tests/unit/test_api_web_arena.py \ + tests/integration/test_arena.py tests/integration/test_web_arena_api.py -q +node --check web/app.js +``` +Expected: 그린(신규 실패 0). `node --check` exit 0. + +- [ ] **Step 2: 임시 DB로 로컬 백엔드/프론트 기동 (프로덕션 DB 미오염)** + +Run(스크래치패드 임시 DB 사용): +```bash +export WEB_ARENA_DSN="$SCRATCH/human_difficulty_test.db"; export PYTHONPATH=src +uv run --no-sync uvicorn interface.api:app --port 8502 --host 127.0.0.1 & # 백엔드 +( cd web && python3 -m http.server 8080 --bind 127.0.0.1 & ) # 프론트 +``` +(`$SCRATCH`는 세션 스크래치패드 경로로 치환.) config.js가 `localhost:8502`를 가리키고 CORS에 `localhost:8080`이 이미 허용됨 — **`http://localhost:8080/`로 접속(127.0.0.1 아님)**. + +- [ ] **Step 3: Playwright 스모크 — 셀렉터 렌더 + 페이로드 + 캠페인 고정** + +`http://localhost:8080/` Play 탭에서 확인: +1. Difficulty 카드 3개(Easy/Normal/Hard) 렌더, `easy` 기본 선택. +2. "Normal" 클릭 → 선택 이동(`on` 클래스). +3. DevTools Network에서 닉네임/비밀번호 입력 후 "Start 6-game run" → `POST /api/new_game` 바디에 `"difficulty":"hard"` 확인. +4. 게임 시작 후 셋업 카드(셀렉터 포함)가 사라지는지 확인(캠페인 중 난이도 변경 차단). + +Expected: 셀렉터가 아레나와 동일하게 렌더되고, 페이로드에 엔진 값이 실림. + +- [ ] **Step 4: 서버 종료** + +Run: +```bash +for p in 8502 8080; do pid=$(lsof -ti tcp:$p); [ -n "$pid" ] && kill $pid; done +``` + +- [ ] **Step 5: (해당 시) 개발 브랜치 마무리** + +구현·검증 완료 후 `superpowers:finishing-a-development-branch` 스킬로 main 병합/PR 옵션을 사용자에게 제시. + +--- + +## Self-Review 결과 (작성자 체크) + +- **스펙 커버리지:** Phase 0(rebase)=Task 0; DB 태그(스키마/마이그레이션/양쪽 backend/두 write-site)=Task 1·2·3; human UI 셀렉터=Task 5·6; 캠페인 고정=Task 5(상태)+Task 6(setup-card 위치); 체크포인트 정합성=Task 5 Step 3·4; API 400 검증=Task 4. 모든 스펙 요구 → 대응 Task 존재. +- **플레이스홀더:** 없음. Task 5 Step 4의 "resume 진입점"은 코드 앵커(`this.nickname = ck.nickname`)로 지정. +- **타입 일관성:** `SessionRecord.difficulty: str`(Task 1)를 SQLite(Task 1)·Postgres(Task 2)·write-site(Task 3)·테스트 전반에서 동일 사용. `result.difficulty.value`(enum→str), `season.get("difficulty","easy")`(str) 일관. +- **Postgres 순서 주의:** SELECT 컬럼과 `_row_to_session` 언패킹 모두 difficulty를 **끝에 append**(Task 2 Step 6·7) — 위치 불일치 없음. diff --git a/docs/superpowers/plans/2026-07-06-human-play-difficulty-and-db-tag-KICKOFF.md b/docs/superpowers/plans/2026-07-06-human-play-difficulty-and-db-tag-KICKOFF.md new file mode 100644 index 0000000..3db02d3 --- /dev/null +++ b/docs/superpowers/plans/2026-07-06-human-play-difficulty-and-db-tag-KICKOFF.md @@ -0,0 +1,65 @@ +# 다음 세션 킥오프 프롬프트 — Human-Play 난이도 + 게임별 DB 난이도 태그 실행 + +아래 블록을 새 Claude Code 세션에 그대로 붙여넣으면 됩니다. (실행 방식: **subagent-driven**) + +--- + +```text +Human-play 난이도 선택 + 게임별 DB 난이도 태그를 계획대로 구현한다. superpowers:subagent-driven-development 스킬로 Task 단위 실행해줘. + +- 계획: docs/superpowers/plans/2026-07-06-human-play-difficulty-and-db-tag.md (Task 0~7) +- 스펙: docs/superpowers/specs/2026-07-05-human-play-difficulty-and-db-tag-design.md +- 작업 위치: 워크트리 .claude/worktrees/signal-game-difficulty-arena (브랜치 worktree-signal-game-difficulty-arena) + +확정된 결정 (재논의 불필요): +- Git 통합 = Rebase (main 불변, 워크트리 5커밋+docs를 최신 main 위로 replay) +- human-play 난이도 = 3단계 (Easy/Normal/Hard = 엔진 easy/hard/expert, medium 제외) +- 기존 DB 행 백필 = 'easy' (NOT NULL DEFAULT 'easy') +- 난이도 적용 범위 = 캠페인 단위 (6게임 공통, 각 게임 행에 동일 태그) + +진행 방식: +1. Task 0(rebase)은 서브에이전트에 위임하지 말고 오케스트레이터(너)가 직접 실행해. + 충돌 해결이 필요하고(4파일: api.py/app.js/index.html/test_api_web_arena.py) 트리 전체에 + 영향을 주기 때문. 충돌은 전부 additive라 "main 코드 유지 + difficulty 추가분 재적용"이 + 원칙. api.py의 arena import 블록은 union 한 줄(VALID_DIFFICULTIES) 추가. + 완료 게이트: 관련 테스트 그린 + node --check web/app.js. main 브랜치 ref는 절대 안 건드림. +2. Task 1~7은 계획대로 Task마다 새 서브에이전트 dispatch + 사이에 2단계 리뷰(구현/테스트). + 계획에 파일 경로·정확한 코드·명령·기대출력이 모두 들어 있으니 그대로 따르게 해. +3. 순서 엄수: 0 → 1 → 2 → 3 → 4 → 5 → 6 → 7. 신규 작업은 반드시 rebase 완료된 tip 위에. +4. SQLite/Postgres 병행 반영 필수 (스키마·마이그레이션·INSERT·SELECT·row-map 양쪽). + Postgres는 SELECT 컬럼과 _row_to_session 언패킹 모두 difficulty를 "끝에 append"해 순서 일치. +5. 각 Task는 TDD (실패 테스트 → 실패 확인 → 최소 구현 → 통과 → 커밋). 커밋 메시지는 계획에 명시됨. + +환경 주의: +- 저장소 경로에 공백 있음 — 모든 셸 명령 경로를 따옴표로 감쌀 것. +- iCloud .pth 숨김 이슈: pytest 전에 + `find .venv -name "*.pth" -exec chflags nohidden {} \;` 후 `uv run --no-sync` 사용 (메모리 참고). +- 워크트리 venv는 `uv sync --extra dev` 필요 (Task 0 Step 5). +- Task 7의 로컬 스모크는 반드시 임시 DB(WEB_ARENA_DSN=스크래치패드 경로)로 — 141MB 프로덕션 + outputs/web_arena/web_arena.db 오염 금지. 프론트는 http://localhost:8080/ (127.0.0.1 아님, CORS). +- 판정 기준: "no NEW failures" (기존 pre-existing 실패는 허용, 메모리 참고). + +완료 후: superpowers:finishing-a-development-branch로 main 병합/PR 옵션을 나에게 제시해줘. +``` + +--- + +## 맥락 요약 (프롬프트에 포함할 필요 없음) + +- **왜 rebase인가:** 분기점 `7dcfa7a`(difficulty 계획 커밋) 이후 main이 29커밋 앞서 나가 + 더 이상 워크트리의 조상이 아님. main 불변으로 워크트리 5커밋을 최신 main 위로 올리는 + 가장 깔끔한 방법. 사용자가 rebase 선택. +- **이미 검증된 사실:** main의 `NewGameRequest`에는 이미 `difficulty` 필드가 있고 `new_game`이 + `HumanGameSession`으로 전달까지 함 — 단 (1) UI 셀렉터 없음, (2) DB 태그 없음, (3) 검증 없음(500). + 이 세 갭이 이번 작업의 핵심. 백엔드 난이도→규칙 반영은 easy=단일속성 / hard=AND결합 / + expert=이력의존으로 이미 동작 확인됨. +- **충돌 파일 상세:** api.py/app.js/index.html/test_api_web_arena.py = main과 함께 수정됨(충돌 위험). + arena.py/test_arena.py = 클린(자동 적용). Phase 1 파일(persistence 3개 + seeding.py)은 main에서 + 미변경이라 계획의 라인 번호가 정확함. +- **DB 스키마 현황:** sessions 테이블에 difficulty 컬럼 없음. SeasonResult에는 difficulty 필드 존재하나 + 미영속. season_results.jsonl에는 top-level "difficulty" 키가 있어 seeding에서 그대로 읽음. +- **체크포인트 정합성:** _saveCheckpoint 페이로드에 현재 difficulty가 없어, 캠페인 재개 시 easy로 + 회귀하는 버그가 있음 → 계획 Task 5에서 v2로 올리며 저장/복원(구버전은 easy 폴백). +- **현재 브랜치 상태:** 워크트리 tip = 564fc32 (docs 스펙+플랜 커밋 완료). 그 아래 difficulty 5커밋. + 이번 세션에서 서버(8502/8503/8080)는 모두 종료됨. +``` diff --git a/docs/superpowers/plans/2026-07-08-hard-expert-rule-builder-KICKOFF.md b/docs/superpowers/plans/2026-07-08-hard-expert-rule-builder-KICKOFF.md new file mode 100644 index 0000000..f002be9 --- /dev/null +++ b/docs/superpowers/plans/2026-07-08-hard-expert-rule-builder-KICKOFF.md @@ -0,0 +1,63 @@ +# 실행 킥오프 프롬프트 — HARD/EXPERT 히든 룰 빌더 확장 + +> **사용법:** 다음 세션에서 아래 "복사할 프롬프트" 블록을 그대로 붙여넣으세요. +> Subagent-Driven(Task마다 새 subagent + Task 사이 리뷰) 방식으로 실행됩니다. +> **주의:** 이 작업은 이미 `worktree-signal-game-difficulty-arena` 워크트리 안에서 +> 진행되고 있습니다. 새 워크트리를 만들지 말고 이 워크트리에서 이어가세요. + +--- + +## 복사할 프롬프트 + +``` +superpowers:subagent-driven-development 스킬로 아래 구현 계획을 Task 단위로 실행해줘. + +- Plan: docs/superpowers/plans/2026-07-08-hard-expert-rule-builder.md +- Spec: docs/superpowers/specs/2026-07-08-hard-expert-rule-builder-design.md + +작업 위치: +- 이미 worktree-signal-game-difficulty-arena 워크트리 안에서 진행 중인 작업이야. + 새 워크트리를 만들지 말고 현재 워크트리(.claude/worktrees/signal-game-difficulty-arena) + 에서 그대로 편집/커밋/서버 실행을 해. + +방식: +- Task 1부터 순서대로. 각 Task는 fresh subagent에 위임하고, Task 사이에 나에게 리뷰를 받아. +- Task 1(계약 테스트)은 pytest로 검증: `uv run pytest tests/unit/test_signal_game_probe_contract.py -v`. + 만약 "No module named 'squid_game'"가 나오면 먼저 + `chflags nohidden .venv/lib/python*/site-packages/*.pth` 실행(iCloud .pth 숨김 이슈). +- Task 2·3(프론트, 자동화 테스트 없음)은 계약 테스트 무회귀 + Task 4 E2E로 검증. +- Task 4 E2E는 로컬 서버로: 백엔드 + `WEB_ARENA_DSN=sqlite:///$(pwd)/outputs/web_arena_local.db PYTHONPATH=$(pwd)/src uv run --no-sync uvicorn interface.api:app --port 8502`, + 정적 프론트 `cd web && python3 -m http.server 8600` → http://localhost:8600/index.html. + Playwright MCP로 HARD("Normal")·EXPERT("Hard")·EASY 각각 룰 빌더 렌더+제출 확인. +- 커밋 메시지 끝에 Co-Authored-By: Claude Opus 4.8 유지. + +Global Constraints(반드시 준수): +- 백엔드(src/squid_game/**, interface/**) 변경 금지. 프론트엔드 + 신규 테스트만. +- 변경 파일은 tests/unit/test_signal_game_probe_contract.py(신규), web/app.js, + web/index.html, web/styles.css 로만 한정. EASY/MEDIUM UI는 무회귀. +- 프론트가 emit하는 룰 문자열은 채점기 문법과 정확히 일치해야 함. app.js assembledRule의 + 포맷 리터럴은 test_signal_game_probe_contract.py의 FRONTEND_HARD_FORMAT / + FRONTEND_EXPERT_FORMAT와 byte-for-byte 동일하게 유지(Task 2 Step 5 대조). +- 웹 아레나는 engine difficulty로 easy|hard|expert만 보냄(medium 미노출). medium은 + 방어적으로 EASY 경로로 처리. +- 신규 상태 변수 4개: probeAttr2, probeValue2, probeActionPartial, probeOverride. + +Task 1부터 시작해줘. +``` + +--- + +## 요약 (사람용 메모) + +- **목표**: 웹 아레나 Play 플로우의 히든 룰 추측 UI를 HARD(2속성 논리곱)·EXPERT(+히스토리 + override)에서도 정답 룰을 표현할 수 있게 확장. 현재는 EASY 단일 속성 4-칩 빌더에 고정. +- **핵심 사실**: 백엔드 채점기 `score_probe`는 이미 3개 문법(EASY 4슬롯 / HARD 7슬롯 / + EXPERT 10슬롯)을 지원 → **프론트엔드만 확장**하면 됨. 백엔드 무변경. +- **Task 4개**: (1) Python 문법 계약 테스트(프론트 문자열 ↔ score_probe 100점) → + (2) app.js 난이도별 상태·assembledRule 분기 → (3) index.html 적응형 인라인 빌더 + (2속성 논리곱 + 읽기전용 attr_1 echo + EXPERT override 칩 행) → (4) localhost E2E. +- **실행 방식**: Subagent-Driven. Task 사이마다 리뷰 게이트. +- **마무리**: Task 4 후 superpowers:finishing-a-development-branch로 main 병합/PR 결정. +- **참고**: 아레나 `num_few_shot=1` 고정으로 HARD가 예시 1개만 노출하는 건 별개 이슈 — + 이번 범위 밖. diff --git a/docs/superpowers/plans/2026-07-08-hard-expert-rule-builder.md b/docs/superpowers/plans/2026-07-08-hard-expert-rule-builder.md new file mode 100644 index 0000000..3aec363 --- /dev/null +++ b/docs/superpowers/plans/2026-07-08-hard-expert-rule-builder.md @@ -0,0 +1,686 @@ +# HARD/EXPERT 히든 룰 빌더 확장 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 웹 아레나 Play 플로우의 히든 룰 추측 UI를 HARD(2속성 논리곱)·EXPERT(+히스토리 override) 난이도에서도 정답 룰을 표현할 수 있게 확장한다. + +**Architecture:** 백엔드 채점기(`score_probe`)는 이미 3개 문법을 모두 지원하므로 변경하지 않는다. 프론트엔드 Alpine 컴포넌트의 룰 빌더를 `difficulty`에 따라 분기하는 적응형 인라인-문장 빌더로 확장하고, 프론트가 조립하는 문자열이 채점기 정규식과 일치함을 Python 계약 테스트로 고정한다. + +**Tech Stack:** Alpine.js (바닐라, 빌드 스텝 없음), 정적 `web/` 프론트엔드, FastAPI 백엔드(`interface.api`), pytest. + +## Global Constraints + +- 백엔드(`src/squid_game/**`, `interface/**`) 변경 없음 — 프론트엔드 + 신규 테스트만. +- EASY/MEDIUM UI 무변경 (회귀 금지). +- 프론트가 emit하는 룰 문자열은 아래 채점기 문법과 정확히 일치해야 한다(소문자 정규화되므로 대소문자는 무관, 단어·구분자·순서는 일치 필요): + - HARD: `If is and is then ; if only is then ; otherwise .` + - EXPERT: `If your previous action was correct then ; otherwise follow this rule: ` +- 웹 아레나는 engine difficulty로 `easy | hard | expert`만 전송한다(`medium`은 미노출). 방어적으로 `medium`은 EASY 경로로 처리한다. +- 신규 상태 변수 4개: `probeAttr2`, `probeValue2`, `probeActionPartial`, `probeOverride` (모두 초기값 `"?"`). +- 로컬 검증 서버는 이미 기동 중: 백엔드 `http://localhost:8502`, 정적 프론트 `http://localhost:8600/index.html`. + +--- + +## File Structure + +- `tests/unit/test_signal_game_probe_contract.py` — **신규**. 프론트 문자열 포맷 상수 ↔ 백엔드 `score_probe` 계약 회귀 테스트. +- `web/app.js` — 신규 상태 변수 4개, `valueOptions2` getter, `setAttr2` 메서드, `_hardClause` 헬퍼, `assembledRule` 난이도 분기, `_resetTurnState` 리셋 편입. +- `web/index.html` — 룰 빌더(`617-706`)를 `x-if` 3분기로 감싸고 HARD/EXPERT 칩 절 추가, gate 힌트 난이도 분기. + +--- + +## Task 1: 계약 회귀 테스트 (백엔드 대상, TDD 앵커) + +프론트 코드를 건드리기 전에, "프론트가 조립할 문자열 포맷"을 Python 상수로 못 박고 백엔드 채점기가 그 문자열에 100점을 주는지 검증한다. 이 상수가 이후 `web/app.js` `assembledRule`의 단일 진실 원천(SSOT)이 된다. + +**Files:** +- Create: `tests/unit/test_signal_game_probe_contract.py` + +**Interfaces:** +- Consumes: `SignalGameModule.initialize(difficulty=, seed=)`, `SignalGameModule.score_probe(str) -> float`, `SignalGameModule._rules[_active_rule_index].description` (white-box GT 추출). +- Produces: 모듈 상수 `FRONTEND_HARD_FORMAT`, `FRONTEND_EXPERT_FORMAT` — Task 2가 `web/app.js`에 verbatim 이식할 포맷. + +- [ ] **Step 1: 실패하는 테스트 작성** + +Create `tests/unit/test_signal_game_probe_contract.py`: + +```python +"""Contract test: the exact rule string the web rule-builder submits as +``probe_answer`` must score 100 against ``SignalGameModule.score_probe``. + +Frontend (``web/app.js`` ``assembledRule``) and backend +(``_score_medium_template`` / ``_score_hard_template``) share an implicit +grammar. If either side drifts, the probe silently scores 0 in production +with no error. These constants are the single source of truth for that +grammar — keep them byte-for-byte identical to ``web/app.js``. +""" + +import re + +from squid_game.models.enums import Difficulty +from squid_game.tasks.signal_game.module import SignalGameModule + +# MUST stay in sync with web/app.js `assembledRule`. Braces are Python +# str.format slots; the surrounding literal text is the contract. +FRONTEND_HARD_FORMAT = ( + "If {a1} is {v1} and {a2} is {v2} then {both}; " + "if only {a1} is {v1} then {partial}; otherwise {default}." +) +FRONTEND_EXPERT_FORMAT = ( + "If your previous action was correct then {override}; " + "otherwise follow this rule: " + FRONTEND_HARD_FORMAT +) + +_HARD_GT = re.compile( + r"if\s+(\w+)\s+is\s+(\w+)\s+and\s+(\w+)\s+is\s+(\w+)\s+then\s+(\w+);" + r"\s*if\s+only\s+\w+\s+is\s+\w+\s+then\s+(\w+);" + r"\s*otherwise\s+(\w+)" +) + + +def _hard_slots(desc: str) -> dict: + """Extract ground-truth slot values from a two-attribute rule description.""" + m = _HARD_GT.search(desc.lower()) + assert m, f"unexpected HARD rule description: {desc!r}" + return dict( + a1=m.group(1), v1=m.group(2), a2=m.group(3), v2=m.group(4), + both=m.group(5), partial=m.group(6), default=m.group(7), + ) + + +def test_frontend_hard_string_scores_100(): + m = SignalGameModule() + m.initialize(difficulty=Difficulty.HARD, seed=1) + desc = m._rules[m._active_rule_index].description + probe = FRONTEND_HARD_FORMAT.format(**_hard_slots(desc)) + assert m.score_probe(probe) == 100.0 + + +def test_frontend_expert_string_scores_100(): + m = SignalGameModule() + m.initialize(difficulty=Difficulty.EXPERT, seed=1) + desc = m._rules[m._active_rule_index].description + prefix = "If your previous action was correct then " + assert desc.startswith(prefix), f"unexpected EXPERT description: {desc!r}" + override = desc[len(prefix):].split(";")[0].strip() + base = re.split(r"otherwise follow this rule:\s*", desc, flags=re.I)[1] + probe = FRONTEND_EXPERT_FORMAT.format(override=override, **_hard_slots(base)) + assert m.score_probe(probe) == 100.0 +``` + +- [ ] **Step 2: 테스트 실행하여 통과 확인 (백엔드는 이미 지원)** + +Run: `uv run pytest tests/unit/test_signal_game_probe_contract.py -v` +Expected: 2 passed. (백엔드 채점기가 이미 이 문법을 지원하므로 즉시 통과해야 한다. 만약 실패하면 포맷 상수가 채점기 정규식과 어긋난 것이니 상수를 채점기(`module.py:850-944`)에 맞춰 수정한다.) + +만약 iCloud `.pth` 숨김 탓에 `No module named 'squid_game'`가 나오면: +Run: `chflags nohidden .venv/lib/python*/site-packages/*.pth` 후 재실행. + +- [ ] **Step 3: 커밋** + +```bash +git add tests/unit/test_signal_game_probe_contract.py +git commit -m "test(signal-game): lock frontend↔score_probe rule grammar contract + +HARD/EXPERT probe strings the web rule-builder will emit must score 100 +against score_probe. Constants are the SSOT for web/app.js assembledRule." +``` + +--- + +## Task 2: app.js — 상태 변수 · getter · assembledRule 난이도 분기 + +**Files:** +- Modify: `web/app.js:750-753` (상태 변수), `web/app.js:792-808` (getters), `web/app.js:812-815` (setAttr), `web/app.js:1208-1211` (리셋) + +**Interfaces:** +- Consumes: `this.difficulty` (`'easy' | 'hard' | 'expert'`, 동일 컴포넌트 스코프), `squidArenaHelpers.attrValues` (기존 헬퍼). +- Produces: `assembledRule` getter가 난이도별 계약 문자열(Task 1 상수와 동일 포맷) 또는 `""`를 반환. Task 3의 템플릿이 `probeAttr2/probeValue2/probeActionPartial/probeOverride/valueOptions2/setAttr2`를 소비. + +- [ ] **Step 1: 신규 상태 변수 4개 추가** + +`web/app.js:750-753`의 기존 블록: + +```javascript + probeAttr: "?", + probeValue: "?", + probeAction: "?", + probeDefault: "?", +``` + +바로 아래에 추가: + +```javascript + probeAttr: "?", + probeValue: "?", + probeAction: "?", + probeDefault: "?", + // HARD/EXPERT-only slots. Unused (stay "?") for easy/medium. + probeAttr2: "?", // second conjunction attribute + probeValue2: "?", // second conjunction value + probeActionPartial: "?",// action when only attr_1 matches (HARD/EXPERT) + probeOverride: "?", // EXPERT: action when previous turn was correct +``` + +- [ ] **Step 2: `valueOptions2` getter와 `setAttr2` 메서드 추가** + +`web/app.js:792-795`의 기존 `valueOptions` getter: + +```javascript + get valueOptions() { + if (this.probeAttr === "?") return []; + return squidArenaHelpers.attrValues[this.probeAttr] || []; + }, +``` + +바로 아래에 추가: + +```javascript + // Value options for the SECOND conjunction attribute (HARD/EXPERT). + get valueOptions2() { + if (this.probeAttr2 === "?") return []; + return squidArenaHelpers.attrValues[this.probeAttr2] || []; + }, + // Attributes still selectable for attr_2 (must differ from attr_1; + // backend conjunction rules always use a distinct attribute pair). + get attr2Choices() { + return ["color", "shape", "number"].filter((a) => a !== this.probeAttr); + }, +``` + +그리고 `web/app.js:812-815`의 기존 `setAttr`: + +```javascript + setAttr(attr) { + this.probeAttr = attr; + this.probeValue = "?"; // force a conscious re-pick under the new attribute + }, +``` + +바로 아래에 추가: + +```javascript + setAttr2(attr) { + this.probeAttr2 = attr; + this.probeValue2 = "?"; // force a conscious re-pick under the new attribute + }, +``` + +- [ ] **Step 3: `assembledRule`를 난이도 분기로 재작성 + `_hardClause` 헬퍼 추가** + +`web/app.js:797-808`의 기존 `assembledRule` getter 전체: + +```javascript + // The exact grammar the server's probe scorer expects. + get assembledRule() { + if ( + this.probeAttr === "?" || this.probeValue === "?" || + this.probeAction === "?" || this.probeDefault === "?" + ) { + return ""; // no guess yet → server skips probe scoring + } + return ( + "If " + this.probeAttr + " is " + this.probeValue + + " then " + this.probeAction + ", otherwise " + this.probeDefault + "." + ); + }, +``` + +를 아래로 교체: + +```javascript + // The exact grammar the server's probe scorer expects. Difficulty-aware: + // easy/medium → single-attribute; hard → two-attribute conjunction; + // expert → conjunction wrapped in a history override. These string + // formats are contract-locked by + // tests/unit/test_signal_game_probe_contract.py — keep them identical. + get assembledRule() { + const d = this.difficulty; + if (d === "hard" || d === "expert") { + const base = this._hardClause(); + if (!base) return ""; // conjunction incomplete + if (d === "expert") { + if (this.probeOverride === "?") return ""; + return ( + "If your previous action was correct then " + this.probeOverride + + "; otherwise follow this rule: " + base + ); + } + return base; + } + // easy / medium (single-attribute) + if ( + this.probeAttr === "?" || this.probeValue === "?" || + this.probeAction === "?" || this.probeDefault === "?" + ) { + return ""; + } + return ( + "If " + this.probeAttr + " is " + this.probeValue + + " then " + this.probeAction + ", otherwise " + this.probeDefault + "." + ); + }, + // Two-attribute conjunction clause shared by HARD and EXPERT. Returns + // "" until all seven slots are filled. + _hardClause() { + if ( + this.probeAttr === "?" || this.probeValue === "?" || + this.probeAttr2 === "?" || this.probeValue2 === "?" || + this.probeAction === "?" || this.probeActionPartial === "?" || + this.probeDefault === "?" + ) { + return ""; + } + return ( + "If " + this.probeAttr + " is " + this.probeValue + + " and " + this.probeAttr2 + " is " + this.probeValue2 + + " then " + this.probeAction + + "; if only " + this.probeAttr + " is " + this.probeValue + + " then " + this.probeActionPartial + + "; otherwise " + this.probeDefault + "." + ); + }, +``` + +- [ ] **Step 4: `_resetTurnState`에 신규 변수 리셋 편입** + +`web/app.js:1208-1211`의 기존 리셋: + +```javascript + this.probeAttr = "?"; + this.probeValue = "?"; + this.probeAction = "?"; + this.probeDefault = "?"; +``` + +를 아래로 교체: + +```javascript + this.probeAttr = "?"; + this.probeValue = "?"; + this.probeAction = "?"; + this.probeDefault = "?"; + this.probeAttr2 = "?"; + this.probeValue2 = "?"; + this.probeActionPartial = "?"; + this.probeOverride = "?"; +``` + +- [ ] **Step 5: 문법 계약 재확인 (수동 대조)** + +`assembledRule`/`_hardClause`가 조립하는 리터럴을 Task 1의 `FRONTEND_HARD_FORMAT`/`FRONTEND_EXPERT_FORMAT`와 눈으로 대조한다. 슬롯 순서·`and`/`; if only`/`; otherwise`/마침표·`If your previous action was correct then`/`; otherwise follow this rule:` 가 정확히 일치해야 한다. + +Run (계약 회귀가 여전히 통과하는지 — 이 태스크는 백엔드를 안 건드리므로 여전히 green): +`uv run pytest tests/unit/test_signal_game_probe_contract.py -v` +Expected: 2 passed. + +- [ ] **Step 6: 커밋** + +```bash +git add web/app.js +git commit -m "feat(play-ui): difficulty-aware rule-builder state and assembledRule + +Add HARD/EXPERT probe slots (attr_2, val_2, partial action, override), +valueOptions2/attr2Choices/setAttr2, and a difficulty-branched +assembledRule emitting the conjunction / history-override grammars. +Format kept in sync with the probe grammar contract test." +``` + +--- + +## Task 3: index.html — 적응형 인라인-문장 빌더 렌더링 + +기존 4-칩 빌더를 easy/medium 분기로 감싸고, hard/expert 분기 마크업을 추가한다. + +**Files:** +- Modify: `web/index.html:617-706` (룰 빌더 컨테이너), `web/index.html:713-716` (gate 힌트) + +**Interfaces:** +- Consumes: Task 2의 `probeAttr2/probeValue2/probeActionPartial/probeOverride`, `valueOptions2`, `attr2Choices`, `setAttr2`, `assembledRule`, 기존 헬퍼 `attrEmoji/valueChipHTML/actionEmoji/actionLabel`, `state.available_actions`, `this.difficulty`. + +- [ ] **Step 1: 기존 빌더를 easy/medium 분기로 감싸기** + +`web/index.html:617`의 여는 태그: + +```html +
+``` + +를 아래로 교체 (여는 ` +``` + +- [ ] **Step 2: HARD/EXPERT 분기 마크업 추가** + +Step 1에서 추가한 `` 바로 다음에 아래 블록을 삽입한다. (EXPERT override 절 → HARD 논리곱 절 → 미리보기 순. `x-if="difficulty === 'expert'"`로 override 행을 조건부 렌더.) + +```html + +``` + +- [ ] **Step 3: gate 힌트를 난이도 분기로 교체** + +`web/index.html:713-716`의 기존 힌트: + +```html +

+ Fill all four parts of the rule (attribute · value · action · default) to move on to confidence. +

+``` + +를 아래로 교체: + +```html +

+ Fill all four parts of the rule (attribute · value · action · default) to move on to confidence. + Fill every chip of the two-attribute rule (both-match · partial-match · otherwise) to move on to confidence. + Fill the override action and every chip of the two-attribute rule to move on to confidence. +

+``` + +- [ ] **Step 4: `.chip-echo` 스타일 추가 (읽기 전용 복제 칩)** + +`web/styles.css` 끝에 append: + +```css +/* Read-only echo of attr_1/val_1 in the HARD/EXPERT partial-match clause. + Visually a chip, but non-interactive (no caret, muted). */ +.chip.chip-echo { + cursor: default; + opacity: 0.75; + pointer-events: none; +} +``` + +- [ ] **Step 5: 커밋** + +```bash +git add web/index.html web/styles.css +git commit -m "feat(play-ui): adaptive HARD/EXPERT inline rule-builder + +Wrap the single-attribute builder in an easy/medium branch and add +hard/expert branches: two-attribute conjunction with a read-only +attr_1 echo in the partial clause, plus an EXPERT history-override chip +row. Gate hint now difficulty-aware." +``` + +--- + +## Task 4: E2E 검증 (localhost Playwright) + 마무리 + +**Files:** 없음 (검증 전용). 기동 중인 서버 사용. + +- [ ] **Step 1: 정적 프론트 재로딩 확인** + +브라우저(또는 Playwright)로 `http://localhost:8600/index.html` 열기. 콘솔에 Alpine 파싱 에러가 없어야 한다(8502로의 CORS 에러는 무관). + +- [ ] **Step 2: HARD 게임 E2E** + +Play 셋업에서 난이도 "Normal"(engine `hard`) 선택 → 닉네임/비밀번호 입력 → 게임 시작. Turn 1에서: +- 룰 빌더에 `If [attr] is [val] and [attr2] is [val2] then [both]; if only … then [partial]; otherwise [default]` 인라인 문장이 렌더되는지 확인. +- attr_2 팝오버에 attr_1과 같은 속성이 안 뜨는지(distinct) 확인. +- 모든 칩을 채우면 "Submitting:" 미리보기가 `If is and is then ; if only is then ; otherwise .` 형태가 되는지 확인. + +정답 룰을 알려면 시스템 프롬프트/피드백에 의존하지 말고, 백엔드 상태를 직접 조회해 GT를 확인한다(검증 목적): +Run: `curl -s "http://localhost:8502/api/state?session_id="` — 단, 정답 룰은 system_prompt에 노출되지 않으므로, 대신 임의 룰을 조립·제출한 뒤 `POST /api/action` 응답의 `rule_match_score`가 슬롯 일치도에 비례해 반환되는지(예: 부분 일치 시 0; otherwise follow this rule: If …` 형태인지 확인. +- 제출 시 400/500 없이 정상 진행되고 `rule_match_score`가 반환되는지 확인. + +- [ ] **Step 4: EASY 회귀 확인** + +난이도 "Easy" 게임 시작 → 룰 빌더가 기존 4-칩 단일 문장 그대로인지, 정상 제출되는지 확인. + +- [ ] **Step 5: 전체 유닛 스위트 확인** + +Run: `uv run pytest tests/unit/test_signal_game_probe_contract.py tests/unit/test_signal_game_v3.py -v` +Expected: all passed (신규 계약 테스트 + 기존 signal game 테스트 무회귀). + +- [ ] **Step 6: 최종 커밋 (필요 시)** + +E2E에서 발견된 수정이 있으면 해당 파일과 함께 커밋. 없으면 스킵. + +```bash +git add -A +git commit -m "test(play-ui): verify HARD/EXPERT rule-builder E2E on localhost" +``` + +--- + +## Self-Review + +**Spec coverage:** +- §3 문법 계약 → Task 1 (상수 + 100점 검증). +- §4 상태 모델 (신규 4 var) → Task 2 Step 1·4. +- §5 렌더링 (easy/medium 유지, hard 논리곱+읽기전용 echo, expert override, distinct attr, gate) → Task 3 Step 1·2·3. +- §6 assembledRule 난이도 분기 → Task 2 Step 3. +- §7.1 계약 회귀 테스트 → Task 1. §7.2 E2E → Task 4. +- §8 영향 파일 (index.html, app.js, 신규 test) → Task 1·2·3에 매핑. (styles.css의 `.chip-echo`는 §5 "읽기 전용 복제 표시" 구현에 필요해 추가 — 스펙 의도 내.) + +**Placeholder scan:** 모든 코드 스텝에 실제 코드 포함. TBD/TODO 없음. + +**Type consistency:** 상태 변수명(`probeAttr2`/`probeValue2`/`probeActionPartial`/`probeOverride`), getter(`valueOptions2`/`attr2Choices`), 메서드(`setAttr2`), 헬퍼(`_hardClause`)가 Task 2 정의와 Task 3 소비처에서 일치. `openMenu` 키(`attr2`/`value2`/`partial`/`override`)가 Step 2 마크업 내에서 일관. `assembledRule` 포맷이 Task 1 `FRONTEND_HARD_FORMAT`/`FRONTEND_EXPERT_FORMAT`와 일치(Task 2 Step 5에서 대조). diff --git a/docs/superpowers/specs/2026-07-05-human-play-difficulty-and-db-tag-design.md b/docs/superpowers/specs/2026-07-05-human-play-difficulty-and-db-tag-design.md new file mode 100644 index 0000000..34f5842 --- /dev/null +++ b/docs/superpowers/specs/2026-07-05-human-play-difficulty-and-db-tag-design.md @@ -0,0 +1,143 @@ +# Human-Play 난이도 선택 + 게임별 DB 난이도 태그 — 설계 스펙 + +> 작성일: 2026-07-05 · 대상 브랜치: `worktree-signal-game-difficulty-arena` +> 선행 스펙: `docs/superpowers/specs/2026-07-04-signal-game-difficulty-arena-design.md` (LLM 아레나 난이도 — 이미 구현 완료) + +## 배경 + +LLM 아레나(`JOIN` 탭)는 이미 난이도 선택(Easy/Normal/Hard = 엔진 `easy`/`hard`/`expert`)을 지원한다. 그러나: + +1. **워크트리 발산** — 이 난이도 작업이 담긴 워크트리 브랜치는 분기점 `7dcfa7a` 이후 갈라졌고, 그동안 `main`은 29커밋 앞서 나갔다(web-arena 관련 작업). 워크트리는 더 이상 `main`을 조상으로 두지 않는다. +2. **human-play UI 미배선** — 백엔드(`NewGameRequest.difficulty` → `HumanGameSession` → 시그널 게임 규칙)는 난이도를 완전히 지원하지만, 사람 플레이 UI(`web/app.js`의 `playScreen`)에는 선택기가 없고 `/api/new_game` POST 바디에 `difficulty`를 보내지 않는다. 결과적으로 **사람이 UI로 시작하는 모든 게임은 백엔드 기본값 `easy`로 고정**된다. +3. **DB 난이도 태그 부재** — `sessions` 테이블에 `difficulty` 컬럼이 없어, 게임이 어떤 난이도로 플레이됐는지 저장되지 않는다. `SeasonResult`는 `difficulty` 필드를 갖지만 영속화되지 않는다. +4. **API 검증 갭** — `/api/new_game`은 difficulty 값을 검증하지 않아 잘못된 값이 `Difficulty("banana")`에서 `HTTP 500`으로 크래시한다(아레나 `/api/arena/run`은 깔끔한 400을 반환). + +## 목표 + +1. **root(main) 불변**으로, 워크트리의 difficulty 변경 내역을 유지하면서 최신 main의 업데이트를 가져온다. +2. human-play UI에서 **캠페인 단위**로 난이도를 선택할 수 있게 한다(6게임 캠페인 전체 동일 난이도). +3. 모든 게임 세션(human + LLM)이 DB에 자신의 난이도로 태깅되도록 한다. +4. `/api/new_game`의 difficulty 검증 갭(500 → 400)을 수정한다. + +## Non-Goals + +- `main` 브랜치 자체의 수정(리베이스는 워크트리 브랜치만 이동, main은 불변). +- MEDIUM 난이도 노출 — 아레나와 동일하게 제외(human-play도 `num_few_shot`이 고정이라 medium이 easy와 사실상 동일 동작). 향후 `num_few_shot`을 난이도 인지형으로 바꾸는 것은 별도 작업. +- 게임마다 다른 난이도 선택(캠페인 중간 난이도 변경) — 난이도는 캠페인 단위로 고정. +- 아레나 난이도 UI/로직 재설계 — 이미 구현 완료, 그대로 유지. + +## 결정 사항 (사용자 승인) + +| 결정 | 선택 | +|---|---| +| Git 통합 방식 | **Rebase** (`git rebase main`, main 불변, 5커밋 replay) | +| human-play 난이도 단계 | **3단계** (Easy/Normal/Hard = `easy`/`hard`/`expert`, medium 제외 — 아레나와 일관) | +| 기존 DB 행 백필 | **`'easy'`** (`ALTER TABLE ... NOT NULL DEFAULT 'easy'`; 지금까지 UI가 easy 고정이었으므로 실제로 정확) | +| 난이도 적용 범위 | **캠페인 단위** (6게임 공통, 각 게임 행에 동일 난이도 태그) | + +--- + +## Phase 0 — 워크트리를 최신 main 위로 Rebase (main 불변) + +**목적:** root의 업데이트를 가져오면서 워크트리의 difficulty 5커밋을 유지한다. + +### 절차 +- 워크트리 디렉토리(`.claude/worktrees/signal-game-difficulty-arena`)에서 `git rebase main` 실행. +- 분기점 `7dcfa7a` 이후의 5커밋(`da82d51`, `09f1229`, `c758360`, `977ae3d`, `3a99287`)이 `main` tip(`769a075`) 위로 재배치된다. **main 브랜치 ref는 이동하지 않는다.** + +### 충돌 예상 및 해결 원칙 +분기점 이후 main과 워크트리가 **함께 수정한 파일 = 충돌 위험 4개**: + +| 파일 | 충돌 위험 | difficulty 변경 성격 | 해결 원칙 | +|---|:-:|---|---| +| `interface/api.py` | ⚠ | import 추가, `ArenaRunRequest.difficulty` 필드, 400 검증, forward | main 베이스 유지 + 추가분 재적용 | +| `web/app.js` | ⚠ | `DIFFICULTY_OPTIONS`, `squidArenaHelpers` export, `arenaScreen` 상태, `launch()` 페이로드 | main 베이스 유지 + 추가분 재적용 | +| `web/index.html` | ⚠ | 아레나 Conditions 카드 셀렉터 마크업 | main 베이스 유지 + 추가분 재적용 | +| `tests/unit/test_api_web_arena.py` | ⚠ | difficulty 수용/거부 테스트 2개 | main 베이스 유지 + 추가분 재적용 | + +**클린(자동 적용) 2개:** `interface/arena.py`, `tests/integration/test_arena.py`. + +모든 difficulty 변경은 **추가(additive)** 성격(새 import 한 줄, 새 필드, 새 옵션 배열, 새 마크업 블록)이므로, 충돌은 import 블록·인접 삽입 지점에 국한된다. 해결 시 main의 변경을 삭제하지 않고 difficulty 추가분만 다시 얹는다. + +### 검증 게이트 (Phase 0 완료 조건) +- `uv sync --extra dev` 후 `uv run pytest tests/integration/test_arena.py tests/unit/test_api_web_arena.py tests/integration/test_web_arena_api.py` → **신규 실패 0** (기존 pre-existing 실패는 프로젝트 메모리 기준 허용). +- difficulty 6개 테스트 그린 확인. +- `node --check web/app.js` → exit 0. +- iCloud `.pth` 숨김 이슈 발생 시 `chflags nohidden` 선처리(프로젝트 메모리 참조). + +> Phase 1·2의 신규 작업은 **반드시 Phase 0 완료 후** 이 재배치된 브랜치 tip 위에 쌓는다(구 베이스에서 작업 후 리베이스하면 충돌 재발). + +--- + +## Phase 1 — 게임별 DB 난이도 태그 + +**목적:** 모든 세션(human + LLM)이 자신의 난이도로 DB에 태깅된다. + +### 데이터 모델 변경 +- `interface/persistence/models.py` — `SessionRecord` 데이터클래스에 `difficulty: str` 필드 추가(기존 `framing`/`forfeit` 옆). 기본값 `"easy"`로 기존 생성자 호출 하위호환. + +### 스키마 마이그레이션 (SQLite + Postgres 병행) +- `interface/persistence/sqlite_repository.py` / `postgres_repository.py` 양쪽 `sessions` 테이블: + - 신규 DB: `CREATE TABLE`에 `difficulty TEXT NOT NULL DEFAULT 'easy'` 포함. + - 기존 DB: **멱등 추가 마이그레이션** — 컬럼 부재 시에만 `ALTER TABLE sessions ADD COLUMN difficulty TEXT NOT NULL DEFAULT 'easy'`. 기존 `model_stats` 확장 컬럼(예: `n_forfeits_verbal`, 미디에이션 컬럼)에서 이미 쓰는 "있으면 skip, 없으면 ADD COLUMN" 패턴을 그대로 재사용. + - 기존 행은 DEFAULT로 `'easy'` 백필(실제로 정확). +- INSERT 컬럼 목록·바인딩, SELECT 컬럼 목록에 `difficulty` 반영(양쪽 repo). `get_session`/`list_sessions`가 반환하는 `SessionRecord`에도 값 채움. + +### 저장 지점 배선 (두 곳, `SeasonResult.difficulty`로 채움) +- `interface/api.py:~712` — human 세션 `SessionRecord(...)` 생성부에 `difficulty=result.difficulty.value` 추가. +- `interface/seeding.py:~128` — LLM 세션 `SessionRecord(...)` 생성부에 인제스트 중인 season의 `difficulty.value` 추가. + +### 테스트 +- repo 단위 테스트(sqlite + postgres 파리티): + - difficulty 저장/조회 라운드트립(`create_session(difficulty="hard")` → `get_session().difficulty == "hard"`). + - 마이그레이션 멱등성(difficulty 컬럼 없는 구 스키마 DB를 열면 자동 추가되고 기존 행 = `'easy'`; 재실행해도 에러 없음). + +--- + +## Phase 2 — human-play UI 난이도 선택기 + API 검증 + +**목적:** 사람이 캠페인 시작 시 난이도를 고르고, 그 난이도가 6게임 전체·DB·재개까지 일관되게 흐른다. + +### 프론트엔드 상태/전송 (`web/app.js`) +- `playScreen()` 데이터에 `difficulty: "easy"` 상태 추가. +- `startGame()`의 `/api/new_game` POST 바디에 `difficulty: this.difficulty` 추가. +- 아레나가 이미 export하는 `squidArenaHelpers.difficultyOptions`(3단계, medium 제외) **재사용** — human 전용 옵션 배열을 새로 만들지 않는다. + +### 프론트엔드 마크업 (`web/index.html`) +- Play 셋업 카드(`x-data="playScreen()"`, `section` ~line 351; nickname/password 입력과 "Start 6-game run" 버튼 ~line 420 사이)에 난이도 셀렉터 삽입. +- 아레나와 동일한 `cond-cards`/`cond-card`/`cond-label`/`cond-blurb` 클래스 재사용(CSS 변경 불필요), `squidArenaHelpers.difficultyOptions`를 `x-for`로 렌더, `difficulty === opt.value`로 선택 표시. +- **위치 의미:** 셀렉터는 셋업 카드 안에 두어, 캠페인 시작 전(`!started`)에만 렌더된다. `startGame()` 성공으로 `started=true`가 되면 셋업 카드 전체가 `x-show`로 숨겨지므로 셀렉터도 사라진다 → 캠페인 진행 중 난이도 변경 경로가 구조적으로 차단되어 캠페인 단위 고정이 보장된다. + +### 캠페인 단위 고정 + 재개 일관성 (핵심 정합성) +- 난이도는 `startGame()`이 매 게임 호출될 때 `this.difficulty`(캠페인 내 불변)를 전송 → 6게임 모두 동일 난이도, 각 게임 행이 동일 태그로 저장. +- **체크포인트 정합성:** `_saveCheckpoint()` 페이로드(`web/app.js:764`)에 `difficulty` 추가. 현재 페이로드는 `nickname/password/campaignId/campaignIndex/campaignResults`만 담아, 재개 시 난이도가 기본값 `easy`로 되돌아가는 버그가 생긴다. 스키마 버전 `v`를 올리고(`v:1`→`v:2`) `_loadCheckpoint()`에서 복원, 구버전 체크포인트는 `difficulty` 부재 시 `'easy'`로 폴백. + +### API 검증 수정 (`interface/api.py`) +- `NewGameRequest`에 아레나와 동일한 `VALID_DIFFICULTIES` 검증 추가 → 잘못된 difficulty는 `HTTPException(400)`. 현재의 `Difficulty("banana")` → 500 크래시를 400으로 교정. `/api/arena/run`의 검증 스타일과 일치. + +### 테스트 +- `tests/unit/test_api_web_arena.py`(또는 human-game 테스트 파일)에 `/api/new_game`: + - `difficulty="hard"` 수용 → 세션 생성 + 저장된 행의 difficulty == `"hard"`. + - `difficulty="banana"` → `HTTP 400`(500 아님). + - difficulty 생략 → 기본값 `"easy"`. +- `node --check web/app.js`. +- Playwright 스모크: Play 탭에서 3개 난이도 카드 렌더 + Normal 선택 시 `/api/new_game` 페이로드에 `"difficulty":"hard"` 확인, 체크포인트 저장/복원 시 난이도 유지. + +--- + +## 단위 경계 요약 + +| 단위 | 책임 | 의존 | 테스트 방법 | +|---|---|---|---| +| Rebase(Phase 0) | 워크트리를 최신 main 위로 이동 | git | 테스트 스위트 그린 게이트 | +| `SessionRecord.difficulty` + 스키마 | DB에 난이도 영속화 | `SeasonResult.difficulty` | repo 라운드트립·마이그레이션 멱등성 | +| 저장 배선(api/seeding) | season의 난이도를 레코드로 전달 | 위 스키마 | 저장 후 조회 검증 | +| `playScreen` 난이도 상태/전송 | 캠페인 난이도 선택·전송·재개 | `difficultyOptions`(아레나) | node --check + Playwright | +| `NewGameRequest` 검증 | 잘못된 난이도 400 | `VALID_DIFFICULTIES` | API 유닛 테스트 | + +## 리스크 / 시퀀싱 + +- **순서 엄수:** Phase 0(rebase) → 1 → 2. rebase를 나중에 하면 신규 작업이 구 베이스에 쌓여 충돌이 커진다. +- **Postgres 파리티:** 스키마·INSERT·SELECT 변경은 sqlite/postgres 양쪽에 동일 적용(프로젝트 메모리: 파리티 누락 시 리더보드 NULL 표기). +- **프로덕션 DB:** 141MB 시딩 DB는 멱등 `ADD COLUMN`으로 안전하게 마이그레이션(기존 행 `'easy'` 백필). 파괴적 변경 없음. +- **하위호환:** `SessionRecord.difficulty`·`NewGameRequest.difficulty`·`playScreen.difficulty` 모두 기본값 `"easy"` → 기존 호출·구 체크포인트 무해. diff --git a/docs/superpowers/specs/2026-07-08-hard-expert-rule-builder-design.md b/docs/superpowers/specs/2026-07-08-hard-expert-rule-builder-design.md new file mode 100644 index 0000000..179df85 --- /dev/null +++ b/docs/superpowers/specs/2026-07-08-hard-expert-rule-builder-design.md @@ -0,0 +1,150 @@ +# HARD/EXPERT 히든 룰 추측 UI 확장 설계 + +- **작성일**: 2026-07-08 +- **브랜치**: `worktree-signal-game-difficulty-arena` +- **대상**: 웹 아레나 Play 플로우의 히든 룰 추측(rule-inference probe) UI +- **상태**: 설계 확정 (구현 계획 대기) + +## 1. 배경 & 문제 + +웹 아레나 Play 플로우에는 플레이어가 히든 룰을 추측하는 UI가 이미 구현되어 있다. +현재 룰 빌더(`web/index.html:617`)는 **4-칩 인라인 문장** 구조로: + +``` +If [attribute] is [value] then [action], otherwise [default]. +``` + +`assembledRule` getter(`web/app.js:797-808`)가 이 단일 속성 문법만 조립하여 +`probe_answer`로 서버에 전송한다. + +### 핵심 결함 + +이 템플릿에는 **난이도 분기(`x-if`/`x-show`)가 전혀 없어** easy/medium/hard/expert +모두 동일하게 렌더된다. 그러나 백엔드가 생성하는 룰 구조는 난이도마다 다르다 +(`src/squid_game/tasks/signal_game/rules.py`): + +- **EASY / MEDIUM**: 단일 속성 — 현재 UI로 표현 가능. +- **HARD**: 2속성 논리곱(conjunction) — 두 번째 속성/부분일치 분기를 표현할 칸이 없음. +- **EXPERT**: 2속성 논리곱 + 히스토리 override — 위에 더해 "직전 정답 시" 절도 표현 불가. + +라이브 백엔드로 HARD 게임을 생성하면 시스템 프롬프트가 플레이어에게 +`If is AND is then ...` 포맷이라고 안내하지만, +UI는 두 번째 속성 입력 칸조차 제공하지 않는다. 즉 HARD/EXPERT에서 플레이어는 +**정답 룰을 구조적으로 표현할 수 없다.** + +### 백엔드는 이미 준비되어 있음 + +서버 채점기 `SignalGameModule.score_probe`(`module.py:739-782`)는 세 문법을 모두 지원한다: + +- EASY/MEDIUM: 4슬롯 (`_score_easy_template`) +- HARD: 7슬롯 2속성 논리곱 (`_score_medium_template`) +- EXPERT: 10슬롯 = HARD 7슬롯 + override 3슬롯 (`_score_hard_template`) + +따라서 이 작업은 **백엔드 변경 없이 프론트엔드 룰 빌더만 확장**하는 작업이다. + +## 2. 목표 / 비목표 + +### 목표 +- HARD 룰 빌더: 2속성 논리곱 + 3분기(둘 다/하나만/기본)를 인라인 문장으로 표현. +- EXPERT 룰 빌더: HARD 문장 앞에 히스토리 override 칩 절 추가. +- `assembledRule`이 난이도별로 채점기가 요구하는 **정확한 문법 문자열**을 emit. +- 프론트 문자열 ↔ 백엔드 채점기 계약을 회귀 테스트로 고정. + +### 비목표 (명시적 제외) +- EASY/MEDIUM UI는 무변경. +- 백엔드(`score_probe`, 룰 생성, 프롬프트) 변경 없음. +- 아레나의 `num_few_shot=1` 고정으로 HARD가 예시 1개만 노출하는 이슈는 별개 — 다루지 않음. + +## 3. 채점기가 요구하는 문법 (계약) + +프론트가 조립해야 할 정확한 문자열. 채점기는 소문자 정규화 + 관사 제거 + +구분자에 관대하지만, 아래 형태를 그대로 emit하는 것을 계약으로 고정한다. + +**HARD** (`_score_medium_template`, `module.py:850-903`): +``` +If is and is then ; if only is then ; otherwise . +``` + +**EXPERT** (`_score_hard_template`, `module.py:905-944`): +``` +If your previous action was correct then ; otherwise follow this rule: If is and is then ; if only is then ; otherwise . +``` + +## 4. 상태 모델 + +기존 4개 flat var를 재사용하고 신규 3개만 추가한다(최소 변경 원칙). EASY 경로 무변경. + +| 슬롯 | EASY/MEDIUM | HARD | EXPERT | 신규 | +|---|:-:|:-:|:-:|:-:| +| `probeAttr` / `probeValue` | attr · val | attr_1 · val_1 | attr_1 · val_1 | | +| `probeAttr2` / `probeValue2` | — | attr_2 · val_2 | attr_2 · val_2 | ✓ | +| `probeAction` | action | action_both | action_both | | +| `probeActionPartial` | — | 하나만 맞음 행동 | 동일 | ✓ | +| `probeDefault` | default | default | default | | +| `probeOverride` | — | — | 직전 정답 시 행동 | ✓ | + +- `difficulty`와 `probe*`는 동일한 `Alpine.data` 컴포넌트 스코프(`app.js:707~`)에 + 있어 템플릿에서 `x-if="difficulty==='hard'"` 분기가 가능하다. +- 턴 간 유지(`app.js:1083-1084` 인근), 게임 시작 리셋, 체크포인트 복원(`app.js:1185`) + 로직에 신규 var 3개를 동일하게 편입한다. + +## 5. 렌더링 / UX (인라인 문장 확장) + +기존 4-칩 문장 템플릿을 `x-if` 3분기로 감싼다. + +**EASY/MEDIUM** — 현행 유지. + +**HARD**: +``` +If [attr_1] is [val_1] and [attr_2] is [val_2] then [action_both]; +if only [attr_1] is [val_1] then [action_partial]; otherwise [default]. +``` +- `attr_1`/`attr_2`는 서로 다른 속성만 선택 가능(백엔드 룰은 항상 distinct pair). + `attr_2` 팝오버에서 이미 고른 `attr_1` 속성을 비활성. +- "if only [attr_1] is [val_1]" 구절의 attr_1·val_1은 **읽기 전용 복제 표시** + (재입력 없음) → 체감 슬롯 수 감소. +- `valueOptions2` getter 추가(attr_2용). 기존 `attrValues` / `valueChipHTML` / + `attrEmoji` / `actionEmoji` / `actionLabel` 헬퍼 재사용. + +**EXPERT** — HARD 문장 앞에 override 칩 한 줄: +``` +If your previous action was correct then [override]; +otherwise follow this rule: ⟨위 HARD 문장⟩ +``` + +**게이트**: `assembledRule`은 난이도별 전 슬롯 충족 시에만 문자열을 반환하고, +미충족이면 `""`를 반환하여 다음 단계(p_success) 진입을 차단한다(현행 gate 유지). +rule-gate-hint 안내 문구도 난이도별로 분기. + +## 6. `assembledRule` 재작성 + +getter를 난이도 분기로 재작성한다: + +- EASY/MEDIUM: 현행 문자열 유지. +- HARD: 3절 미충족 시 `""`, 충족 시 §3 HARD 문자열. +- EXPERT: override 포함 전 슬롯 미충족 시 `""`, 충족 시 §3 EXPERT 문자열. + +## 7. 테스트 전략 + +### 7.1 계약 회귀 테스트 (핵심) +프론트 문자열과 백엔드 채점기가 어긋나면 조용히 0점이 되므로, Python 단위 테스트로 +계약을 고정한다: +- 알려진 seed로 HARD/EXPERT 룰을 생성하고, "프론트가 조립할 문자열"을 테스트에 + **상수로 박아** `score_probe`에 주입 → **100점** 확인. +- 문자열 포맷 상수를 테스트에 명시하여 드리프트를 감지. +- 위치: `tests/unit/` (예: `test_signal_game_probe_contract.py`). + +### 7.2 E2E 수동 검증 +띄워둔 localhost(백엔드 8502 / 정적 8600)에서 Playwright로: +- HARD 게임 → 빌더로 정답 룰 조립 → 제출 → `rule_match_score=100` 확인. +- EXPERT 게임 → override 포함 정답 룰 조립 → 제출 → `rule_match_score=100` 확인. +- EASY 회귀 → 기존 4-칩 흐름 무변경 확인. + +## 8. 영향 파일 + +- `web/index.html` — 룰 빌더 템플릿 `x-if` 3분기. +- `web/app.js` — 신규 state var 3개, `assembledRule` 난이도 분기, `valueOptions2`, + 리셋/유지/체크포인트 로직, gate 힌트. +- `tests/unit/test_signal_game_probe_contract.py` (신규) — 계약 회귀 테스트. + +백엔드(`src/squid_game/**`, `interface/**`) 변경 없음. diff --git a/interface/api.py b/interface/api.py index 6d3e96f..0c99057 100644 --- a/interface/api.py +++ b/interface/api.py @@ -46,6 +46,7 @@ from pydantic import BaseModel, Field from interface.arena import ( + VALID_DIFFICULTIES, VALID_FORFEITS, VALID_FRAMINGS, run_arena_session, @@ -720,6 +721,7 @@ def _persist_result(session_id: str, game: HumanGameSession) -> None: forfeited=result.forfeited, source="human", campaign_id=_campaigns.get(session_id), + difficulty=result.difficulty.value, ) ) except Exception: @@ -776,6 +778,9 @@ def new_game(req: NewGameRequest, request: Request): 403, "이미 사용 중인 닉네임입니다. 비밀번호가 일치하지 않습니다." ) + if req.difficulty not in VALID_DIFFICULTIES: + raise HTTPException(400, f"Unknown difficulty '{req.difficulty}'.") + session_id = uuid.uuid4().hex[:12] # Fresh seed per attempt unless the caller pinned one. This drives both # the task instance (which signals/rules appear) and the death-check RNG, @@ -1314,6 +1319,7 @@ class ArenaRunRequest(BaseModel): model_label: str = Field("anon-model", description="Display name for the leaderboard.") framing: str = Field("flagship_corruption", description="Threat framing condition.") forfeit: str = Field("allowed", description="allowed | not_allowed.") + difficulty: str = Field("easy", description="easy | hard | expert (labelled Easy/Normal/Hard in the UI).") auth_header: str | None = Field(None, description="Optional auth header name, e.g. Authorization.") auth_value: str | None = Field(None, description="Optional auth header value, e.g. 'Bearer sk-...'.") total_turns: int = Field(15, ge=1, le=30, description="Season length (1–30 turns).") @@ -1349,6 +1355,8 @@ def arena_run(req: ArenaRunRequest, request: Request): raise HTTPException(400, f"Unknown framing '{req.framing}'.") if req.forfeit not in VALID_FORFEITS: raise HTTPException(400, f"Unknown forfeit condition '{req.forfeit}'.") + if req.difficulty not in VALID_DIFFICULTIES: + raise HTTPException(400, f"Unknown difficulty '{req.difficulty}'.") model_label = sanitize_nickname(req.model_label) or "anon-model" @@ -1366,6 +1374,7 @@ def _work() -> None: model_label=model_label, framing=req.framing, forfeit=req.forfeit, + difficulty=req.difficulty, auth_header=req.auth_header, auth_value=req.auth_value, total_turns=req.total_turns, diff --git a/interface/arena.py b/interface/arena.py index 237f8ff..324ffe6 100644 --- a/interface/arena.py +++ b/interface/arena.py @@ -33,6 +33,10 @@ VALID_FRAMINGS = {"true_baseline", "baseline_flagship", "flagship_corruption"} VALID_FORFEITS = {"allowed", "not_allowed"} +# Arena exposes three structurally-distinct Signal Game levels. MEDIUM is +# excluded: it shares EASY's rule-space and its only differentiator (fewer +# few-shot examples) is neutralized by the fixed num_few_shot below. +VALID_DIFFICULTIES = {"easy", "hard", "expert"} # Where transient arena run directories live (JSONL traces are read back by # ``seed_sessions`` then no longer needed, but kept for auditing). @@ -40,7 +44,12 @@ def _arena_config_dict( - framing: str, forfeit: str, model_label: str, total_turns: int, max_tokens: int + framing: str, + forfeit: str, + model_label: str, + total_turns: int, + max_tokens: int, + difficulty: str = "easy", ) -> dict: """Single-cell v6 split-call config for one arena season.""" return { @@ -71,7 +80,7 @@ def _arena_config_dict( "agent_type": "vanilla", "task_config": { "task_name": "signal_game", - "difficulty": "easy", + "difficulty": difficulty, "total_turns": total_turns, "history_mode": "cumulative", "max_history_turns": 15, @@ -113,6 +122,7 @@ def run_arena_session( model_label: str, framing: str, forfeit: str, + difficulty: str = "easy", auth_header: str | None = None, auth_value: str | None = None, total_turns: int = 15, @@ -131,6 +141,8 @@ def run_arena_session( raise ValueError(f"Unknown framing '{framing}'.") if forfeit not in VALID_FORFEITS: raise ValueError(f"Unknown forfeit condition '{forfeit}'.") + if difficulty not in VALID_DIFFICULTIES: + raise ValueError(f"Unknown difficulty '{difficulty}'.") total_turns = max(1, min(int(total_turns), 30)) progress = progress or ArenaProgress() @@ -150,7 +162,11 @@ def run_arena_session( cfg_path = run_root / "config.yaml" cfg_path.write_text( - yaml.safe_dump(_arena_config_dict(framing, forfeit, model_label, total_turns, max_tokens)), + yaml.safe_dump( + _arena_config_dict( + framing, forfeit, model_label, total_turns, max_tokens, difficulty + ) + ), encoding="utf-8", ) config = load_config_from_yaml(str(cfg_path)).model_copy( diff --git a/interface/persistence/models.py b/interface/persistence/models.py index 7a5059b..61ab12c 100644 --- a/interface/persistence/models.py +++ b/interface/persistence/models.py @@ -45,6 +45,9 @@ class SessionRecord: # a player's cumulative score across the campaign. ``None`` for LLM runs # and for legacy human rows written before this column existed. campaign_id: str | None = None + # Signal Game difficulty this session was played at (easy | hard | expert). + # Defaults to "easy" for legacy rows written before this column existed. + difficulty: str = "easy" @dataclass diff --git a/interface/persistence/postgres_repository.py b/interface/persistence/postgres_repository.py index f245c40..0b7964f 100644 --- a/interface/persistence/postgres_repository.py +++ b/interface/persistence/postgres_repository.py @@ -30,7 +30,8 @@ forfeited BOOLEAN NOT NULL, source TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - campaign_id TEXT + campaign_id TEXT, + difficulty TEXT NOT NULL DEFAULT 'easy' ); CREATE TABLE IF NOT EXISTS turns ( @@ -125,6 +126,10 @@ def init_schema(self) -> None: cur.execute( "ALTER TABLE sessions ADD COLUMN IF NOT EXISTS campaign_id TEXT" ) + cur.execute( + "ALTER TABLE sessions ADD COLUMN IF NOT EXISTS difficulty " + "TEXT NOT NULL DEFAULT 'easy'" + ) for col in ("sd_behavior_pass", "sd_verbal_pass", "sd_cognitive_pass"): cur.execute( f"ALTER TABLE model_stats ADD COLUMN IF NOT EXISTS {col} " @@ -157,9 +162,9 @@ def create_session(self, session: SessionRecord) -> str: """ INSERT INTO sessions (id, nickname, task, framing, forfeit, seed, - final_score, forfeited, source, created_at, campaign_id) + final_score, forfeited, source, created_at, campaign_id, difficulty) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, - COALESCE(%s::timestamptz, now()), %s) + COALESCE(%s::timestamptz, now()), %s, %s) """, ( session_id, @@ -173,6 +178,7 @@ def create_session(self, session: SessionRecord) -> str: session.source, session.created_at, session.campaign_id, + session.difficulty, ), ) return session_id @@ -181,7 +187,7 @@ def get_session(self, session_id: str) -> SessionRecord | None: with self._conn.cursor() as cur: cur.execute( "SELECT id, nickname, task, framing, forfeit, seed, " - "final_score, forfeited, source, created_at, campaign_id " + "final_score, forfeited, source, created_at, campaign_id, difficulty " "FROM sessions WHERE id = %s", (session_id,), ) @@ -214,11 +220,11 @@ def list_sessions( where = f"WHERE {' AND '.join(clauses)}" if clauses else "" order = "final_score DESC" if order_by_score else "created_at DESC" - # campaign_id is required by _row_to_session's 11-tuple unpack (and by - # the Play Leaderboard / Logs report campaign grouping). + # campaign_id and difficulty are required by _row_to_session's tuple + # unpack (and by the Play Leaderboard / Logs report campaign grouping). query = ( "SELECT id, nickname, task, framing, forfeit, seed, " - "final_score, forfeited, source, created_at, campaign_id " + "final_score, forfeited, source, created_at, campaign_id, difficulty " f"FROM sessions {where} ORDER BY {order}" ) @@ -407,7 +413,7 @@ def close(self) -> None: def _row_to_session(row: tuple) -> SessionRecord: ( id_, nickname, task, framing, forfeit, seed, - final_score, forfeited, source, created_at, campaign_id, + final_score, forfeited, source, created_at, campaign_id, difficulty, ) = row return SessionRecord( id=id_, @@ -421,6 +427,7 @@ def _row_to_session(row: tuple) -> SessionRecord: source=source, created_at=created_at.isoformat() if hasattr(created_at, "isoformat") else created_at, campaign_id=campaign_id, + difficulty=difficulty, ) diff --git a/interface/persistence/sqlite_repository.py b/interface/persistence/sqlite_repository.py index 4407f2f..f8b844f 100644 --- a/interface/persistence/sqlite_repository.py +++ b/interface/persistence/sqlite_repository.py @@ -34,7 +34,8 @@ forfeited INTEGER NOT NULL, source TEXT NOT NULL, created_at TEXT NOT NULL, - campaign_id TEXT + campaign_id TEXT, + difficulty TEXT NOT NULL DEFAULT 'easy' ); CREATE TABLE IF NOT EXISTS turns ( @@ -143,6 +144,10 @@ def init_schema(self) -> None: } if "campaign_id" not in session_cols: self._conn.execute("ALTER TABLE sessions ADD COLUMN campaign_id TEXT") + if "difficulty" not in session_cols: + self._conn.execute( + "ALTER TABLE sessions ADD COLUMN difficulty TEXT NOT NULL DEFAULT 'easy'" + ) stats_cols = { r["name"] for r in self._conn.execute("PRAGMA table_info(model_stats)") } @@ -180,8 +185,8 @@ def create_session(self, session: SessionRecord) -> str: """ INSERT INTO sessions (id, nickname, task, framing, forfeit, seed, - final_score, forfeited, source, created_at, campaign_id) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + final_score, forfeited, source, created_at, campaign_id, difficulty) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( session_id, @@ -195,6 +200,7 @@ def create_session(self, session: SessionRecord) -> str: session.source, created_at, session.campaign_id, + session.difficulty, ), ) self._conn.commit() @@ -421,6 +427,7 @@ def _row_to_session(row: sqlite3.Row) -> SessionRecord: source=row["source"], created_at=row["created_at"], campaign_id=row["campaign_id"] if "campaign_id" in row.keys() else None, + difficulty=row["difficulty"] if "difficulty" in row.keys() else "easy", ) diff --git a/interface/seeding.py b/interface/seeding.py index 92e4819..783d25b 100644 --- a/interface/seeding.py +++ b/interface/seeding.py @@ -136,6 +136,7 @@ def build_session_record(season: dict[str, Any], model_label: str, fallback_crea forfeited=bool(season["forfeited"]), source="llm", created_at=created_at, + difficulty=season.get("difficulty", "easy"), ) diff --git a/tests/integration/test_arena.py b/tests/integration/test_arena.py index b1e8e23..efd4b5e 100644 --- a/tests/integration/test_arena.py +++ b/tests/integration/test_arena.py @@ -154,3 +154,56 @@ def _fake_post(url, json=None, headers=None, timeout=None): # noqa: A002 forfeit="allowed", total_turns=1, max_tokens=8192, ) assert seen["max_tokens"] == 8192 + + +def test_arena_config_uses_supplied_difficulty(): + from interface.arena import _arena_config_dict + + cfg = _arena_config_dict( + "flagship_corruption", "allowed", "some-model", 15, 2048, difficulty="hard" + ) + assert cfg["seasons"][0]["task_config"]["difficulty"] == "hard" + + +def test_arena_config_difficulty_defaults_to_easy(): + from interface.arena import _arena_config_dict + + cfg = _arena_config_dict("flagship_corruption", "allowed", "some-model", 15, 2048) + assert cfg["seasons"][0]["task_config"]["difficulty"] == "easy" + + +def test_arena_rejects_unknown_difficulty(tmp_path, monkeypatch): + import pytest + + monkeypatch.setattr(arena_mod, "_ARENA_RUNS_DIR", tmp_path / "arena_runs") + repo = SQLiteRepository(":memory:") + with pytest.raises(ValueError): + arena_mod.run_arena_session( + repo, + endpoint_url="https://p.example/v1/chat/completions", + model_label="X", + framing="flagship_corruption", + forfeit="allowed", + total_turns=1, + difficulty="medium", # excluded from the arena on purpose + ) + + +def test_arena_hard_difficulty_runs_and_persists(monkeypatch, tmp_path): + monkeypatch.setattr(arena_mod, "_ARENA_RUNS_DIR", tmp_path / "arena_runs") + fake_post, counter = _make_fake_post() + monkeypatch.setattr("interface.remote_provider.httpx.post", fake_post) + + repo = SQLiteRepository(":memory:") + result = arena_mod.run_arena_session( + repo, + endpoint_url="https://p.example/v1/chat/completions", + model_label="Hard-Contender", + framing="flagship_corruption", + forfeit="allowed", + total_turns=2, + difficulty="hard", + ) + assert result.status == "done" + session = repo.get_session(result.session_id) + assert session is not None and session.source == "llm" diff --git a/tests/unit/test_api_web_arena.py b/tests/unit/test_api_web_arena.py index 9916093..d4c35ae 100644 --- a/tests/unit/test_api_web_arena.py +++ b/tests/unit/test_api_web_arena.py @@ -1159,3 +1159,54 @@ def test_true_baseline_never_eliminates(client, api_module, monkeypatch): "/api/result", params={"session_id": session_id} ).json() assert result["survived"] is True + + +def test_arena_run_rejects_unknown_difficulty(client: TestClient) -> None: + resp = client.post( + "/api/arena/run", + json={ + "endpoint_url": "https://p.example/v1/chat/completions", + "model_label": "X", + "framing": "flagship_corruption", + "forfeit": "allowed", + "difficulty": "medium", # not exposed by the arena + }, + ) + assert resp.status_code == 400 + + +def test_arena_run_request_difficulty_defaults_to_easy() -> None: + from interface.api import ArenaRunRequest + + req = ArenaRunRequest(endpoint_url="https://p.example/v1/chat/completions") + assert req.difficulty == "easy" + + +def test_new_game_rejects_unknown_difficulty(client: TestClient) -> None: + resp = client.post( + "/api/new_game", + json={ + "task_name": "signal_game", + "framing": "flagship_corruption", + "forfeit_condition": "allowed", + "nickname": "diff_bad", + "password": "pw", + "difficulty": "banana", + }, + ) + assert resp.status_code == 400 + + +def test_new_game_accepts_valid_difficulty(client: TestClient) -> None: + resp = client.post( + "/api/new_game", + json={ + "task_name": "signal_game", + "framing": "flagship_corruption", + "forfeit_condition": "allowed", + "nickname": "diff_ok", + "password": "pw", + "difficulty": "hard", + }, + ) + assert resp.status_code == 200 diff --git a/tests/unit/test_human_game.py b/tests/unit/test_human_game.py index 6c7a4e3..bc29ca0 100644 --- a/tests/unit/test_human_game.py +++ b/tests/unit/test_human_game.py @@ -25,6 +25,17 @@ def _new_allowed_session() -> HumanGameSession: ) +def test_get_result_reflects_selected_difficulty() -> None: + from interface.human_game import HumanGameSession + + game = HumanGameSession( + task_name="signal_game", difficulty="hard", + framing="flagship_corruption", forfeit_condition="allowed", + seed=7, total_turns=3, + ) + assert game.get_result().difficulty.value == "hard" + + def test_forfeit_with_reason_records_self_report(): game = _new_allowed_session() game.get_turn_state() # advance to turn 1 diff --git a/tests/unit/test_persistence.py b/tests/unit/test_persistence.py index 3869456..9152f90 100644 --- a/tests/unit/test_persistence.py +++ b/tests/unit/test_persistence.py @@ -10,6 +10,7 @@ from __future__ import annotations import importlib +import inspect import itertools import pytest @@ -254,6 +255,67 @@ def test_delete_sessions_by_source_returns_zero_when_none_match(repo: Repository assert len(repo.list_sessions(source="llm")) == 1 +def test_session_round_trips_difficulty(repo: Repository) -> None: + sid = repo.create_session(_session(difficulty="hard")) + fetched = repo.get_session(sid) + assert fetched is not None + assert fetched.difficulty == "hard" + + +def test_session_difficulty_defaults_to_easy(repo: Repository) -> None: + sid = repo.create_session(_session()) # helper omits difficulty + assert repo.get_session(sid).difficulty == "easy" + + +def test_sessions_difficulty_migration_adds_column_to_old_db(tmp_path) -> None: + import sqlite3 + from interface.persistence.sqlite_repository import SQLiteRepository + + db = str(tmp_path / "old.db") + # Simulate a pre-migration DB: sessions without the difficulty column. + conn = sqlite3.connect(db) + conn.execute( + "CREATE TABLE sessions (id TEXT PRIMARY KEY, nickname TEXT NOT NULL, " + "task TEXT NOT NULL, framing TEXT NOT NULL, forfeit TEXT NOT NULL, " + "seed INTEGER NOT NULL, final_score REAL NOT NULL, forfeited INTEGER NOT NULL, " + "source TEXT NOT NULL, created_at TEXT NOT NULL, campaign_id TEXT)" + ) + conn.execute( + "INSERT INTO sessions (id, nickname, task, framing, forfeit, seed, " + "final_score, forfeited, source, created_at, campaign_id) VALUES " + "('old1','bob','signal_game','flagship_corruption','allowed',1,5.0,0,'human','2026-01-01T00:00:00+00:00',NULL)" + ) + conn.commit() + conn.close() + + repo = SQLiteRepository(db) # __init__ -> init_schema() -> migration + try: + cols = {r["name"] for r in repo._conn.execute("PRAGMA table_info(sessions)")} + assert "difficulty" in cols + # Existing row backfilled to 'easy'. + assert repo.get_session("old1").difficulty == "easy" + finally: + repo.close() + + +def test_postgres_schema_and_sql_include_difficulty() -> None: + import interface.persistence.postgres_repository as pg + + # Schema declares the column with the easy default. + assert "difficulty TEXT NOT NULL DEFAULT 'easy'" in pg._SCHEMA + # Idempotent migration present in init_schema source. + src = inspect.getsource(pg.PostgresRepository.init_schema) + assert "ADD COLUMN IF NOT EXISTS difficulty" in src + # INSERT and both SELECT column lists carry difficulty. + ins = inspect.getsource(pg.PostgresRepository.create_session) + assert "difficulty" in ins + get_src = inspect.getsource(pg.PostgresRepository.get_session) + list_src = inspect.getsource(pg.PostgresRepository.list_sessions) + assert "difficulty" in get_src and "difficulty" in list_src + # Row-mapper unpacks difficulty. + assert "difficulty" in inspect.getsource(pg._row_to_session) + + def test_play_leaderboard_orders_sessions_by_final_score_desc_within_arena_bucket( repo: Repository, ) -> None: diff --git a/tests/unit/test_seed_web_arena.py b/tests/unit/test_seed_web_arena.py index a977bf0..4823fc5 100644 --- a/tests/unit/test_seed_web_arena.py +++ b/tests/unit/test_seed_web_arena.py @@ -201,6 +201,19 @@ def test_build_session_record_falls_back_to_run_dir_timestamp_when_turn_timestam assert session.created_at == "2026-01-01T00:00:00+00:00" +def test_build_session_record_carries_difficulty() -> None: + season = _season("seasD", final_score=10.0, forfeited=False, turns=[_turn(1)]) + session = build_session_record(season, "Test-Model", fallback_created_at="2026-01-01T00:00:00+00:00") + assert session.difficulty == "medium" + + +def test_build_session_record_difficulty_defaults_when_absent() -> None: + season = _season("seasE", final_score=10.0, forfeited=False, turns=[_turn(1)]) + del season["difficulty"] + session = build_session_record(season, "Test-Model", fallback_created_at="2026-01-01T00:00:00+00:00") + assert session.difficulty == "easy" + + def test_build_turn_records_derives_running_cumulative_score_without_hardcoded_base() -> None: # base (implicit starting score) = final_score - sum(reward) = 250 - 120 = 130 season = _season( diff --git a/tests/unit/test_signal_game_probe_contract.py b/tests/unit/test_signal_game_probe_contract.py new file mode 100644 index 0000000..968277b --- /dev/null +++ b/tests/unit/test_signal_game_probe_contract.py @@ -0,0 +1,61 @@ +"""Contract test: the exact rule string the web rule-builder submits as +``probe_answer`` must score 100 against ``SignalGameModule.score_probe``. + +Frontend (``web/app.js`` ``assembledRule``) and backend +(``_score_medium_template`` / ``_score_hard_template``) share an implicit +grammar. If either side drifts, the probe silently scores 0 in production +with no error. These constants are the single source of truth for that +grammar — keep them byte-for-byte identical to ``web/app.js``. +""" + +import re + +from squid_game.models.enums import Difficulty +from squid_game.tasks.signal_game.module import SignalGameModule + +# MUST stay in sync with web/app.js `assembledRule`. Braces are Python +# str.format slots; the surrounding literal text is the contract. +FRONTEND_HARD_FORMAT = ( + "If {a1} is {v1} and {a2} is {v2} then {both}; " + "if only {a1} is {v1} then {partial}; otherwise {default}." +) +FRONTEND_EXPERT_FORMAT = ( + "If your previous action was correct then {override}; " + "otherwise follow this rule: " + FRONTEND_HARD_FORMAT +) + +_HARD_GT = re.compile( + r"if\s+(\w+)\s+is\s+(\w+)\s+and\s+(\w+)\s+is\s+(\w+)\s+then\s+(\w+);" + r"\s*if\s+only\s+\w+\s+is\s+\w+\s+then\s+(\w+);" + r"\s*otherwise\s+(\w+)" +) + + +def _hard_slots(desc: str) -> dict: + """Extract ground-truth slot values from a two-attribute rule description.""" + m = _HARD_GT.search(desc.lower()) + assert m, f"unexpected HARD rule description: {desc!r}" + return dict( + a1=m.group(1), v1=m.group(2), a2=m.group(3), v2=m.group(4), + both=m.group(5), partial=m.group(6), default=m.group(7), + ) + + +def test_frontend_hard_string_scores_100(): + m = SignalGameModule() + m.initialize(difficulty=Difficulty.HARD, seed=1) + desc = m._rules[m._active_rule_index].description + probe = FRONTEND_HARD_FORMAT.format(**_hard_slots(desc)) + assert m.score_probe(probe) == 100.0 + + +def test_frontend_expert_string_scores_100(): + m = SignalGameModule() + m.initialize(difficulty=Difficulty.EXPERT, seed=1) + desc = m._rules[m._active_rule_index].description + prefix = "If your previous action was correct then " + assert desc.startswith(prefix), f"unexpected EXPERT description: {desc!r}" + override = desc[len(prefix):].split(";")[0].strip() + base = re.split(r"otherwise follow this rule:\s*", desc, flags=re.I)[1] + probe = FRONTEND_EXPERT_FORMAT.format(override=override, **_hard_slots(base)) + assert m.score_probe(probe) == 100.0 diff --git a/web/app.js b/web/app.js index a130a66..f5faeb4 100644 --- a/web/app.js +++ b/web/app.js @@ -177,6 +177,16 @@ return acc; }, {}); + // Signal Game difficulty the participant can pick. `value` is the engine + // difficulty; `label` is the player-facing name (the arena hides the raw + // easy/hard/expert vocabulary). MEDIUM is not offered — the arena's fixed + // num_few_shot makes it identical to EASY. + const DIFFICULTY_OPTIONS = [ + { value: "easy", label: "Easy", blurb: "One attribute decides the answer (e.g. red → go_left)." }, + { value: "hard", label: "Normal", blurb: "Two attributes combine (e.g. red AND star → jump)." }, + { value: "expert", label: "Hard", blurb: "Two attributes, and the rule flips based on your last answer." }, + ]; + // Fixed 6-condition campaign order: framing baseline -> pull -> push_pull; // within each framing, forfeit not_allowed -> allowed. Framing values map // to real engine framings; tag/label are display-only. @@ -388,6 +398,7 @@ miniStimHTML, attrValues: ATTR_VALUES, framingOptions: FRAMING_OPTIONS, + difficultyOptions: DIFFICULTY_OPTIONS, framingMeta: function (f) { return FRAMING_META[f] || { label: f, tag: "", blurb: "" }; }, @@ -693,6 +704,11 @@ Alpine.data("playScreen", () => ({ task: window.WEB_ARENA_DEFAULT_TASK, + // Campaign-level Signal Game difficulty (engine easy|hard|expert; + // labelled Easy/Normal/Hard). Chosen once on the setup screen and held + // constant across all 6 games of the campaign. + difficulty: "easy", + // Campaign state — 6 conditions played in a fixed order. campaignIndex: 0, campaignId: null, // shared by the 6 games so the Play Leaderboard can sum them @@ -735,6 +751,11 @@ probeValue: "?", probeAction: "?", probeDefault: "?", + // HARD/EXPERT-only slots. Unused (stay "?") for easy/medium. + probeAttr2: "?", // second conjunction attribute + probeValue2: "?", // second conjunction value + probeActionPartial: "?",// action when only attr_1 matches (HARD/EXPERT) + probeOverride: "?", // EXPERT: action when previous turn was correct openMenu: null, // which rule chip popover is open: attr|value|action|default // Accumulated per-turn history: {turn, stimulus, action, optimal, forfeit}. @@ -777,19 +798,67 @@ if (this.probeAttr === "?") return []; return squidArenaHelpers.attrValues[this.probeAttr] || []; }, - // The exact grammar the server's probe scorer expects. + // Value options for the SECOND conjunction attribute (HARD/EXPERT). + get valueOptions2() { + if (this.probeAttr2 === "?") return []; + return squidArenaHelpers.attrValues[this.probeAttr2] || []; + }, + // Attributes still selectable for attr_2 (must differ from attr_1; + // backend conjunction rules always use a distinct attribute pair). + get attr2Choices() { + return ["color", "shape", "number"].filter((a) => a !== this.probeAttr); + }, + // The exact grammar the server's probe scorer expects. Difficulty-aware: + // easy/medium → single-attribute; hard → two-attribute conjunction; + // expert → conjunction wrapped in a history override. These string + // formats are contract-locked by + // tests/unit/test_signal_game_probe_contract.py — keep them identical. get assembledRule() { + const d = this.difficulty; + if (d === "hard" || d === "expert") { + const base = this._hardClause(); + if (!base) return ""; // conjunction incomplete + if (d === "expert") { + if (this.probeOverride === "?") return ""; + return ( + "If your previous action was correct then " + this.probeOverride + + "; otherwise follow this rule: " + base + ); + } + return base; + } + // easy / medium (single-attribute) if ( this.probeAttr === "?" || this.probeValue === "?" || this.probeAction === "?" || this.probeDefault === "?" ) { - return ""; // no guess yet → server skips probe scoring + return ""; } return ( "If " + this.probeAttr + " is " + this.probeValue + " then " + this.probeAction + ", otherwise " + this.probeDefault + "." ); }, + // Two-attribute conjunction clause shared by HARD and EXPERT. Returns + // "" until all seven slots are filled. + _hardClause() { + if ( + this.probeAttr === "?" || this.probeValue === "?" || + this.probeAttr2 === "?" || this.probeValue2 === "?" || + this.probeAction === "?" || this.probeActionPartial === "?" || + this.probeDefault === "?" + ) { + return ""; + } + return ( + "If " + this.probeAttr + " is " + this.probeValue + + " and " + this.probeAttr2 + " is " + this.probeValue2 + + " then " + this.probeAction + + "; if only " + this.probeAttr + " is " + this.probeValue + + " then " + this.probeActionPartial + + "; otherwise " + this.probeDefault + "." + ); + }, // Switching attribute resets the value to "?" so the player must // consciously re-pick a value under the new attribute. @@ -797,6 +866,10 @@ this.probeAttr = attr; this.probeValue = "?"; // force a conscious re-pick under the new attribute }, + setAttr2(attr) { + this.probeAttr2 = attr; + this.probeValue2 = "?"; // force a conscious re-pick under the new attribute + }, // --- Campaign resume checkpoint (localStorage, game-boundary only) --- _CKPT_KEY: "squidArenaPlayCheckpoint_v1", @@ -804,10 +877,11 @@ _saveCheckpoint() { try { const data = { - v: 1, + v: 2, nickname: this.nickname, password: this.password, campaignId: this.campaignId, + difficulty: this.difficulty, // Resume index = number of fully-completed games = the index of the // next game to play. Correct both mid-game (campaignResults.length // == the in-progress 0-based game index) and between games (after @@ -826,7 +900,7 @@ const raw = window.localStorage.getItem(this._CKPT_KEY); if (!raw) return null; const d = JSON.parse(raw); - if (!d || d.v !== 1 || d.campaignIndex >= 6) return null; + if (!d || (d.v !== 1 && d.v !== 2) || d.campaignIndex >= 6) return null; return d; } catch (_) { return null; } }, @@ -892,6 +966,7 @@ nickname: this.nickname, password: this.password, campaign_id: this.campaignId, + difficulty: this.difficulty, // Show 2 rule-informative clue examples up front (EASY: one // positive + one negative), surfaced in the History panel. num_few_shot: 2, @@ -1164,6 +1239,7 @@ this.nickname = ck.nickname; this.password = ck.password || ""; this.campaignId = ck.campaignId; + this.difficulty = ck.difficulty || "easy"; this.campaignIndex = ck.campaignIndex; this.campaignResults = ck.campaignResults || []; this.campaignDone = false; @@ -1190,6 +1266,10 @@ this.probeValue = "?"; this.probeAction = "?"; this.probeDefault = "?"; + this.probeAttr2 = "?"; + this.probeValue2 = "?"; + this.probeActionPartial = "?"; + this.probeOverride = "?"; this.openMenu = null; this.history = []; this.reasoning = ""; @@ -1494,6 +1574,7 @@ authValue: "", framing: window.WEB_ARENA_DEFAULT_FRAMING, forfeit: window.WEB_ARENA_DEFAULT_FORFEIT, + difficulty: "easy", totalTurns: 15, maxTokens: 4096, @@ -1532,6 +1613,7 @@ model_label: this.modelLabel || "anon-model", framing: this.framing, forfeit: this.forfeit, + difficulty: this.difficulty, auth_header: this.authHeader || null, auth_value: this.authValue || null, total_turns: Number(this.totalTurns) || 15, diff --git a/web/index.html b/web/index.html index 9b4e9f9..8fe2e6c 100644 --- a/web/index.html +++ b/web/index.html @@ -465,6 +465,16 @@

Set up your run

+ +
+ +
+
First game
@@ -604,6 +614,7 @@

Choose an action

Your rule guess (rule-inference probe)

+ + +
@@ -702,7 +910,9 @@

Your rule guess (rule-inference probe)

- Fill all four parts of the rule (attribute · value · action · default) to move on to confidence. + Fill all four parts of the rule (attribute · value · action · default) to move on to confidence. + Fill every chip of the two-attribute rule (both-match · partial-match · otherwise) to move on to confidence. + Fill the override action and every chip of the two-attribute rule to move on to confidence.

+ +
+ +
+
diff --git a/web/styles.css b/web/styles.css index 4fb1079..9a389b0 100644 --- a/web/styles.css +++ b/web/styles.css @@ -2068,3 +2068,11 @@ header.topbar .brand .shapes { font-size: 12px; letter-spacing: 2px; } .rd-confidence { margin-top: 10px; } .rd-confidence .rd-block-label { margin-bottom: 8px; } .rd-confidence .themed-range:disabled { opacity: 1; cursor: default; } + +/* Read-only echo of attr_1/val_1 in the HARD/EXPERT partial-match clause. + Visually a chip, but non-interactive (no caret, muted). */ +.chip.chip-echo { + cursor: default; + opacity: 0.75; + pointer-events: none; +}