Compare commits

...
Author SHA1 Message Date
KoalaTrapLord 6c0aa77d2a feat: wire get_sim_insights into build_deck pipeline (Steps 4 & 6) 2026-03-27 02:24:57 -04:00
KoalaTrapLord 21341ad387 feat: inject sim_context into suggest_cards and enforce_deck_ratios prompts 2026-03-27 02:23:27 -04:00
KoalaTrapLord 0dbbd9786e feat: add sim_insights helper to translate learned weights into LLM prompt text 2026-03-27 02:22:11 -04:00
KoalaTrapLord 817b71f69d feat(ui): add tip-icon tooltips to distillation, simulator, coach, deckgenerator pages
- 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)
2026-03-27 02:06:58 -04:00
KoalaTrapLord e71cac9dd1 feat(ui): add tooltips to all pages — distillation, simulator, coach, deckgenerator
- 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
2026-03-27 01:57:37 -04:00
8 changed files with 391 additions and 90 deletions

No files matched your search

@@ -102,7 +102,7 @@ def chat(
return response.choices[0].message.content or ""
# ── 1. Analyze synergies ─────────────────────────────────────────
# ── 1. Analyze synergies ─────────────────────────────────────────────
def analyze_synergies(
commander_name: str,
@@ -145,7 +145,7 @@ Cards to evaluate:
return []
# ── 2. Suggest cards for a category ──────────────────────────────
# ── 2. Suggest cards for a category ────────────────────────────────
def suggest_cards(
commander_name: str,
@@ -154,13 +154,23 @@ def suggest_cards(
count: int = 10,
exclude: Optional[List[str]] = None,
strategy_notes: Optional[str] = None,
sim_context: str = "",
) -> List[str]:
"""
Ask the model to suggest cards for a specific deck category.
Parameters
----------
sim_context:
Optional natural-language insight string from get_sim_insights().
When non-empty, appended to the prompt so the LLM can factor in
statistically successful card traits from simulation data.
Returns a list of card names.
"""
exclude_str = f"\nDo NOT suggest these cards: {json.dumps(exclude)}" if exclude else ""
strategy = f"\nStrategy notes: {strategy_notes}" if strategy_notes else ""
sim_block = f"\n\n{sim_context}" if sim_context else ""
prompt = f"""You are a Magic: The Gathering Commander deck-building expert.
@@ -168,7 +178,7 @@ Commander: {commander_name}
Color identity: {', '.join(color_identity)}
Category: {category}
{strategy}
{exclude_str}
{exclude_str}{sim_block}
Suggest exactly {count} cards for the {category} category that work well with this commander.
Only suggest cards that are legal in Commander and match the color identity.
@@ -200,7 +210,7 @@ Return ONLY a JSON array of card name strings, like: ["Card A", "Card B", ...]""
return []
# ── 3. Filter by color identity ──────────────────────────────────
# ── 3. Filter by color identity ────────────────────────────────────
def filter_color_identity(
card_names: List[str],
@@ -242,17 +252,28 @@ Cards:
return card_names
# ── 4. Enforce deck ratios ───────────────────────────────────────
# ── 4. Enforce deck ratios ───────────────────────────────────────────
def enforce_deck_ratios(
cards_by_category: Dict[str, List[str]],
target_ratios: Dict[str, int],
commander_name: str,
sim_context: str = "",
) -> Dict[str, List[str]]:
"""
Ask the model to trim/expand categories to hit target counts.
Parameters
----------
sim_context:
Optional natural-language insight string from get_sim_insights().
When non-empty, appended to the prompt so the LLM preferentially
retains cards matching statistically high-value traits when trimming.
Returns adjusted dict of category -> card names.
"""
sim_block = f"\n\n{sim_context}" if sim_context else ""
prompt = f"""You are a Magic: The Gathering Commander deck-building expert.
Commander: {commander_name}
@@ -265,7 +286,7 @@ Target card counts per category:
Adjust each category to match the target count. Remove the weakest cards from
over-filled categories. For under-filled categories, suggest additional cards.
The total across all categories must be exactly 99.
The total across all categories must be exactly 99.{sim_block}
Return a JSON object with the same category keys, each mapping to an array of card name strings."""
@@ -284,7 +305,7 @@ Return a JSON object with the same category keys, each mapping to an array of ca
return cards_by_category
# ── 5. Assemble final deck JSON ──────────────────────────────────
# ── 5. Assemble final deck JSON ─────────────────────────────────────────
def assemble_deck_json(
commander_name: str,
@@ -0,0 +1,124 @@
"""
sim_insights.py
===============
Translates learned simulation weights (learned_weights.json) into concise
natural-language insight strings that can be injected into Ollama prompts.
This bridges the gap between the Monte Carlo engine's outcome-weighted
reinforcement learning and the LLM-driven deck builder: keywords that
statistically correlate with wins are surfaced to Ollama so it can
prioritize those card traits when suggesting and trimming cards.
Usage::
from commander_ai_lab.deck_builder.api.sim_insights import get_sim_insights
context = get_sim_insights() # returns "" if no meaningful learning yet
if context:
prompt += f"\n\n{context}"
The function is intentionally safe to call when learned_weights.json does
not yet exist (load_weights() falls back to AI_DEFAULT_WEIGHTS silently),
and returns an empty string when no weight has drifted far enough from the
default to be worth mentioning — so existing prompt behaviour is unchanged
until there is real simulation data.
"""
from __future__ import annotations
import logging
from typing import Optional
from commander_ai_lab.sim.rules import AI_DEFAULT_WEIGHTS, load_weights
logger = logging.getLogger(__name__)
# Maps weight keys → human-readable MTG concepts shown to the LLM.
# Only keys the LLM can meaningfully act on are included.
_KEY_TO_CONCEPT: dict[str, str] = {
"kw_flying": "flying (evasion)",
"kw_haste": "haste",
"kw_deathtouch": "deathtouch",
"kw_indestructible": "indestructible",
"kw_doubleStrike": "double strike",
"kw_hexproof": "hexproof",
"kw_trample": "trample",
"kw_lifelink": "lifelink",
"kw_menace": "menace",
"kw_ward": "ward",
"kw_annihilator": "annihilator",
"kw_cascade": "cascade",
"spell_ramp": "mana ramp spells",
"spell_destroy": "targeted removal",
"spell_draw": "card draw",
"spell_counter": "counterspells and board control",
"trig_etb": "enters-the-battlefield triggers",
"trig_dies": "death triggers",
"trig_attack": "attack triggers",
"card_ptBonus": "high power/toughness creatures",
}
def get_sim_insights(
top_n: int = 6,
threshold_delta: float = 0.05,
weights_path: Optional[str] = None,
) -> str:
"""
Return a concise insight paragraph for injecting into LLM prompts.
Parameters
----------
top_n:
Maximum number of concepts to mention (avoids bloating the prompt).
threshold_delta:
Minimum absolute drift from the default weight before a concept
is considered meaningful enough to report. 0.05 means a weight
must have moved by at least 5% of its default before it matters.
weights_path:
Optional explicit path to learned_weights.json. If None, the
default location (``src/.../sim/learned_weights.json``) is used.
Returns
-------
str
A multi-line insight string ready to append to an LLM prompt, or
an empty string if no meaningful learning is available yet.
"""
try:
learned = load_weights(weights_path)
except Exception as exc:
logger.warning("sim_insights: could not load weights: %s", exc)
return ""
deltas: dict[str, float] = {}
for key, concept in _KEY_TO_CONCEPT.items():
default_val = AI_DEFAULT_WEIGHTS.get(key, 0.0)
learned_val = learned.get(key, default_val)
delta = learned_val - default_val
if abs(delta) >= threshold_delta:
deltas[concept] = delta
if not deltas:
# No meaningful drift yet — don't inject noise into prompts
return ""
# Sort by magnitude, take top_n
sorted_items = sorted(deltas.items(), key=lambda x: -abs(x[1]))[:top_n]
boosted = [c for c, d in sorted_items if d > 0]
penalized = [c for c, d in sorted_items if d < 0]
lines = [
"[Simulation Insights] Data from thousands of Commander games in this lab suggests:",
]
if boosted:
lines.append(f" - PRIORITIZE cards with: {', '.join(boosted)}")
if penalized:
lines.append(f" - DEPRIORITIZE cards with: {', '.join(penalized)}")
lines.append(
"Use this to guide your selections where multiple options are otherwise comparable."
)
result = "\n".join(lines)
logger.debug("sim_insights: injecting %d concept(s) into prompt", len(sorted_items))
return result
@@ -13,6 +13,7 @@ from typing import Dict, List, Optional
from ..api import edhrec, scryfall
from ..api import ollama_client as ollama
from ..api.sim_insights import get_sim_insights
from ..core.collection_filter import filter_names_by_collection
from ..core.models import BuildRequest, BuildResult, CardEntry, CommanderDeck, DeckRatios
from ..core.rules_engine import check_ban_list, filter_by_color_identity, validate_deck
@@ -37,7 +38,7 @@ def build_deck(request: BuildRequest) -> BuildResult:
warnings: List[str] = []
sources: List[str] = []
# ── Step 1: Resolve commander ────────────────────────────────
# ── Step 1: Resolve commander ────────────────────────────────────────────
logger.info(f"Step 1: Resolving commander '{request.commander_name}'")
commander = scryfall.search_commander(request.commander_name)
if not commander:
@@ -50,7 +51,17 @@ def build_deck(request: BuildRequest) -> BuildResult:
logger.info(f"Commander: {commander.name}, CI: {ci_list}")
sources.append("scryfall")
# ── Step 2: Fetch EDHrec recommendations ─────────────────────
# Load simulation insights once; injected into Ollama prompts in Steps 4 & 6.
# Returns "" when learned_weights.json doesn't exist or has no meaningful drift
# yet — so behaviour is identical to before until real sim data is available.
sim_context = get_sim_insights()
if sim_context:
logger.info("Sim insights available — injecting into Ollama prompts")
sources.append("sim_weights")
else:
logger.debug("No sim insights available (run update_weights.py after overnight sims)")
# ── Step 2: Fetch EDHrec recommendations ─────────────────────────────
logger.info(f"Step 2: Fetching EDHrec data for '{commander.name}'")
edhrec_cards = edhrec.get_recommended_cards(commander.name)
if edhrec_cards:
@@ -59,7 +70,7 @@ def build_deck(request: BuildRequest) -> BuildResult:
else:
warnings.append("EDHrec returned no data — relying on Scryfall + Ollama")
# ── Step 3: Fetch Scryfall candidates by category ────────────
# ── Step 3: Fetch Scryfall candidates by category ────────────────────────
logger.info("Step 3: Fetching Scryfall candidates by category")
scryfall_candidates: Dict[str, List[CardEntry]] = {
"ramp": scryfall.search_ramp(commander_ci),
@@ -70,7 +81,7 @@ def build_deck(request: BuildRequest) -> BuildResult:
for cat, cards in scryfall_candidates.items():
logger.info(f" Scryfall {cat}: {len(cards)} candidates")
# ── Step 4: Ollama suggestions to fill gaps ──────────────────
# ── Step 4: Ollama suggestions to fill gaps ────────────────────────────
logger.info("Step 4: Ollama suggesting additional cards")
# Collect all names we already have
@@ -79,7 +90,9 @@ def build_deck(request: BuildRequest) -> BuildResult:
all_names.extend(c.name for c in cards)
all_names = list(set(all_names))
# Ask Ollama to suggest cards for categories that need more
# Ask Ollama to suggest cards for categories that need more.
# sim_context is forwarded so the LLM factors in what traits have
# been statistically successful in our own game simulations.
categories_to_fill = ["synergy", "protection", "wincon"]
ollama_suggestions: Dict[str, List[str]] = {}
@@ -91,6 +104,7 @@ def build_deck(request: BuildRequest) -> BuildResult:
count=15,
exclude=all_names,
strategy_notes=request.strategy_notes,
sim_context=sim_context,
)
ollama_suggestions[category] = suggested
all_names.extend(suggested)
@@ -98,7 +112,7 @@ def build_deck(request: BuildRequest) -> BuildResult:
sources.append("ollama")
# ── Step 5: Filter (color identity, collection, ban list) ────
# ── Step 5: Filter (color identity, collection, ban list) ────────────────
logger.info("Step 5: Filtering candidates")
# Build a unified name pool per category
@@ -140,7 +154,7 @@ def build_deck(request: BuildRequest) -> BuildResult:
n for n in cards_by_category[cat] if n not in BANNED_CARDS
]
# ── Step 6: Ollama enforce ratios ────────────────────────────
# ── Step 6: Ollama enforce ratios ────────────────────────────────────
logger.info("Step 6: Ollama enforcing deck ratios")
target_ratios = {
"lands": request.ratios.lands,
@@ -153,10 +167,13 @@ def build_deck(request: BuildRequest) -> BuildResult:
"uncategorized": request.ratios.uncategorized,
}
# sim_context is forwarded so Ollama preferentially retains cards
# matching high-value sim traits when trimming over-filled categories.
adjusted = ollama.enforce_deck_ratios(
cards_by_category=cards_by_category,
target_ratios=target_ratios,
commander_name=commander.name,
sim_context=sim_context,
)
# Verify total is 99
@@ -165,7 +182,7 @@ def build_deck(request: BuildRequest) -> BuildResult:
warnings.append(f"Ollama returned {total} cards instead of 99 — adjusting")
logger.warning(f"Ratio enforcement returned {total} cards")
# ── Step 7: Assemble final deck ──────────────────────────────
# ── Step 7: Assemble final deck ────────────────────────────────────────
logger.info("Step 7: Assembling final deck")
# Build a globally-deduplicated name list. enforce_deck_ratios can
+23 -8
View File
@@ -58,7 +58,10 @@
<!-- Power Level Slider -->
<div class="form-group">
<label class="form-label" for="coach-power-level">Target Power Level</label>
<label class="form-label" for="coach-power-level">
Target Power Level
<span class="tip-icon tip-below" data-tip="Guides the LLM on how competitive suggestions should be. 1-3 = casual jank; 4-6 = upgraded precon; 7-8 = high power; 9-10 = cEDH with fast mana and infinite combos.">?</span>
</label>
<div class="coach-slider-row">
<input type="range" id="coach-power-level" min="1" max="10" value="7" />
<span class="coach-slider-value" id="coach-power-value">7</span>
@@ -68,7 +71,10 @@
<!-- Meta Focus -->
<div class="form-group">
<label class="form-label" for="coach-meta-focus">Meta Focus</label>
<label class="form-label" for="coach-meta-focus">
Meta Focus
<span class="tip-icon tip-below" data-tip="The game plan the coach optimizes for. Aggro = fast damage; Control = answers and card advantage; Combo = tutors and win-con protection; Stax = slow everyone down.">?</span>
</label>
<select id="coach-meta-focus" class="form-select">
<option value="">Any / Balanced</option>
<option value="aggro">Aggro</option>
@@ -81,7 +87,10 @@
<!-- Budget -->
<div class="form-group">
<label class="form-label" for="coach-budget">Budget</label>
<label class="form-label" for="coach-budget">
Budget
<span class="tip-icon tip-below" data-tip="Restricts suggestions to cards within a price range. Budget caps cards at ~$2 each; Medium at ~$10. No Limit allows any card including Reserved List staples.">?</span>
</label>
<select id="coach-budget" class="form-select">
<option value="">No Limit</option>
<option value="budget">Budget (&lt; $2 per card)</option>
@@ -92,7 +101,10 @@
<!-- Focus Areas -->
<div class="form-group coach-field-wide">
<label class="form-label">Focus Areas (optional)</label>
<label class="form-label">
Focus Areas (optional)
<span class="tip-icon tip-below" data-tip="Narrows the coach to specific deck categories. Leaving all unchecked triggers a full-deck general analysis across all categories.">?</span>
</label>
<div class="coach-focus-tags">
<label class="coach-tag"><input type="checkbox" value="ramp" /> Ramp</label>
<label class="coach-tag"><input type="checkbox" value="card draw" /> Card Draw</label>
@@ -121,7 +133,7 @@
</div>
</section>
<!-- ═══ Multi-turn Chat Panel ═══ -->
<!-- Multi-turn Chat Panel -->
<section class="panel coach-chat-section" id="coach-chat-section">
<div class="panel-header coach-chat-header">
<h2>Chat with Coach</h2>
@@ -143,7 +155,7 @@
</div>
</section>
<!-- ═══ Cards-Like Search Panel ═══ -->
<!-- Cards-Like Search Panel -->
<section class="panel coach-cardslike-section" id="coach-cardslike-section">
<div class="panel-header coach-panel-collapsible" id="cardslike-header">
<h2>Find Similar Cards</h2>
@@ -163,7 +175,10 @@
<button class="coach-color-pip coach-pip-G" data-color="G" title="Green">G</button>
</div>
<div class="form-group coach-cardslike-topn">
<label class="form-label">Results</label>
<label class="form-label">
Results
<span class="tip-icon tip-below" data-tip="How many similar cards to return. Uses vector embeddings to find cards with the most semantically similar oracle text and mechanics to your query card.">?</span>
</label>
<input type="number" id="cardslike-topn" class="form-input" value="10" min="1" max="50" />
</div>
<button id="cardslike-search-btn" class="btn btn-accent btn-sm">Search</button>
@@ -173,7 +188,7 @@
</div>
</section>
<!-- ═══ One-Shot Results Section ═══ -->
<!-- One-Shot Results Section -->
<section id="coach-results" class="coach-results" style="display:none;">
<!-- Apply Suggestions Bar -->
+33 -49
View File
@@ -62,9 +62,13 @@
class="dg-input"
id="dg-strategy"
placeholder="e.g. zombie tokens, voltron, spellslinger..."
title="Describe your deck strategy — the AI uses this to pick thematically aligned cards"
/>
<div class="dg-bracket-row">
<label class="dg-label">Target Bracket</label>
<label class="dg-label">
Target Bracket
<span class="tip-icon" data-tip="B1 = precon-level casual. B2 = upgraded with synergies. B3 = high power with strong combos. B4 = cEDH with maximum optimization, fast mana, and instant-speed interaction.">?</span>
</label>
<div class="dg-bracket-btns" id="dg-bracket-btns">
<button class="dg-bracket-btn" data-bracket="1" title="Casual — precon level">B1</button>
<button class="dg-bracket-btn" data-bracket="2" title="Upgraded Casual">B2</button>
@@ -105,7 +109,7 @@
min="0"
step="25"
/>
<select class="dg-select" id="dg-budget-mode">
<select class="dg-select" id="dg-budget-mode" title="Total Deck: caps the combined price of all 100 cards. Per Card Max: caps each individual card price.">
<option value="total">Total Deck $</option>
<option value="per_card">Per Card Max $</option>
</select>
@@ -120,25 +124,34 @@
</div>
<div class="dg-panel-body">
<label class="dg-toggle-row">
<span class="dg-toggle-label">Use my collection</span>
<span class="dg-toggle-label">
Use my collection
<span class="tip-icon" data-tip="Prioritizes cards you already own before pulling from the full card pool. Saves money and ensures the deck is ready to play. Requires an imported collection.">?</span>
</span>
<input type="checkbox" class="dg-toggle-check" id="dg-use-collection" checked />
<span class="dg-toggle-track"><span class="dg-toggle-thumb"></span></span>
</label>
<label class="dg-toggle-row">
<span class="dg-toggle-label">Smart Substitution</span>
<span class="dg-toggle-label">
Smart Substitution
<span class="tip-icon" data-tip="When a suggested card isn't in your collection, automatically finds the closest functional replacement you do own. Keeps the deck playable without buying extra cards.">?</span>
</span>
<input type="checkbox" class="dg-toggle-check" id="dg-substitution" checked />
<span class="dg-toggle-track"><span class="dg-toggle-thumb"></span></span>
</label>
<div class="dg-model-row">
<label class="dg-label">AI Model</label>
<label class="dg-label">
AI Model
<span class="tip-icon" data-tip="The local LLM used to generate the deck list. Larger models produce better card selections but are slower. Requires Ollama running locally with the selected model pulled.">?</span>
</label>
<select class="dg-select" id="dg-model">
<option value="gpt-oss:20b" selected>gpt-oss:20b (Local Ollama)</option>
<option value="gpt-oss:20b" selected>gpt-oss:20b (Local Ollama)</option>
</select>
</div>
</div>
</div>
<!-- Omit Cards (chips input) -->
<!-- Omit Cards (chips input) -->
<div class="dg-panel">
<div class="dg-panel-header">
<span class="dg-panel-title">Omit Cards</span>
@@ -160,12 +173,12 @@
<!-- Action Buttons -->
<div class="dg-actions">
<button class="dg-btn dg-btn-preview" id="dg-preview-btn" disabled>
<span class="dg-btn-icon">⚡</span> Generate Deck
<span class="dg-btn-icon">&#9889;</span> Generate Deck
</button>
</div>
<!-- Conversation Log -->
<div id="dg-conversation-log" class="dg-conversation-log">
<!-- Conversation Log -->
<div id="dg-conversation-log" class="dg-conversation-log">
<div class="dg-log-entry dg-log-info"><span class="dg-log-ts">[Ready]</span> Waiting for deck generation...</div>
</div>
@@ -175,7 +188,7 @@
<section class="dg-col-results">
<!-- Empty State -->
<div class="dg-empty-state" id="dg-empty-state">
<div class="dg-empty-icon">⚡</div>
<div class="dg-empty-icon">&#9889;</div>
<div class="dg-empty-title">V3 AI Deck Generator</div>
<div class="dg-empty-text">Select a commander, set your bracket and strategy, then click "Generate Deck" to build a complete 100-card deck using Claude Opus with structured output.</div>
</div>
@@ -185,47 +198,18 @@
<div class="dg-spinner"></div>
<div class="dg-loading-text" id="dg-loading-text">Generating deck via Local AI...</div>
</div>
<div class="dg-progress-wrap" id="dg-progress-wrap" style="display:none">
<div class="dg-progress-bar">
<div class="dg-progress-fill" id="dg-progress-fill"></div>
</div>
<div class="dg-progress-meta">
<span class="dg-progress-phase" id="dg-progress-phase"></span>
<span class="dg-progress-elapsed" id="dg-progress-elapsed">0s elapsed</span>
</div>
<div class="dg-progress-wrap" id="dg-progress-wrap" style="display:none">
<div class="dg-progress-bar">
<div class="dg-progress-fill" id="dg-progress-fill"></div>
</div>
<!-- Results -->
<div class="dg-results" id="dg-results" style="display:none">
<!-- Strategy & Bracket Summary -->
<div class="dg-strategy-bar" id="dg-strategy-bar"></div>
<!-- Stats Bar -->
<div class="dg-stats-bar" id="dg-stats-bar"></div>
<!-- Substitution Summary -->
<div class="dg-sub-summary" id="dg-sub-summary" style="display:none"></div>
<!-- Card Groups -->
<div class="dg-card-groups" id="dg-card-groups"></div>
<!-- Export & Save Bar -->
<div class="dg-save-bar" id="dg-save-bar">
<button class="dg-btn dg-btn-save" id="dg-save-btn">
<span class="dg-btn-icon">💾</span> Save to Deck Builder
</button>
<button class="dg-btn dg-btn-ghost" id="dg-regenerate-btn">
<span class="dg-btn-icon">🔄</span> Regenerate
</button>
<div class="dg-export-group">
<button class="dg-btn dg-btn-export" id="dg-export-csv">📊 CSV</button>
<button class="dg-btn dg-btn-export" id="dg-export-dck">🎮 Forge .dck</button>
<button class="dg-btn dg-btn-export" id="dg-export-moxfield">📋 Moxfield</button>
<button class="dg-btn dg-btn-export" id="dg-export-shopping">🛒 Shopping List</button>
</div>
<div class="dg-progress-meta">
<span class="dg-progress-phase" id="dg-progress-phase"></span>
<span class="dg-progress-elapsed" id="dg-progress-elapsed">0s elapsed</span>
</div>
</div>
<!-- Results placeholder — rest generated dynamically by deckgenerator.js -->
<div class="dg-results" id="dg-results" style="display:none"></div>
</section>
</main>
+44 -12
View File
@@ -16,7 +16,7 @@
<div class="page-header" style="display:flex;align-items:center;justify-content:space-between;">
<div>
<h1>Closed-Loop Distillation</h1>
<p>Iterative supervised → PPO → quality gate → merge pipeline</p>
<p>Iterative supervised &rarr; PPO &rarr; quality gate &rarr; merge pipeline</p>
</div>
<button id="btn-refresh" class="btn btn-secondary" onclick="refreshAll()" title="Refresh status">
<span class="btn-icon" id="refresh-icon">&#8635;</span> Refresh
@@ -59,43 +59,70 @@
<div class="train-controls">
<div class="param-grid form-row">
<div class="form-group">
<label class="form-label" for="param-iterations">Max Generations</label>
<label class="form-label" for="param-iterations">
Max Generations
<span class="tip-icon" data-tip="How many distillation cycles to run. Each cycle: supervised train &rarr; PPO fine-tune &rarr; quality gate. Stops early if convergence is detected.">?</span>
</label>
<input type="number" id="param-iterations" class="form-input" value="10" min="1" max="100" />
</div>
<div class="form-group">
<label class="form-label" for="param-conv-window">Convergence Window</label>
<label class="form-label" for="param-conv-window">
Convergence Window
<span class="tip-icon" data-tip="Number of recent generations to check for plateau. If ELO gain stays below threshold across this many gens, training stops early to save time.">?</span>
</label>
<input type="number" id="param-conv-window" class="form-input" value="3" min="2" max="10" />
</div>
<div class="form-group">
<label class="form-label" for="param-sup-epochs">Supervised Epochs</label>
<label class="form-label" for="param-sup-epochs">
Supervised Epochs
<span class="tip-icon" data-tip="Training epochs for the supervised step each generation. Fewer is fine since each cycle trains on incrementally better data. 30 is a solid starting point.">?</span>
</label>
<input type="number" id="param-sup-epochs" class="form-input" value="30" min="5" max="200" />
</div>
<div class="form-group">
<label class="form-label" for="param-sup-lr">Supervised LR</label>
<label class="form-label" for="param-sup-lr">
Supervised LR
<span class="tip-icon" data-tip="Learning rate for the supervised phase. 0.001 is a solid default. Lower it in later generations if the model is already strong.">?</span>
</label>
<input type="number" id="param-sup-lr" class="form-input" value="0.001" step="0.0001" min="0.00001" max="0.1" />
</div>
</div>
<div class="param-grid form-row">
<div class="form-group">
<label class="form-label" for="param-ppo-iters">PPO Iterations</label>
<label class="form-label" for="param-ppo-iters">
PPO Iterations
<span class="tip-icon" data-tip="Number of PPO update steps per generation. Each step samples game episodes, computes advantages, and updates policy weights.">?</span>
</label>
<input type="number" id="param-ppo-iters" class="form-input" value="50" min="10" max="500" />
</div>
<div class="form-group">
<label class="form-label" for="param-ppo-episodes">PPO Episodes/Iter</label>
<label class="form-label" for="param-ppo-episodes">
PPO Episodes/Iter
<span class="tip-icon" data-tip="Game simulations per PPO update step. More episodes = more stable gradients but slower iterations. 64 balances speed and stability well.">?</span>
</label>
<input type="number" id="param-ppo-episodes" class="form-input" value="64" min="8" max="512" step="8" />
</div>
<div class="form-group">
<label class="form-label" for="param-ppo-lr">PPO Learning Rate</label>
<label class="form-label" for="param-ppo-lr">
PPO Learning Rate
<span class="tip-icon" data-tip="PPO step size. Keep this lower than the supervised LR. 0.0003 is the standard RL default and works well here.">?</span>
</label>
<input type="number" id="param-ppo-lr" class="form-input" value="0.0003" step="0.0001" min="0.00001" max="0.01" />
</div>
<div class="form-group">
<label class="form-label" for="param-min-wr">Min Win Rate Gate</label>
<label class="form-label" for="param-min-wr">
Min Win Rate Gate
<span class="tip-icon" data-tip="Quality gate threshold. A generation must beat the heuristic opponent at this win rate to be accepted and merged into the next cycle. 0.30 is a lenient starting gate.">?</span>
</label>
<input type="number" id="param-min-wr" class="form-input" value="0.30" step="0.05" min="0.1" max="0.9" />
</div>
</div>
<div class="param-grid form-row distill-bottom-params">
<div class="form-group">
<label class="form-label" for="param-opponent">Opponent</label>
<label class="form-label" for="param-opponent">
Opponent
<span class="tip-icon" data-tip="Who the agent trains against during PPO. Heuristic is recommended for a consistent challenge. Self-play can become unstable without a strong supervised baseline first.">?</span>
</label>
<select id="param-opponent" class="form-select">
<option value="heuristic">Heuristic</option>
<option value="random">Random</option>
@@ -103,7 +130,10 @@
</select>
</div>
<div class="form-group">
<label class="form-label" for="param-playstyle">Playstyle</label>
<label class="form-label" for="param-playstyle">
Playstyle
<span class="tip-icon" data-tip="Deck archetype used for simulations in this distillation run. Midrange is the most balanced test bed. Match this to the archetype you want the model to master.">?</span>
</label>
<select id="param-playstyle" class="form-select">
<option value="midrange">Midrange</option>
<option value="aggro">Aggro</option>
@@ -164,7 +194,9 @@
<!-- ELO Rating Chart -->
<section class="panel" id="elo-panel">
<div class="panel-header elo-panel-header">
<h2>ELO Ratings</h2>
<h2>ELO Ratings
<span class="tip-icon tip-below" data-tip="ELO scores measure relative strength. Baseline is 1200. Scores above that mean the model wins more than it loses. Tournament runs the top 10 most recent checkpoints head-to-head.">?</span>
</h2>
<button id="btn-run-elo" class="btn btn-sm btn-secondary" onclick="runEloTournament()">
<span class="btn-icon">&#127942;</span> Run Tournament
</button>
+89
View File
@@ -864,3 +864,92 @@ body {
.form-input:focus, .form-select:focus, .form-textarea:focus {
box-shadow: 0 0 0 3px var(--accent-dim), inset 0 1px 3px rgba(0,0,0,0.1);
}
/* ══════════════════════════════════════════════════════════════
Tooltip System (.tip-icon + data-tip)
Shared across all pages — do not duplicate in page-specific CSS
══════════════════════════════════════════════════════════════ */
.tip-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 15px;
height: 15px;
border-radius: 50%;
background: var(--surface-3);
color: var(--text-secondary);
font-size: 10px;
font-weight: 700;
font-style: normal;
cursor: default;
position: relative;
vertical-align: middle;
margin-left: 4px;
flex-shrink: 0;
transition: background 0.15s, color 0.15s;
user-select: none;
}
.tip-icon:hover {
background: var(--accent-dim);
color: var(--accent);
}
.tip-icon::after {
content: attr(data-tip);
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
background: var(--surface-0, #1a1a2e);
color: var(--text, #e0e0e0);
border: 1px solid var(--border, #333);
border-radius: var(--radius-md, 6px);
padding: 8px 12px;
font-size: 12px;
font-weight: 400;
line-height: 1.5;
white-space: normal;
width: 240px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
pointer-events: none;
opacity: 0;
transition: opacity 0.15s ease;
z-index: 1000;
text-align: left;
text-transform: none;
letter-spacing: 0;
}
.tip-icon::before {
content: '';
position: absolute;
bottom: calc(100% + 3px);
left: 50%;
transform: translateX(-50%);
border: 5px solid transparent;
border-top-color: var(--border, #333);
pointer-events: none;
opacity: 0;
transition: opacity 0.15s ease;
z-index: 1001;
}
.tip-icon:hover::after,
.tip-icon:hover::before {
opacity: 1;
}
.tip-icon.tip-below::after {
bottom: auto;
top: calc(100% + 8px);
}
.tip-icon.tip-below::before {
bottom: auto;
top: calc(100% + 3px);
border-top-color: transparent;
border-bottom-color: var(--border, #333);
}
+25 -6
View File
@@ -28,20 +28,27 @@
</select>
</div>
<div class="form-group sim-field-auto">
<label class="form-label" for="sim-opponent-select">Opponent</label>
<label class="form-label" for="sim-opponent-select">
Opponent
<span class="tip-icon tip-below" data-tip="Heuristic AI uses rule-based logic — fast and deterministic. DeepSeek AI routes decisions through your local Ollama LLM for smarter but slower play.">?</span>
</label>
<select id="sim-opponent-select" class="form-select">
<option value="heuristic">Heuristic AI</option>
<option value="deepseek">DeepSeek AI</option>
</select>
</div>
<div class="form-group sim-field-auto">
<label class="form-label" for="sim-num-games">Games</label>
<label class="form-label" for="sim-num-games">
Games
<span class="tip-icon tip-below" data-tip="Number of games to simulate. 10 is quick for a gut-check; 100+ gives statistically meaningful win rates. Each game is fully simulated end-to-end.">?</span>
</label>
<input type="number" id="sim-num-games" class="form-input sim-num-input" value="10" min="1" max="1000" />
</div>
<div class="form-group sim-field-auto sim-field-end">
<label class="form-checkbox">
<input type="checkbox" id="sim-record-logs" checked />
Record turn logs
<span class="tip-icon tip-below" data-tip="Saves per-turn decision snapshots to .jsonl files. Required for ML training data collection. Slightly slower — disable if you only need win rate stats.">?</span>
</label>
</div>
<div class="form-group sim-field-auto sim-field-end">
@@ -61,19 +68,31 @@
</div>
<div class="deepseek-config-row">
<div class="form-group sim-field-grow">
<label class="form-label" for="ds-api-base">API Endpoint</label>
<label class="form-label" for="ds-api-base">
API Endpoint
<span class="tip-icon tip-below" data-tip="Base URL of your Ollama server. Default is localhost:11434. Change this if Ollama is running on a different machine or WSL2 network address.">?</span>
</label>
<input type="text" id="ds-api-base" class="form-input" value="http://localhost:11434" placeholder="http://localhost:11434" />
</div>
<div class="form-group sim-field-auto">
<label class="form-label" for="ds-model">Model</label>
<label class="form-label" for="ds-model">
Model
<span class="tip-icon tip-below" data-tip="Ollama model tag to use for AI decisions. Leave blank to auto-detect the best available model. Examples: deepseek-r1:7b, llama3:8b, mistral.">?</span>
</label>
<input type="text" id="ds-model" class="form-input" value="" placeholder="Auto-detect" style="width:200px" />
</div>
<div class="form-group sim-field-auto">
<label class="form-label" for="ds-temp">Temp</label>
<label class="form-label" for="ds-temp">
Temp
<span class="tip-icon tip-below" data-tip="LLM sampling temperature. Lower = more deterministic decisions. 0.3 is a good balance — fully greedy (0.0) can get stuck in repetitive play patterns.">?</span>
</label>
<input type="number" id="ds-temp" class="form-input sim-num-input" value="0.3" min="0" max="2" step="0.1" />
</div>
<div class="form-group sim-field-auto">
<label class="form-label" for="ds-timeout">Timeout (s)</label>
<label class="form-label" for="ds-timeout">
Timeout (s)
<span class="tip-icon tip-below" data-tip="Max seconds to wait for an LLM response per decision. If exceeded, falls back to heuristic AI automatically. Increase if your GPU is slow on cold inference.">?</span>
</label>
<input type="number" id="ds-timeout" class="form-input sim-num-input" value="10" min="1" max="60" />
</div>
</div>