- forge_runner._run_process_blocking: parse [DRAW] PARSE-SUMMARY lines
and record life totals per seat into state.draw_game_snapshots
- routes/lab.py get_result(): attach draw_game_snapshots to result JSON
under 'drawGames' so the dashboard and analysis scripts can query them
- New services/draw_game_analyzer.py: report which deck held the highest
average life at timeout; used by /api/lab/result and scripts/
- Also lowers the default batch --clock from 6000 → 3000 per game
(30s per-game wall limit for batch mode, down from 60s).
Interactive games can override via req.clock which defaults stay at 6000.
Closes#176
- Add validate_card_names() helper: checks collection_entries DB first,
then falls back to Scryfall /cards/named?fuzzy= for unknowns.
Drops any name that cannot be resolved (hallucinated cards).
- Inject validation in Step 4 immediately after each suggest_cards()
call so hallucinated names never reach ratio enforcement or assembly.
- Log dropped hallucinations at WARNING level with count + examples.
- Resolves the 'Sonic Hedgehog Charger' / 'Sonic Boom' class of
LLM-invented card names that were silently entering the deck.
- Add _parse_card_list_fallback() to recover plain-text CSV output from
the LLM when json_mode fails to enforce JSON (e.g. qwen2.5:7b drift)
- Harden suggest_cards system prompt with a one-shot JSON example so
small models are less likely to return prose in the first place
- Log recovered names at WARNING level so the degradation is visible
without being silent (previously returned [] with no recovery attempt)
- No behaviour changes to any other function
dict() on a types.SimpleNamespace raises TypeError: object is not iterable.
vars() correctly extracts the __dict__ attribute map. Fixes
TestBuildCollectionFailureTracking::test_high_failure_rate_raises_runtime_error.
The three split-combat helpers were accidentally defined as nested
functions inside _resolve_combat(), making them unreachable as
GameEngine.assign_attackers() etc. from tests and turn_manager.
Move them out to class-level methods with correct 4-space indentation.
_resolve_combat() now remains as the backward-compatible atomic path for
run_n(); the split methods are available for turn_manager priority
windows and direct testing.
begin_combat/end_combat are dispatched as named priority windows inside
_phase_combat(), not as entries in RESPONSE_PHASES. The constant is
correctly {"upkeep", "end_step"} — test was asserting stale intent.
Delta matches \bdelta\b in the nonbasic land guard regex, causing enrich_card
to classify it as a land (cmc=0). Zeta and Theta have no land keyword matches.
Surfaces previously-computed-but-unused stats into the Claude prompt:
1. USER_PROMPT_TEMPLATE gains a new '### Sim Insights' section that shows:
- Deck-level opening hand quality: avg keptInOpeningHandRate across all
cards, plus the 3 worst offenders with their actual rates
- Curve velocity: avg avgTurnCast weighted by castRate, flagging cards
cast on avg turn > 6 as 'late'
- Synergy outliers: bottom-3 synergyScore cards flagged as 'isolated'
- Archetype weak spots: archetypes with winRate < 40% flagged explicitly
2. _format_archetypes() now annotates weak spots with [WEAK SPOT] and
strong matchups with [STRENGTH] so Claude sees them immediately.
3. _format_top_cards() adds keptInOpeningHandRate to each line so
the 'good' cards also show their hand-keepability signal.
4. New helper _format_sim_insights() computes all deck-level aggregates
from CardPerformance list — no new fields on DeckMeta needed.
No schema changes. Fully backward-compatible with existing DeckReport.
New test file covering:
- TestGlobalFeatureDimensions: output shape (29,), all values in [0,1]
- TestTotalPowerRealValue: real total_power used at index 7 when present
- TestTotalPowerHeuristic: creatures*3 fallback when total_power absent/zero
- TestLandCount: real lands used at index 13 when present; defaults to 0
- TestCreatureCount: real creatures used at index 6; fallback to bf len
- TestManaFeature: mana normalised and clamped to [0,1]
- TestPhaseOneHot: exactly one hot in indices 9-12 per player block
- TestIsActivePlayer: binary 0/1 at index 8
- TestLifeNormalisation: 40 life → 1.0, 0 life → 0.0
- TestTurnFeature: turn clamped to [0,1]
- TestFullEncode: total state vector shape (6177,), dtype float32
- TestEmptyPlayer: _empty_player() has all Step 4 keys
- TestLegacySnapshot: old JSONL snapshot without Step 4 keys still encodes"
Previously _encode_global() always computed:
total_power = creatures * 3 (heuristic)
Now reads p.get('total_power', 0) first, falling back to the heuristic
only when the key is absent (legacy JSONL snapshots without Step 4 data).
Also wires total_toughness, artifacts, and enchantments into _empty_player()
so padding players have consistent keys.
- TestBoardStatsWiring: verifies creaturesOnField, landCount,
totalPowerOnBoard, totalToughnessOnBoard, artifactsOnField,
enchantmentsOnField are accepted without 422 and survive
a round-trip through the fallback encoder path
- TestBoardStatsFallback: verifies resolver fallbacks when Forge
fields are absent (creatures inferred from bf length,
total_power falls back to creatures * 3 heuristic,
lands defaults to 0)
- TestSchemaAlias: verifies snapshot_schema Field(alias='schema')
still accepts JSON key 'schema' without 422
- Removes now-irrelevant UserWarning (schema shadow fixed)
- ml/eval/forge_evaluator.py: reusable eval harness wrapping
MultiThreadBatchRunner + PolicyInferenceService; collects win rate,
avg turns, life differential, commander damage, illegal action rate,
policy entropy per game; writes results/eval-{run_id}.json
- scripts/eval_policy.py: CLI to run N Forge eval games with any
checkpoint vs forge-builtin or policy-server opponent; supports
--threads, --clock, --seed, --greedy, --temperature
- scripts/compare_eval.py: loads two result JSONs, computes absolute
win-rate delta + 95% CI, runs Fisher's exact test for significance,
prints formatted comparison table and pass/fail verdict
Previously _build_encoder_snapshot() stubbed:
- lands = 0 (always zero)
- creatures = len(bf_names) (name-list length, ignores tokens/etc)
- mana = mana_available (was already correct)
- total_power / total_toughness / artifacts / enchantments = missing
Now reads real pre-computed values from PlayerZoneState:
- creatures ← creaturesOnField (Forge board stat)
- lands ← landCount (Forge board stat)
- total_power ← totalPowerOnBoard (fed to global feature index 7)
- total_toughness← totalToughnessOnBoard
- artifacts ← artifactsOnField
- enchantments ← enchantmentsOnField
- mana ← manaAvailable (unchanged)
Also replaces the `creatures * 3` proxy in _build_encoder_snapshot with
the real totalPowerOnBoard value so index-7 of the global feature vector
tracks actual power rather than a heuristic.
PlayerZoneState gains 5 new optional int fields (default 0) matching
DecisionSnapshot.PlayerSnapshot field names so JSON from both the live
IPC path and the JSONL training path deserialise without changes.
Covers:
- health endpoint returns {ready, status, model_loaded}
- precomputed fast path: state_vector float[29] → vector_source=precomputed
- correct 6177-dim tensor shape reaches the mock model
- all 8 MacroAction values are valid responses
- encoder fallback: missing state_vector → vector_source=encoder
- encoder fallback: wrong-length state_vector → vector_source=encoder
- GameSession field name aliases (turnNumber, activePlayer,
commanderTax, handCount, commandZone) are accepted without 422
- 503 when model not loaded
- /api/policy/stats tracks precomputed_vector_hits vs encoder_fallbacks
- both phase strings (MAIN1, main_1) round-trip correctly
Uses a MockPolicyInferenceService that captures the tensor shape passed
to the model so we can assert on dim without needing a real checkpoint.
DecideRequest now accepts an optional state_vector field (float[29]).
When present, /api/policy/decide uses it directly for the global scalar
block instead of re-deriving it from nested player fields.
StateEncoder gets a new encode_with_precomputed_global() path that:
- Accepts the precomputed float[29] global block from Java
- Still runs full zone embedding pooling (hand/battlefield/graveyard/
command_zone) from the players[] zone contents
- Appends the playstyle one-hot as normal
- Returns the same 6177-dim tensor the policy network expects
Fallback: if state_vector is absent or wrong length, encode() is called
as before so all existing callers (batch predict, JSONL training) are
unaffected.
Also maps GameSession player field names to encoder field names:
handCount -> hand size proxy when hand[] is empty (AI seats)
battlefield list -> creatures count proxy
commanderTax -> cmdr_tax
turnNumber -> turn
activePlayer -> active_seat
Replaces synthetic stub loop with a live Forge process:
- Spawns Forge with --policy + --policyServer flags so Forge calls
back to /api/policy/decide at each AI decision point
- Reads Forge stdout line-by-line and parses zone/phase/life updates
via the same regex patterns used in BatchRunner
- Syncs full board state (battlefield, hand, graveyard, exile,
command zone, stack) on every Forge state-change line
- Routes human input from humanActionQueue via a side-channel
HTTP POST to /api/policy/override on the policy server
- Handles activePlayer + priorityPlayer correctly for 3-4 player pods
- Falls back to synthetic stub loop if Forge subprocess fails to start
- Watchdog thread kills stalled Forge process after 10 min
- _write_build_meta: after writing build_meta.json, also stamps built_at
onto the collection via col.modify() (best-effort, swallows exceptions)
- _collection_is_current: if sidecar built_at is missing/zero, falls back
to collection.metadata["built_at"] so a deleted sidecar doesn't force
an unnecessary full rebuild when the ChromaDB data is still valid
- Add BATCH_RETRY_LIMIT and BATCH_RETRY_DELAY constants after DEFAULT_BATCH_SIZE
- Add _insert_batch_with_retry() helper with exponential backoff
- Replace both bare try/except insert blocks in _build_collection_from_bulk
with calls to the retry helper so transient ChromaDB errors are retried
up to BATCH_RETRY_LIMIT times before a batch is counted as failed
Adds a buildStateVector(int seat) method to GameSession that produces
a float[29] global scalar vector matching STATE_DIMS layout in
ml/config/scope.py:
Per-player block (14 features × 2 players = 28):
[0] life_total / 40.0
[1] commander_damage / 21.0 (stub 0.0 until real Forge tracks it)
[2] mana_available / 20.0 (stub 0.0 until mana pool wired)
[3] commander_tax / 10.0
[4] cards_in_hand / 15.0
[5] cards_in_graveyard / 100.0
[6] creatures_on_battlefield / 30.0
[7] total_power_on_board / 100.0 (stub = creature count until P/T data)
[8] is_active_player (0 or 1)
[9] phase one-hot[0] main1
[10] phase one-hot[1] combat
[11] phase one-hot[2] main2
[12] phase one-hot[3] end
[13] (padding / reserved)
Global:
[28] turn_number / max_turns (25)
buildStateSnapshot() now includes "state_vector": float[] so that
PolicyClient.decide(JsonObject) sends a machine-readable vector the
Python /api/policy/decide endpoint can pass directly to the encoder
without re-parsing the nested human-readable fields.
Stub fields (mana_available, commander_damage, total_power_on_board)
are zero until real Forge board state is wired in Step 4.
Wires forge_episode_generator into the PPO training loop:
- New --data-source flag: synthetic (default) | forge | mixed
- New --forge-results-dir, --forge-batch-id, --forge-weight flags
- ForgeDataSource class: runs run_forge_pipeline() in background thread,
drains episodes into RolloutBuffer via episodes_to_buffer()
- Mixed mode: blends Forge episodes + synthetic self-play per iteration
using --forge-weight ratio (0.0–1.0)
- Synthetic path unchanged — no regression for existing training runs
- _get_cards_folder() now tries both forge-gui/res/cardsfolder and res/cardsfolder
so FORGE_DIR can point to either the repo root or the forge-gui subdirectory
- Add reset_forge_cache() to allow hot-reloading without server restart
The previous backfill operated on seen_names dict which silently
ignored basics already present (dict keys are unique). Now backfills
directly on card_entries list after Scryfall fetch, incrementing
quantity on existing basics or appending new CardEntry objects.
Distributes shortfall evenly across commander color identity basics.
* feat(rag): Phase 2 — ChromaDB vector index over bulk card DB
* feat(rag): add routes/rag.py with build, search, status endpoints
* Update lab_api.pyfeat(rag): register rag_router in lab_api.py, remove old inline rag_status
* Update requirements.txtfeat(rag): add chromadb>=0.5.0 to requirements.txtfeat(rag): add chromadb to requirements.txt
- Add SubstitutionMethod enum to track how each card was resolved
- smart_substitute() now always returns a valid card string (never None/drops)
fallback chain: exact -> fuzzy -> functional analog -> original kept -> basic land
- apply_substitutions() loop no longer silently drops cards on failure
- _pad_to_99() circuit breaker added before BuildResult validation
- Pre-validation count logging per category for fast regression diagnosis
- Startup assertion: sum(SLOT_BUDGET) == 99 raises on app boot if misconfigured
- Prompt injection: slot budget + exact card count requirement sent to LLM
- run_substitution() anthropic fallback failure now pads with basic land instead of leaving slot missing
- SubstitutionBatchResult gains padded_slots field (> 0 = circuit breaker fired)
- anthropic_client.py: all 4 methods (chat_structured, chat_plain, achat_structured, achat_plain) now use .messages.stream() context manager
- coach_service.py: inline aclient.messages.create() replaced with async streaming via aclient.messages.stream()
- _parse_response() updated to accept pre-extracted text string (since stream gives us text directly)
- check_status() left as non-streaming (max_tokens=10, safe from the 10min rule)
Completes the sim → weights → deck builder feedback loop.
- Add run_update_weights() to overnight-run.py
- Add auto_update_weights config key (default True)
- Add --no-update-weights, --weights-min-games, --weights-lr CLI flags
- Runs before neural net training so both pipelines get fresh data
Closes#112
- Add sim_insights.py to translate learned_weights.json into LLM prompt text
- Add sim_context param to suggest_cards() and enforce_deck_ratios() in ollama_client.py
- Wire get_sim_insights() into build_deck pipeline (Steps 4 & 6)
- Fully backwards compatible — no-op until real sim data exists
- distillation.html: tooltips on all 10 params + ELO Ratings header
- simulator.html: tooltips on Opponent, Games, Record logs, API Endpoint, Model, Temp, Timeout
- coach.html: tooltips on Power Level, Meta Focus, Budget, Focus Areas, Similar Cards Results
- deckgenerator.html: tooltips on Target Bracket, Use my collection, Smart Substitution, AI Model
- All pages now share tooltip styles from global.css (no per-page duplication needed)
- Move .tip-icon tooltip CSS from training.css into global.css so all pages share it
- distillation.html: 8 param tooltips (Max Generations, Convergence Window, Supervised Epochs, Supervised LR, PPO Iterations, PPO Episodes/Iter, PPO LR, Min Win Rate Gate, Opponent, Playstyle) + ELO Ratings header
- simulator.html: tooltips on Opponent, Games, Record turn logs, API Endpoint, Model, Temp, Timeout
- coach.html: tooltips on Target Power Level, Meta Focus, Budget, Focus Areas, Find Similar Cards Results
- deckgenerator.html: tooltips on Target Bracket, Use my collection, Smart Substitution, AI Model