metropolis-chess / docs /ARCHITECTURE.md
Forkei's picture
docs: add ARCHITECTURE.md + GOTCHAS.md, rewrite README for Lucas handover
1d3a309

Architecture

Chess Club is a FastAPI + Socket.IO application. Domain logic splits across four named layers; infrastructure is SQLite + optional Redis + Google Gemini API + two chess engines.


The four-layer split

Soul — app/agents/soul.py

The LLM agent. Runs after the engine move every character turn. Receives a structured SoulInput bundle: a BoardSummary (prose description of the position — not raw FEN), the current MoodState, surfaced memories from the Subconscious, the engine move just played, any pending player chat, and recent match history. Returns a SoulResponse (Pydantic schema in app/schemas/agents.py) containing:

  • speak — nullable; silence is the default (most moves are silent)
  • emotion + emotion_intensity — drives the character video/indicator in the UI
  • mood_deltas — ±0.1 per axis, applied to raw mood after the Soul returns
  • note_about_opponent — queued for post-match memory generation
  • save_memory — optional InlineMemoryRequest; if set, app/memory/inline_save.py persists it asynchronously mid-match
  • referenced_memory_ids — validated as a subset of what the Subconscious surfaced
  • game_action — controls pre-match room flow (start_game, start_match_vs_kenji, propose_goal); always "none" during an active match

Does not pick chess moves. LLM failures degrade silently to a neutral empty response so gameplay never stalls.

The Soul also has lightweight variants for agent contexts: run_agent_soul_in_match() (agent responding to player chat mid-match), run_agent_soul_in_match_move() (agent's own move turn), and run_agent_soul_for_room() (agent pre-match room).

Subconscious — app/agents/subconscious.py

Memory retrieval agent. Runs before the Soul, concurrently with the engine, on the post-player-move board position (not the post-engine position). Multi-axis scoring across all memories for the character or agent:

Axis Weight
Semantic similarity (cosine on 384-dim embeddings) 0.35
Trigger keyword match 0.25
Opponent relevance 0.15
Mood alignment 0.10
Recency penalty −0.15

If the top-1 score beats top-2 by > 0.15, returns the top 5 directly. Otherwise sends the top 8 to Gemini Flash Lite for a structured re-rank with one-sentence retrieval reasons per memory. Per-match cache with a 3-turn TTL keyed by (last_player_uci, chat_hash, mood_polarity_bucket) prevents redundant LLM re-ranks on identical context.

Returns 0–5 SurfacedMemory objects for the Soul's context window.

Director — app/director/

Deterministic code; no LLM calls. Entry point: choose_engine_config(character, mood, match_context) in app/director/director.py. Computes:

  • Effective Elo = current_elo + 100·confidence − 150·tilt, clamped to [floor_elo, max_elo]
  • Engine selection: Stockfish beast-mode when effective_elo > 2100 or aggression ≥ 9 ∧ confidence > 0.7; Maia-2 for 1100–1900 range; Stockfish fallback below Maia's range or when Maia-2 isn't installed
  • Time budget: 0.5s (patience=1) → 5s (patience=10), discounted by aggression, capped at 8s

Mood state (MoodState(aggression, confidence, tilt, engagement), all [0,1]) is managed by app/director/mood.py. Exponential smoothing with τ = 3 moves. Persisted to Redis keyed by match ID; falls back to in-process dict if Redis is absent. The Soul's mood_deltas are applied via apply_deltas() then smooth_mood() after each character turn — the Soul nudges mood, the Director reads it.

Elo ratchet logic lives in app/director/elo.py: ±10% of signed outcome delta per match, clamped ±30; floor rises +25 after 3 consecutive matches where current Elo > floor + 100.

Body — app/engine/

The chess engines. Abstract interface: ChessEngine ABC + EngineConfig + MoveResult in app/engine/base.py. Three implementations:

  • maia2_engine.py — Maia-2 rapid neural network, 1100–1900 Elo band, human-like piece preference. Requires torch + weights (~300 MB, cached in MAIA2_CACHE_DIR).
  • stockfish_engine.py — Stockfish UCI, variable Elo via UCI_Elo, returns top-3 candidate moves with centipawn evaluations.
  • mock_engine.py — First legal move, fully deterministic. Always available; used in tests and dev-without-engines.

Engine lifecycle: app/engine/registry.py. Board → prose summary: board_to_english() in app/engine/board_abstraction.py. This function is why the Soul never receives raw FEN — it gets material counts, castling rights, pinned/hanging pieces, king safety flags, and game phase as readable English. Prevents LLM chess hallucinations (the Soul making up moves it claims to play).

Shuffle guard: app/engine/diversity_guard.py::filter_shuffle_moves() filters candidates that return a piece to a square it recently left (lookback: 6 plies). Applied to Maia's candidate list before the final move is chosen.


How a single match flows

Two orchestration paths share the Soul/Subconscious/engine infrastructure.

Human-vs-character

Key files: app/sockets/server.py (event handler), app/sockets/room_server.py (per-match room), app/matches/streaming.py (turn loop)

  1. Client connects to Socket.IO /play namespace with player_id cookie; server.py authenticates, joins the socket to room match:<match_id>.
  2. Client emits make_move {uci}.
  3. room_server.py validates the session is a participant (not spectator), acquires match_lock(match_id) from app/concurrency/locks.py.
  4. Calls apply_player_move_streamed(match, move_input, emitters) in app/matches/streaming.py:
    • Validates move legality against chess.Board; raises IllegalMove if invalid.
    • Applies player move; emits player_move_applied.
    • Emits agent_thinking with eta hint: engine_time_budget + agent_thinking_soul_overhead_seconds (default 1.5s), rounded to 0.5s. This is a soft estimate, not a hard timeout.
    • Launches two concurrent asyncio tasks: run_subconscious(...) on the current board, and engine.get_move(config).
    • When Subconscious completes → emits memory_surfaced (memory ribbon populates while engine still thinking).
    • When engine completes → emits agent_move (board updates).
    • run_soul(character, soul_input) with engine move + surfaced memories → emits agent_chat (if speak is non-null) and mood_update.
    • Applies mood_deltas from Soul; re-smooths; persists.
    • Checks terminal state; if game over → emits match_ended, starts post-match processor.
  5. Post-match processor (app/post_match/processor.py) runs in a background daemon thread. Five steps: engine analysis → feature extraction → Elo ratchet → memory generation → narrative summary. Status streams back via asyncio.run_coroutine_threadsafe to the main event loop (app/sockets/processor_callback.py). Clients that reconnected can fall back to polling GET /api/matches/{id}/post_match_status.

The memory_surfacedagent_move ordering is the headline UX property: the memory ribbon populates during the "thinking" state rather than after the move lands.

Agent-vs-character

Key files: app/sockets/room_server.py (deploy trigger), app/matches/agent_streaming.py (server-side loop)

The match is fully automated. After the player deploys their agent from the agent room, room_server.py creates the match and fires asyncio.create_task(run_agent_match(...)). The watching player is a spectator in their own match — they see events but don't make moves.

The loop in agent_streaming.py alternates:

  • Agent's turn: engine picks a move from the agent's Elo configuration; run_agent_soul_in_match_move() generates chat. Auto-resign triggers at RESIGN_THRESHOLD_CP = −500 centipawns for 3 consecutive turns.
  • Character's turn: the same _run_engine_and_agents pipeline imported from streaming.py — identical Soul + Subconscious + engine sequence as the human path.

Both sides emit events to the match:<match_id> Socket.IO room. agent_streaming.py imports private helpers from streaming.py (_run_engine_and_agents, _finalize_outcome, _persist_engine_move, etc.) — this is the main coupling between the two flows.

Pre-match rooms

Pre-match chat (human talking to a character before starting): Socket.IO /room namespace, app/sockets/room_server.py. The SoulResponse.game_action field drives match initiation — when the Soul returns start_game (character-side room) or start_match_vs_kenji (agent-side room), the server emits game_started {match_id, redirect_url} and the browser redirects automatically.


Memory model

Storage

Memory rows (app/models/memory.py) carry either character_id or agent_id — exactly one is non-null. Character memories belong to a character (preset or user-created); agent memories belong to the player's agent. No cross-ownership.

Each memory has: narrative (prose, 1–3 sentences), triggers (keyword list, ≥3 required), valence (float sentiment), scope (enum: character_lore / opponent_specific / cross_player), embedding (JSON array, 384 floats), surface_count (retrieval counter).

Generation

Three paths:

  1. Preset seeding (app/characters/memory_generator.py): LLM generates 30–50 memories at first startup from backstory, voice, and quirks. Embeddings created inline at save. Configurable via memory_gen_target / memory_gen_min / memory_gen_max in app/config.py.

  2. Inline (mid-match) (app/memory/inline_save.py): SoulResponse.save_memory carries an InlineMemoryRequest. If set, an asyncio.create_task fires and persists the memory without blocking the turn. The turn returns before the save completes — this is fire-and-forget.

  3. Post-match (app/post_match/memory_gen.py): LLM generates 1–3 MATCH_RECAP / OPPONENT_SPECIFIC memories after each game. Validates ≥3 triggers per memory; retries once with explicit feedback if validation fails. Deduplicates against existing memories at 0.92 cosine similarity threshold before persisting.

Retrieval

app/agents/subconscious.py (see above). Vector search is in-Python cosine similarity via NumPy in app/memory/vector_store.py — no external vector DB. The interface (upsert, search, get_embedding) is designed to be swapped to sqlite-vec without changing any callers.


Streaming architecture

Namespace File Purpose
/room app/sockets/room_server.py Pre-match chat (both character rooms and agent rooms)
/play app/sockets/server.py + room_server.py In-match gameplay, post-match streaming
Lobby/matchmaking app/sockets/lobby_server.py PvP lobby and matchmaking queue
Presence app/sockets/presence_server.py Live online count

All LLM work follows the same pattern — this is the non-negotiable rule for new features:

  1. Receive socket event; validate and authenticate immediately (synchronous, fast).
  2. Acquire per-session or per-match lock from app/concurrency/locks.py.
  3. Launch asyncio.create_task(...) for LLM work — never await the LLM call inline on the event handler.
  4. Emit events as work completes.

The synchronous-HTTP path (app/matches/service.py::apply_player_move) was replaced in Phase 3b specifically because inline await on LLM calls blocked all other socket events. That antipattern must not come back.


Concurrency primitives

app/concurrency/locks.py — two dict registries of asyncio.Lock objects:

  • chat_session_lock(session_id) — serializes LLM calls within a single pre-match chat session
  • match_lock(match_id) — serializes character turns within a match; prevents concurrent make_move events from racing

Both are context managers (async with match_lock(match_id):). Both are in-process dict-backed.

Multi-worker swap seam: The module docstring spells it out: replace the dict[str, asyncio.Lock] with a Redis SET NX EX pattern using redis.asyncio. The call sites don't change — they only see the context manager interface. All state that would break under multi-worker (disconnect registry, spectator counts, Socket.IO room membership) has the same swap point.

Memory note: the dicts grow monotonically — one Lock per unique session/match ID, never freed. At current scale (dozens of concurrent sessions) negligible. At scale, add TTL eviction.


Feature flag system

Three boolean settings in app/config.py, loaded via pydantic-settings from env vars:

Settings field Env var Default
show_player_agents SHOW_PLAYER_AGENTS True
show_clay_economy SHOW_CLAY_ECONOMY False
show_agent_autonomy SHOW_AGENT_AUTONOMY False

Route guard pattern (app/web/routes.py):

def _require_player_agents() -> None:
    if not get_settings().show_player_agents:
        raise HTTPException(status_code=404)

# Applied with:
@router.get("/agents", dependencies=[Depends(_require_player_agents)])

Routes that should be hidden return 404 (not 403 — the flag makes the feature non-existent, not forbidden).

Template injection (module level in app/web/routes.py):

templates.env.globals["feature_flags"] = {
    "show_player_agents": _ff.show_player_agents,
    "show_clay_economy": _ff.show_clay_economy,
    "show_agent_autonomy": _ff.show_agent_autonomy,
}

Templates access feature_flags.show_player_agents to conditionally render nav links, wager panels, and autonomy controls.

Adding a new flag: (1) Add a bool field to Settings in app/config.py. (2) Inject into templates.env.globals in app/web/routes.py. (3) Write a _require_X() guard function. (4) Apply Depends(_require_X) on hidden routes.


Seam-swap interfaces

These are the three places where Lucas needs to wire Chess Club into the wider Metropolis platform. They're designed to be reimplemented without touching call sites.

ClayLedgerapp/economy/clay_ledger.py

Current implementation: SqliteClayLedger — all wagering in Chess Club's own SQLite DB. Abstract interface:

class ClayLedger:
    def get_balance(self, player_id: str) -> int: ...
    def debit(self, player_id, amount, reason, match_id=None) -> ClayTransaction: ...
    def credit(self, player_id, amount, reason, match_id=None) -> ClayTransaction: ...
    def transfer(self, from_id, to_id, amount, reason, match_id=None) -> tuple[...]: ...
    def transactions_for_player(self, player_id, limit=50, reason=None) -> list[...]: ...

All call sites import via from app.economy.clay_ledger import get_ledger, InsufficientFunds. get_ledger() returns the module-level singleton initialized in app/main.py. To swap: subclass ClayLedger, implement the five methods, update get_ledger(). No other files change.

Two likely replacement implementations:

  • MetropolisInternalLedger: delegates to Bhaven's internal balance API over HTTP
  • OnChainClayLedger: calls a Polygon ERC-20 contract (ABI from PolygonScan, transfer → on-chain)

PlayerAgent.metropolis_token_idapp/models/player_agent.py:31

Currently nullable=True, always null. Intended to bind a player agent to a Metropolis NFT when MetropolisX integration lands:

  1. Contract address and ABI from Bhaven
  2. On agent creation: balanceOf(owner_wallet) → list token IDs → tokenURI(tokenId) → fetch metadata JSON → extract image and traits
  3. Store token_id as metropolis_token_id (String(80)); use metadata image as avatar_image_url; inject traits into personality generation

Auth seam — app/auth.py

Currently cookie-based. Cookie name: PLAYER_COOKIE (plain player UUID, no signing by default; SESSION_SECRET enables signing). Two entry points used by all route handlers:

  • require_player(request) — raises 401 (API) or redirects to /login (HTML) if unauthenticated
  • get_optional_player(request) — returns None for unauthenticated requests

To swap to JWT/SSO with the Metropolis platform: replace these two functions in app/auth.py. No other files need to change.


Tech stack reference

Package Min version Role
Python 3.11 Runtime
FastAPI 0.115 HTTP framework + dependency injection
SQLAlchemy 2.0 ORM + session factory
Pydantic v2 2.7 Schema validation; structured LLM output
Alembic 1.13 Schema migrations (Phase 3a+)
python-socketio 5.11 Real-time Socket.IO server
google-genai 0.3 Gemini API client (Soul + memory generation)
sentence-transformers 2.7 Memory embeddings (all-MiniLM-L6-v2, 384 dims)
chess 1.10 Board state, move legality, PGN parsing
maia2 0.1 Human-like engine (1100–1900 Elo)
Stockfish (Python) 3.28 High-skill engine + post-match analysis
argon2-cffi 23.1 Password hashing (Phase 4 email/password auth)
Redis 5.0 Mood persistence (optional; in-process fallback when absent)
NumPy 1.24 Cosine similarity for memory retrieval

LLM model: configured via gemini_model in app/config.py (current default: gemini-3.1-flash-lite-preview). Change there to upgrade.