metropolis-chess / docs /phase_4_evolution.md
Forkei's picture
Phase 4.0–4.3: demo prep — auth, Elo fairness, rooms, lobbies, conversational entry, evolution
7bad702

Phase 4.3 — Agent evolution

Characters evolve after each match. Same character for everyone (global drift), bounded per match, clamped to identity. Elo is already handled by Phase 4.0b; this phase handles the human-like learning layer on top.

Scope

Five channels, in priority order:

  1. Stylistic driftaggression, risk_tolerance, patience, trash_talk sliders shift a small amount based on match outcome + opponent quality.
  2. Opening-book evolution — per-opening score updated by a simple EMA of (opening_played · result_signal). Over time a character favours openings that won and drifts away from openings that lost.
  3. Trap memory — pattern-specific counters ("fell for scholar's mate vs. @alice"). Probability of falling again drops but never zeros — humans still slip.
  4. Tone drift — confidence/tilt baselines shift over streaks. Not a slider; a bias the Soul sees in its mood state.
  5. Hard clamps — a Viktor who loses 50 games in a row can't turn into a pacifist. Sliders cannot drift more than ±2 from base; opening-book weights cannot flip sign on their basis openings.

Invariants

  • Private matches skip the whole evolution step (per Phase 4.0b memory). Set once: if match.is_private: return.
  • Global, not per-player. One evolution state row per character. Per-player memory (who bullied whom) stays in OpponentProfile and character memories.
  • Per-match deltas are small. A single match can only move a slider by ≤ 1 step, a tone baseline by ≤ 0.05, an opening EMA by ≤ 0.1.
  • Identity clamps bound cumulative drift. Character's base sliders are the anchor. Cumulative drift is capped at ±2 slider steps from base. The character can change — slowly — but can't become unrecognisable.
  • Cadence: every match. Kept batch-friendly so we can flip to "every N matches" later without rewriting the pipeline.

Data model

character_evolution_state — one row per character, lazy-created on first post-match run.

column type notes
character_id (PK) String(36) FK one row per character
slider_drift JSON {aggression, risk_tolerance, patience, trash_talk} — signed deltas from base; bounded [-2, +2]
opening_scores JSON {"<opening_label>": float} — EMA-updated result signal in [-1, +1]
trap_memory JSON list of {"pattern": str, "fell_for": int, "avoided": int, "last_seen_at": iso}
tone_drift JSON {"confidence_baseline": float, "tilt_baseline": float} in [-0.3, +0.3]
matches_processed int counter
last_updated_at datetime
last_match_id str nullable for idempotency check — skip if we've already processed this match

Migration 0012_character_evolution_state.

Drift math

Let win_value: +1 on a win (character POV), -1 on loss, 0 on draw. Let opponent_strength: player_elo_at_start / character_elo_at_start (centered around 1.0). Strong opponents amplify drift; weak opponents attenuate it.

signal        = win_value * clamp(opponent_strength, 0.3, 2.0)

Slider drift. Each match picks at most ONE slider to nudge, selected by which one would have helped most given the outcome:

  • Lost, played cautiously (avg ACPL < 30) → nudge aggression +0.5.
  • Lost, played recklessly (avg ACPL > 80) → nudge patience +0.5.
  • Won, opponent higher-rated → nudge confidence in tone (not a slider).
  • On tilt (recent loss streak ≥ 3) → nudge trash_talk slightly up or down toward the character's base depending on direction-of-drift.
new_drift = clamp(old_drift + delta, -2.0, +2.0)

Cap: at most one slider updated per match. Delta magnitude ≤ 0.5.

Opening-book EMA. For each opening the character played in the match:

score'       = 0.9 * score_prev  +  0.1 * signal

Only applied to openings in character.opening_preferences or to openings whose score has been built up over ≥ 3 matches.

Trap memory. A "trap" is a sharp loss in ≤ 15 plies, or a loss to a named mating pattern detectable by the analysis step. Detection is cheap: reuse MatchAnalysis.critical_moments — the first critical moment in the first 12 plies with swing ≥ 400cp is tagged as a trap and fed into trap_memory.

if detected_trap(pattern):
    entry = trap_memory[pattern] or new(entry)
    if character_fell: entry.fell_for += 1
    else:              entry.avoided += 1
    entry.last_seen_at = now

A learning Memory row is generated the FIRST time a pattern is seen (narrative: "Last time an opponent tried X, I stepped right into it ..."). Subsequent hits only bump the counter — no new memory row per match (prevents the Subconscious from drowning in redundant copies).

Tone drift. EMA toward current streak:

confidence_baseline'  = 0.95 * confidence_prev + 0.05 * clamp(win_streak / 5, -0.3, +0.3)
tilt_baseline'        = 0.95 * tilt_prev       + 0.05 * clamp(-loss_streak / 5, -0.3, +0.3)

Applied every match. Fed to the Soul as an additive nudge on the initial MoodState before each turn.

Pipeline

Added as a new step after elo_ratchet, before memory_generation in app/post_match/processor.py. That way generated memories can reference newly-detected traps.

engine_analysis → feature_extraction → elo_ratchet
  → evolution                ← NEW
  → memory_generation → narrative_summary

Contract: apply_evolution(session, match, analysis) — raises on programmer errors, swallows LLM errors (shouldn't invoke LLM anyway — the hook is pure data). Idempotent via last_match_id guard.

Integration with Subconscious

  • Learned memories from trap memory use a new MemoryType.LEARNING scope. They enter the normal vector-retrieval flow; the Subconscious doesn't need changes.
  • Tone drift is read at Soul call time — _build_soul_input adds tone_drift to the mood parameter before passing to the Soul. No Soul-side changes needed.
  • Slider drift is read whenever the Director builds the engine config and whenever the style prompt is rendered. Effective slider = character.slider + drift.

Private-match exclusion

At entry: if match.is_private: return — no state read, no state write, no memory generation of learned entries. Logged at INFO so a demo can grep evolution.log to verify the guardrail is working.

Testing

  • Unit tests per math function (slider delta selection, opening EMA step, trap detection, tone EMA, clamps).
  • Integration test — simulate a 50-match burst against a fixed opponent profile and assert cumulative drift stays in bounds.
  • Private-match test — confirm apply_evolution is a no-op on match.is_private = True.
  • Idempotency test — re-running the pipeline on the same match doesn't double-apply drift.

What this phase does NOT do

  • Does not build a multi-player personality model (global only).
  • Does not feed PvP matches into evolution (only PvE — characters are PvE only; PvP is player-vs-player).
  • Does not build a UI for viewing a character's drift. Phase 4.5 polish pass adds a read-only "this character has learned…" panel on the detail page if there's time.