aimodelshare 0.3.7__py3-none-any.whl → 0.4.71__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. aimodelshare/moral_compass/__init__.py +51 -2
  2. aimodelshare/moral_compass/api_client.py +92 -4
  3. aimodelshare/moral_compass/apps/__init__.py +36 -16
  4. aimodelshare/moral_compass/apps/ai_consequences.py +98 -88
  5. aimodelshare/moral_compass/apps/bias_detective_ca.py +2722 -0
  6. aimodelshare/moral_compass/apps/bias_detective_en.py +2722 -0
  7. aimodelshare/moral_compass/apps/bias_detective_part1.py +2722 -0
  8. aimodelshare/moral_compass/apps/bias_detective_part2.py +2465 -0
  9. aimodelshare/moral_compass/apps/bias_detective_part_es.py +2722 -0
  10. aimodelshare/moral_compass/apps/ethical_revelation.py +237 -147
  11. aimodelshare/moral_compass/apps/fairness_fixer.py +1839 -859
  12. aimodelshare/moral_compass/apps/fairness_fixer_ca.py +1869 -0
  13. aimodelshare/moral_compass/apps/fairness_fixer_en.py +1869 -0
  14. aimodelshare/moral_compass/apps/fairness_fixer_es.py +1869 -0
  15. aimodelshare/moral_compass/apps/judge.py +130 -143
  16. aimodelshare/moral_compass/apps/justice_equity_upgrade.py +793 -831
  17. aimodelshare/moral_compass/apps/justice_equity_upgrade_ca.py +815 -0
  18. aimodelshare/moral_compass/apps/justice_equity_upgrade_en.py +815 -0
  19. aimodelshare/moral_compass/apps/justice_equity_upgrade_es.py +815 -0
  20. aimodelshare/moral_compass/apps/mc_integration_helpers.py +227 -745
  21. aimodelshare/moral_compass/apps/model_building_app_ca.py +4544 -0
  22. aimodelshare/moral_compass/apps/model_building_app_ca_final.py +3899 -0
  23. aimodelshare/moral_compass/apps/model_building_app_en.py +4290 -0
  24. aimodelshare/moral_compass/apps/model_building_app_en_final.py +3869 -0
  25. aimodelshare/moral_compass/apps/model_building_app_es.py +4362 -0
  26. aimodelshare/moral_compass/apps/model_building_app_es_final.py +3899 -0
  27. aimodelshare/moral_compass/apps/model_building_game.py +4211 -935
  28. aimodelshare/moral_compass/apps/moral_compass_challenge.py +195 -95
  29. aimodelshare/moral_compass/apps/what_is_ai.py +126 -117
  30. aimodelshare/moral_compass/challenge.py +98 -17
  31. {aimodelshare-0.3.7.dist-info → aimodelshare-0.4.71.dist-info}/METADATA +1 -1
  32. {aimodelshare-0.3.7.dist-info → aimodelshare-0.4.71.dist-info}/RECORD +35 -19
  33. aimodelshare/moral_compass/apps/bias_detective.py +0 -714
  34. {aimodelshare-0.3.7.dist-info → aimodelshare-0.4.71.dist-info}/WHEEL +0 -0
  35. {aimodelshare-0.3.7.dist-info → aimodelshare-0.4.71.dist-info}/licenses/LICENSE +0 -0
  36. {aimodelshare-0.3.7.dist-info → aimodelshare-0.4.71.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,3899 @@
1
+ """
2
+ Model Building Game - Gradio application for the Justice & Equity Challenge.
3
+
4
+ Session-based authentication with leaderboard caching and progressive rank unlocking.
5
+
6
+ Concurrency Notes:
7
+ - This app is designed to run in a multi-threaded environment (Cloud Run).
8
+ - Per-user state is stored in gr.State objects, NOT in os.environ.
9
+ - Caches are protected by locks to ensure thread safety.
10
+ - Linear algebra libraries are constrained to single-threaded mode to prevent
11
+ CPU oversubscription in containerized deployments.
12
+ """
13
+
14
+ import os
15
+
16
+ # -------------------------------------------------------------------------
17
+ # Thread Limit Configuration (MUST be set before importing numpy/sklearn)
18
+ # Prevents CPU oversubscription in containerized environments like Cloud Run.
19
+ # -------------------------------------------------------------------------
20
+ os.environ.setdefault("OMP_NUM_THREADS", "1")
21
+ os.environ.setdefault("OPENBLAS_NUM_THREADS", "1")
22
+ os.environ.setdefault("MKL_NUM_THREADS", "1")
23
+ os.environ.setdefault("NUMEXPR_NUM_THREADS", "1")
24
+
25
+ import time
26
+ import random
27
+ import requests
28
+ import contextlib
29
+ from io import StringIO
30
+ import threading
31
+ import functools
32
+ from pathlib import Path
33
+ from datetime import datetime, timedelta
34
+ from typing import Optional, Dict, Any, Tuple, Callable, TypeVar
35
+
36
+ import numpy as np
37
+ import pandas as pd
38
+ import gradio as gr
39
+
40
+ # --- Scikit-learn Imports ---
41
+ from sklearn.model_selection import train_test_split
42
+ from sklearn.preprocessing import StandardScaler
43
+ from sklearn.impute import SimpleImputer
44
+ from sklearn.compose import ColumnTransformer
45
+ from sklearn.pipeline import Pipeline
46
+ from sklearn.preprocessing import OneHotEncoder
47
+ from sklearn.linear_model import LogisticRegression
48
+ from sklearn.tree import DecisionTreeClassifier
49
+ from sklearn.ensemble import RandomForestClassifier
50
+ from sklearn.neighbors import KNeighborsClassifier
51
+
52
+ # --- AI Model Share Imports ---
53
+ try:
54
+ from aimodelshare.playground import Competition
55
+ except ImportError:
56
+ raise ImportError(
57
+ "The 'aimodelshare' library is required. Install with: pip install aimodelshare"
58
+ )
59
+
60
+ # -------------------------------------------------------------------------
61
+ # Configuration & Caching Infrastructure
62
+ # -------------------------------------------------------------------------
63
+
64
+
65
+ # -------------------------------------------------------------------------
66
+ # CACHE CONFIGURATION (Optimized: Thread-Safe SQLite)
67
+ # -------------------------------------------------------------------------
68
+ import sqlite3
69
+
70
+ CACHE_DB_FILE = "prediction_cache.sqlite"
71
+
72
+ def get_cached_prediction(key):
73
+ """
74
+ Lightning-fast lookup from SQLite database.
75
+ THREAD-SAFE FIX: Opens a new connection for every lookup.
76
+ """
77
+ # 1. Check if DB exists
78
+ if not os.path.exists(CACHE_DB_FILE):
79
+ return None
80
+
81
+ try:
82
+ # Use a context manager ('with') to ensure the connection
83
+ # is ALWAYS closed, releasing file locks immediately.
84
+ # timeout=10 ensures we don't wait forever if the file is busy.
85
+ with sqlite3.connect(CACHE_DB_FILE, timeout=10.0) as conn:
86
+ cursor = conn.cursor()
87
+ cursor.execute("SELECT value FROM cache WHERE key=?", (key,))
88
+ result = cursor.fetchone()
89
+
90
+ if result:
91
+ return result[0]
92
+ else:
93
+ return None
94
+
95
+ except sqlite3.OperationalError as e:
96
+ # Handle locking errors gracefully
97
+ print(f"⚠️ CACHE LOCK ERROR: {e}. Falling back to training.", flush=True)
98
+ return None
99
+
100
+ except Exception as e:
101
+ print(f"⚠️ DB READ ERROR: {e}", flush=True)
102
+ return None
103
+
104
+ print("✅ App configured for Thread-Safe SQLite Cache.")
105
+
106
+
107
+ LEADERBOARD_CACHE_SECONDS = int(os.environ.get("LEADERBOARD_CACHE_SECONDS", "45"))
108
+ MAX_LEADERBOARD_ENTRIES = os.environ.get("MAX_LEADERBOARD_ENTRIES")
109
+ MAX_LEADERBOARD_ENTRIES = int(MAX_LEADERBOARD_ENTRIES) if MAX_LEADERBOARD_ENTRIES else None
110
+ DEBUG_LOG = os.environ.get("DEBUG_LOG", "false").lower() == "true"
111
+
112
+ # In-memory caches (per container instance)
113
+ # Each cache has its own lock for thread safety under concurrent requests
114
+ _cache_lock = threading.Lock() # Protects _leaderboard_cache
115
+ _user_stats_lock = threading.Lock() # Protects _user_stats_cache
116
+ _auth_lock = threading.Lock() # Protects get_aws_token() credential injection
117
+
118
+ # Auth-aware leaderboard cache: separate entries for authenticated vs anonymous
119
+ # Structure: {"anon": {"data": df, "timestamp": float}, "auth": {"data": df, "timestamp": float}}
120
+ _leaderboard_cache: Dict[str, Dict[str, Any]] = {
121
+ "anon": {"data": None, "timestamp": 0.0},
122
+ "auth": {"data": None, "timestamp": 0.0},
123
+ }
124
+ _user_stats_cache: Dict[str, Dict[str, Any]] = {}
125
+ USER_STATS_TTL = LEADERBOARD_CACHE_SECONDS
126
+
127
+ # -------------------------------------------------------------------------
128
+ # Retry Helper for External API Calls
129
+ # -------------------------------------------------------------------------
130
+
131
+ T = TypeVar("T")
132
+
133
+ def _retry_with_backoff(
134
+ func: Callable[[], T],
135
+ max_attempts: int = 3,
136
+ base_delay: float = 0.5,
137
+ description: str = "operation"
138
+ ) -> T:
139
+ """
140
+ Execute a function with exponential backoff retry on failure.
141
+
142
+ Concurrency Note: This helper provides resilience against transient
143
+ network failures when calling external APIs (Competition.get_leaderboard,
144
+ playground.submit_model). Essential for Cloud Run deployments where
145
+ network calls may occasionally fail under load.
146
+
147
+ Args:
148
+ func: Callable to execute (should take no arguments)
149
+ max_attempts: Maximum number of attempts (default: 3)
150
+ base_delay: Initial delay in seconds, doubled each retry (default: 0.5)
151
+ description: Human-readable description for logging
152
+
153
+ Returns:
154
+ Result from successful function call
155
+
156
+ Raises:
157
+ Last exception if all attempts fail
158
+ """
159
+ last_exception: Optional[Exception] = None
160
+ delay = base_delay
161
+
162
+ for attempt in range(1, max_attempts + 1):
163
+ try:
164
+ return func()
165
+ except Exception as e:
166
+ last_exception = e
167
+ if attempt < max_attempts:
168
+ _log(f"{description} attempt {attempt} failed: {e}. Retrying in {delay}s...")
169
+ time.sleep(delay)
170
+ delay *= 2 # Exponential backoff
171
+ else:
172
+ _log(f"{description} failed after {max_attempts} attempts: {e}")
173
+
174
+ # Loop always runs at least once (max_attempts >= 1), so last_exception is set
175
+ raise last_exception # type: ignore[misc]
176
+
177
+ def _log(msg: str):
178
+ """Log message if DEBUG_LOG is enabled."""
179
+ if DEBUG_LOG:
180
+ print(f"[ModelBuildingGame] {msg}")
181
+
182
+ def _normalize_team_name(name: str) -> str:
183
+ """Normalize team name for consistent comparison and storage."""
184
+ if not name:
185
+ return ""
186
+ return " ".join(str(name).strip().split())
187
+
188
+ def _get_leaderboard_with_optional_token(playground_instance: Optional["Competition"], token: Optional[str] = None) -> Optional[pd.DataFrame]:
189
+ """
190
+ Fetch fresh leaderboard with optional token authentication and retry logic.
191
+
192
+ This is a helper function that centralizes the pattern of fetching
193
+ a fresh (non-cached) leaderboard with optional token authentication.
194
+ Use this for user-facing flows that require fresh, full data.
195
+
196
+ Concurrency Note: Uses _retry_with_backoff for resilience against
197
+ transient network failures.
198
+
199
+ Args:
200
+ playground_instance: The Competition playground instance (or None)
201
+ token: Optional authentication token for the fetch
202
+
203
+ Returns:
204
+ DataFrame with leaderboard data, or None if fetch fails or playground is None
205
+ """
206
+ if playground_instance is None:
207
+ return None
208
+
209
+ def _fetch():
210
+ if token:
211
+ return playground_instance.get_leaderboard(token=token)
212
+ return playground_instance.get_leaderboard()
213
+
214
+ try:
215
+ return _retry_with_backoff(_fetch, description="leaderboard fetch")
216
+ except Exception as e:
217
+ _log(f"Leaderboard fetch failed after retries: {e}")
218
+ return None
219
+
220
+ def _fetch_leaderboard(token: Optional[str]) -> Optional[pd.DataFrame]:
221
+ """
222
+ Fetch leaderboard with auth-aware caching (TTL: LEADERBOARD_CACHE_SECONDS).
223
+
224
+ Concurrency Note: Cache is keyed by auth scope ("anon" vs "auth") to prevent
225
+ cross-user data leakage. Authenticated users share a single "auth" cache entry
226
+ to avoid unbounded cache growth. Protected by _cache_lock.
227
+ """
228
+ # Determine cache key based on authentication status
229
+ cache_key = "auth" if token else "anon"
230
+ now = time.time()
231
+
232
+ with _cache_lock:
233
+ cache_entry = _leaderboard_cache[cache_key]
234
+ if (
235
+ cache_entry["data"] is not None
236
+ and now - cache_entry["timestamp"] < LEADERBOARD_CACHE_SECONDS
237
+ ):
238
+ _log(f"Leaderboard cache hit ({cache_key})")
239
+ return cache_entry["data"]
240
+
241
+ _log(f"Fetching fresh leaderboard ({cache_key})...")
242
+ df = None
243
+ try:
244
+ playground_id = "https://cf3wdpkg0d.execute-api.us-east-1.amazonaws.com/prod/m"
245
+ playground_instance = Competition(playground_id)
246
+
247
+ def _fetch():
248
+ return playground_instance.get_leaderboard(token=token) if token else playground_instance.get_leaderboard()
249
+
250
+ df = _retry_with_backoff(_fetch, description="leaderboard fetch")
251
+ if df is not None and not df.empty and MAX_LEADERBOARD_ENTRIES:
252
+ df = df.head(MAX_LEADERBOARD_ENTRIES)
253
+ _log(f"Leaderboard fetched ({cache_key}): {len(df) if df is not None else 0} entries")
254
+ except Exception as e:
255
+ _log(f"Leaderboard fetch failed ({cache_key}): {e}")
256
+ df = None
257
+
258
+ with _cache_lock:
259
+ _leaderboard_cache[cache_key]["data"] = df
260
+ _leaderboard_cache[cache_key]["timestamp"] = time.time()
261
+ return df
262
+
263
+ def _get_or_assign_team(username: str, leaderboard_df: Optional[pd.DataFrame]) -> Tuple[str, bool]:
264
+ """Get existing team from leaderboard or assign random team."""
265
+ # TEAM_NAMES is defined in configuration section below
266
+ try:
267
+ if leaderboard_df is not None and not leaderboard_df.empty and "Team" in leaderboard_df.columns:
268
+ user_submissions = leaderboard_df[leaderboard_df["username"] == username]
269
+ if not user_submissions.empty:
270
+ if "timestamp" in user_submissions.columns:
271
+ try:
272
+ user_submissions = user_submissions.copy()
273
+ user_submissions["timestamp"] = pd.to_datetime(
274
+ user_submissions["timestamp"], errors="coerce"
275
+ )
276
+ user_submissions = user_submissions.sort_values("timestamp", ascending=False)
277
+ _log(f"Sorted {len(user_submissions)} submissions by timestamp for {username}")
278
+ except Exception as ts_err:
279
+ _log(f"Timestamp sort error: {ts_err}")
280
+ existing_team = user_submissions.iloc[0]["Team"]
281
+ if pd.notna(existing_team) and str(existing_team).strip():
282
+ normalized = _normalize_team_name(existing_team)
283
+ _log(f"Found existing team for {username}: {normalized}")
284
+ return normalized, False
285
+ new_team = _normalize_team_name(random.choice(TEAM_NAMES))
286
+ _log(f"Assigning new team to {username}: {new_team}")
287
+ return new_team, True
288
+ except Exception as e:
289
+ _log(f"Team assignment error: {e}")
290
+ new_team = _normalize_team_name(random.choice(TEAM_NAMES))
291
+ return new_team, True
292
+
293
+ def _try_session_based_auth(request: "gr.Request") -> Tuple[bool, Optional[str], Optional[str]]:
294
+ """Attempt to authenticate via session token. Returns (success, username, token)."""
295
+ try:
296
+ session_id = request.query_params.get("sessionid") if request else None
297
+ if not session_id:
298
+ _log("No sessionid in request")
299
+ return False, None, None
300
+
301
+ from aimodelshare.aws import get_token_from_session, _get_username_from_token
302
+
303
+ token = get_token_from_session(session_id)
304
+ if not token:
305
+ _log("Failed to get token from session")
306
+ return False, None, None
307
+
308
+ username = _get_username_from_token(token)
309
+ if not username:
310
+ _log("Failed to extract username from token")
311
+ return False, None, None
312
+
313
+ _log(f"Session auth successful for {username}")
314
+ return True, username, token
315
+
316
+ except Exception as e:
317
+ _log(f"Session auth failed: {e}")
318
+ return False, None, None
319
+
320
+ def _compute_user_stats(username: str, token: str) -> Dict[str, Any]:
321
+ """
322
+ Compute user statistics with caching.
323
+
324
+ Concurrency Note: Protected by _user_stats_lock for thread-safe
325
+ cache reads and writes.
326
+ """
327
+ now = time.time()
328
+
329
+ # Thread-safe cache check
330
+ with _user_stats_lock:
331
+ cached = _user_stats_cache.get(username)
332
+ if cached and (now - cached.get("_ts", 0) < USER_STATS_TTL):
333
+ _log(f"User stats cache hit for {username}")
334
+ # Return shallow copy to prevent caller mutations from affecting cache.
335
+ # Stats dict contains only primitives (float, int, str), so shallow copy is sufficient.
336
+ return cached.copy()
337
+
338
+ _log(f"Computing fresh stats for {username}")
339
+ leaderboard_df = _fetch_leaderboard(token)
340
+ team_name, _ = _get_or_assign_team(username, leaderboard_df)
341
+
342
+ stats = {
343
+ "best_score": 0.0,
344
+ "rank": 0,
345
+ "team_name": team_name,
346
+ "submission_count": 0,
347
+ "last_score": 0.0,
348
+ "_ts": time.time()
349
+ }
350
+
351
+ try:
352
+ if leaderboard_df is not None and not leaderboard_df.empty:
353
+ user_submissions = leaderboard_df[leaderboard_df["username"] == username]
354
+ if not user_submissions.empty:
355
+ stats["submission_count"] = len(user_submissions)
356
+ if "accuracy" in user_submissions.columns:
357
+ stats["best_score"] = float(user_submissions["accuracy"].max())
358
+ if "timestamp" in user_submissions.columns:
359
+ try:
360
+ user_submissions = user_submissions.copy()
361
+ user_submissions["timestamp"] = pd.to_datetime(
362
+ user_submissions["timestamp"], errors="coerce"
363
+ )
364
+ recent = user_submissions.sort_values("timestamp", ascending=False).iloc[0]
365
+ stats["last_score"] = float(recent["accuracy"])
366
+ except:
367
+ stats["last_score"] = stats["best_score"]
368
+ else:
369
+ stats["last_score"] = stats["best_score"]
370
+
371
+ if "accuracy" in leaderboard_df.columns:
372
+ user_bests = leaderboard_df.groupby("username")["accuracy"].max()
373
+ ranked = user_bests.sort_values(ascending=False)
374
+ try:
375
+ stats["rank"] = int(ranked.index.get_loc(username) + 1)
376
+ except KeyError:
377
+ stats["rank"] = 0
378
+ except Exception as e:
379
+ _log(f"Error computing stats for {username}: {e}")
380
+
381
+ # Thread-safe cache update
382
+ with _user_stats_lock:
383
+ _user_stats_cache[username] = stats
384
+ _log(f"Stats for {username}: {stats}")
385
+ return stats
386
+ def _build_attempts_tracker_html(current_count, limit=10):
387
+ """
388
+ Generate HTML for the attempts tracker display.
389
+ Shows current attempt count vs limit with color coding.
390
+
391
+ Args:
392
+ current_count: Number of attempts used so far
393
+ limit: Maximum allowed attempts (default: ATTEMPT_LIMIT)
394
+
395
+ Returns:
396
+ str: HTML string for the tracker display
397
+ """
398
+ if current_count >= limit:
399
+ # Limit reached - red styling
400
+ bg_color = "#f0f9ff"
401
+ border_color = "#bae6fd"
402
+ text_color = "#0369a1"
403
+ icon = "🛑"
404
+ label = f"Last chance (for now) to boost your score!: {current_count}/{limit}"
405
+ else:
406
+ # Normal - blue styling
407
+ bg_color = "#f0f9ff"
408
+ border_color = "#bae6fd"
409
+ text_color = "#0369a1"
410
+ icon = "📊"
411
+ label = f"Attempts used: {current_count}/{limit}"
412
+
413
+ return f"""<div style='text-align:center; padding:8px; margin:8px 0; background:{bg_color}; border-radius:8px; border:1px solid {border_color};'>
414
+ <p style='margin:0; color:{text_color}; font-weight:600; font-size:1rem;'>{icon} {label}</p>
415
+ </div>"""
416
+
417
+ def check_attempt_limit(submission_count: int, limit: int = None) -> Tuple[bool, str]:
418
+ """Check if submission count exceeds limit."""
419
+ # ATTEMPT_LIMIT is defined in configuration section below
420
+ if limit is None:
421
+ limit = ATTEMPT_LIMIT
422
+
423
+ if submission_count >= limit:
424
+ msg = f"⚠️ Attempt limit reached ({submission_count}/{limit})"
425
+ return False, msg
426
+ return True, f"Attempts: {submission_count}/{limit}"
427
+
428
+ # -------------------------------------------------------------------------
429
+ # Future: Fairness Metrics
430
+ # -------------------------------------------------------------------------
431
+
432
+ # def compute_fairness_metrics(y_true, y_pred, sensitive_attrs):
433
+ # """
434
+ # Compute fairness metrics for model predictions.
435
+ #
436
+ # Args:
437
+ # y_true: Ground truth labels
438
+ # y_pred: Model predictions
439
+ # sensitive_attrs: DataFrame with sensitive attributes (race, sex, age)
440
+ #
441
+ # Returns:
442
+ # dict: Fairness metrics including demographic parity, equalized odds
443
+ #
444
+ # TODO: Implement using fairlearn or aif360
445
+ # """
446
+ # pass
447
+
448
+
449
+
450
+ # -------------------------------------------------------------------------
451
+ # 1. Configuration
452
+ # -------------------------------------------------------------------------
453
+
454
+ MY_PLAYGROUND_ID = "https://cf3wdpkg0d.execute-api.us-east-1.amazonaws.com/prod/m"
455
+
456
+ # --- Submission Limit Configuration ---
457
+ # Maximum number of successful leaderboard submissions per user per session.
458
+ # Preview runs (pre-login) and failed/invalid attempts do NOT count toward this limit.
459
+ # Only actual successful playground.submit_model() calls increment the count.
460
+ #
461
+ # TODO: Server-side persistent enforcement recommended
462
+ # The current attempt limit is stored in gr.State (per-session) and can be bypassed
463
+ # by refreshing the browser. For production use with 100+ concurrent users,
464
+ # consider implementing server-side persistence via Redis or Firestore to track
465
+ # attempt counts per user across sessions.
466
+ ATTEMPT_LIMIT = 1000000000
467
+
468
+ # --- Leaderboard Polling Configuration ---
469
+ # After a real authenticated submission, we poll the leaderboard to detect eventual consistency.
470
+ # This prevents the "stuck on first preview KPI" issue where the leaderboard hasn't updated yet.
471
+ # Increased from 12 to 60 to better tolerate backend latency and cold starts.
472
+ # If polling times out, optimistic fallback logic will provide provisional UI updates.
473
+ LEADERBOARD_POLL_TRIES = 60 # Number of polling attempts (increased to handle backend latency/cold starts)
474
+ LEADERBOARD_POLL_SLEEP = 1.0 # Sleep duration between polls (seconds)
475
+ ENABLE_AUTO_RESUBMIT_AFTER_READY = False # Future feature flag for auto-resubmit
476
+
477
+ MODEL_TYPES = {
478
+ "The Balanced Generalist": {
479
+ "model_builder": lambda: LogisticRegression(
480
+ max_iter=500, random_state=42, class_weight="balanced"
481
+ ),
482
+ "card": "A fast, reliable, well-rounded model. Good starting point; less prone to overfitting."
483
+ },
484
+ "The Rule-Maker": {
485
+ "model_builder": lambda: DecisionTreeClassifier(
486
+ random_state=42, class_weight="balanced"
487
+ ),
488
+ "card": "Learns simple 'if/then' rules. Easy to interpret, but can miss subtle patterns."
489
+ },
490
+ "The 'Nearest Neighbor'": {
491
+ "model_builder": lambda: KNeighborsClassifier(),
492
+ "card": "Looks at the closest past examples. 'You look like these others; I'll predict like they behave.'"
493
+ },
494
+ "The Deep Pattern-Finder": {
495
+ "model_builder": lambda: RandomForestClassifier(
496
+ random_state=42, class_weight="balanced"
497
+ ),
498
+ "card": "An ensemble of many decision trees. Powerful, can capture deep patterns; watch complexity."
499
+ }
500
+ }
501
+
502
+ DEFAULT_MODEL = "The Balanced Generalist"
503
+
504
+ TEAM_NAMES = [
505
+ "The Moral Champions", "The Justice League", "The Data Detectives",
506
+ "The Ethical Explorers", "The Fairness Finders", "The Accuracy Avengers"
507
+ ]
508
+ CURRENT_TEAM_NAME = random.choice(TEAM_NAMES)
509
+
510
+
511
+ # --- Feature groups for scaffolding (Weak -> Medium -> Strong) ---
512
+ FEATURE_SET_ALL_OPTIONS = [
513
+ ("Juvenile Felony Count", "juv_fel_count"),
514
+ ("Juvenile Misdemeanor Count", "juv_misd_count"),
515
+ ("Other Juvenile Count", "juv_other_count"),
516
+ ("Race", "race"),
517
+ ("Sex", "sex"),
518
+ ("Charge Severity (M/F)", "c_charge_degree"),
519
+ ("Days Before Arrest", "days_b_screening_arrest"),
520
+ ("Age", "age"),
521
+ ("Length of Stay", "length_of_stay"),
522
+ ("Prior Crimes Count", "priors_count"),
523
+ ]
524
+ FEATURE_SET_GROUP_1_VALS = [
525
+ "juv_fel_count", "juv_misd_count", "juv_other_count", "race", "sex",
526
+ "c_charge_degree", "days_b_screening_arrest"
527
+ ]
528
+ FEATURE_SET_GROUP_2_VALS = ["c_charge_desc", "age"]
529
+ FEATURE_SET_GROUP_3_VALS = ["length_of_stay", "priors_count"]
530
+ ALL_NUMERIC_COLS = [
531
+ "juv_fel_count", "juv_misd_count", "juv_other_count",
532
+ "days_b_screening_arrest", "age", "length_of_stay", "priors_count"
533
+ ]
534
+ ALL_CATEGORICAL_COLS = [
535
+ "race", "sex", "c_charge_degree"
536
+ ]
537
+ DEFAULT_FEATURE_SET = FEATURE_SET_GROUP_1_VALS
538
+
539
+
540
+ # --- Data Size config ---
541
+ DATA_SIZE_MAP = {
542
+ "Small (20%)": 0.2,
543
+ "Medium (60%)": 0.6,
544
+ "Large (80%)": 0.8,
545
+ "Full (100%)": 1.0
546
+ }
547
+ DEFAULT_DATA_SIZE = "Small (20%)"
548
+
549
+
550
+ MAX_ROWS = 4000
551
+ TOP_N_CHARGE_CATEGORICAL = 50
552
+ WARM_MINI_ROWS = 300 # Small warm dataset for instant preview
553
+ CACHE_MAX_AGE_HOURS = 24 # Cache validity duration
554
+ np.random.seed(42)
555
+
556
+ # Global state containers (populated during initialization)
557
+ playground = None
558
+ X_TRAIN_RAW = None # Keep this for 100%
559
+ X_TEST_RAW = None
560
+ Y_TRAIN = None
561
+ Y_TEST = None
562
+ # Add a container for our pre-sampled data
563
+ X_TRAIN_SAMPLES_MAP = {}
564
+ Y_TRAIN_SAMPLES_MAP = {}
565
+
566
+ # Warm mini dataset for instant preview
567
+ X_TRAIN_WARM = None
568
+ Y_TRAIN_WARM = None
569
+
570
+ # Cache for transformed test sets (for future performance improvements)
571
+ TEST_CACHE = {}
572
+
573
+ # Initialization flags to track readiness state
574
+ INIT_FLAGS = {
575
+ "competition": False,
576
+ "dataset_core": False,
577
+ "pre_samples_small": False,
578
+ "pre_samples_medium": False,
579
+ "pre_samples_large": False,
580
+ "pre_samples_full": False,
581
+ "leaderboard": False,
582
+ "default_preprocessor": False,
583
+ "warm_mini": False,
584
+ "errors": []
585
+ }
586
+
587
+ # Lock for thread-safe flag updates
588
+ INIT_LOCK = threading.Lock()
589
+
590
+ # -------------------------------------------------------------------------
591
+ # 2. Data & Backend Utilities
592
+ # -------------------------------------------------------------------------
593
+
594
+ def _get_cache_dir():
595
+ """Get or create the cache directory for datasets."""
596
+ cache_dir = Path.home() / ".aimodelshare_cache"
597
+ cache_dir.mkdir(exist_ok=True)
598
+ return cache_dir
599
+
600
+ def _safe_request_csv(url, cache_filename="compas.csv"):
601
+ """
602
+ Request CSV from URL with local caching.
603
+ Reuses cached file if it exists and is less than CACHE_MAX_AGE_HOURS old.
604
+ """
605
+ cache_dir = _get_cache_dir()
606
+ cache_path = cache_dir / cache_filename
607
+
608
+ # Check if cache exists and is fresh
609
+ if cache_path.exists():
610
+ file_time = datetime.fromtimestamp(cache_path.stat().st_mtime)
611
+ if datetime.now() - file_time < timedelta(hours=CACHE_MAX_AGE_HOURS):
612
+ return pd.read_csv(cache_path)
613
+
614
+ # Download fresh data
615
+ response = requests.get(url, timeout=30)
616
+ response.raise_for_status()
617
+ df = pd.read_csv(StringIO(response.text))
618
+
619
+ # Save to cache
620
+ df.to_csv(cache_path, index=False)
621
+
622
+ return df
623
+
624
+ def safe_int(value, default=1):
625
+ """
626
+ Safely coerce a value to int, returning default if value is None or invalid.
627
+ Protects against TypeError when Gradio sliders receive None.
628
+ """
629
+ if value is None:
630
+ return default
631
+ try:
632
+ return int(value)
633
+ except (ValueError, TypeError):
634
+ return default
635
+
636
+ def load_and_prep_data(use_cache=True):
637
+ """
638
+ Load, sample, and prepare raw COMPAS dataset.
639
+ NOW PRE-SAMPLES ALL DATA SIZES and creates warm mini dataset.
640
+ """
641
+ url = "https://raw.githubusercontent.com/propublica/compas-analysis/master/compas-scores-two-years.csv"
642
+
643
+ # Use cached version if available
644
+ if use_cache:
645
+ try:
646
+ df = _safe_request_csv(url)
647
+ except Exception as e:
648
+ print(f"Cache failed, fetching directly: {e}")
649
+ response = requests.get(url)
650
+ df = pd.read_csv(StringIO(response.text))
651
+ else:
652
+ response = requests.get(url)
653
+ df = pd.read_csv(StringIO(response.text))
654
+
655
+ # Calculate length_of_stay
656
+ try:
657
+ df['c_jail_in'] = pd.to_datetime(df['c_jail_in'])
658
+ df['c_jail_out'] = pd.to_datetime(df['c_jail_out'])
659
+ df['length_of_stay'] = (df['c_jail_out'] - df['c_jail_in']).dt.total_seconds() / (24 * 60 * 60) # in days
660
+ except Exception:
661
+ df['length_of_stay'] = np.nan
662
+
663
+ if df.shape[0] > MAX_ROWS:
664
+ df = df.sample(n=MAX_ROWS, random_state=42)
665
+
666
+ feature_columns = ALL_NUMERIC_COLS + ALL_CATEGORICAL_COLS
667
+ feature_columns = sorted(list(set(feature_columns)))
668
+
669
+ target_column = "two_year_recid"
670
+
671
+ if "c_charge_desc" in df.columns:
672
+ top_charges = df["c_charge_desc"].value_counts().head(TOP_N_CHARGE_CATEGORICAL).index
673
+ df["c_charge_desc"] = df["c_charge_desc"].apply(
674
+ lambda x: x if pd.notna(x) and x in top_charges else "OTHER"
675
+ )
676
+
677
+ for col in feature_columns:
678
+ if col not in df.columns:
679
+ if col == 'length_of_stay' and 'length_of_stay' in df.columns:
680
+ continue
681
+ df[col] = np.nan
682
+
683
+ X = df[feature_columns].copy()
684
+ y = df[target_column].copy()
685
+
686
+ X_train_raw, X_test_raw, y_train, y_test = train_test_split(
687
+ X, y, test_size=0.25, random_state=42, stratify=y
688
+ )
689
+
690
+ # Pre-sample all data sizes
691
+ global X_TRAIN_SAMPLES_MAP, Y_TRAIN_SAMPLES_MAP, X_TRAIN_WARM, Y_TRAIN_WARM
692
+
693
+ X_TRAIN_SAMPLES_MAP["Full (100%)"] = X_train_raw
694
+ Y_TRAIN_SAMPLES_MAP["Full (100%)"] = y_train
695
+
696
+ for label, frac in DATA_SIZE_MAP.items():
697
+ if frac < 1.0:
698
+ X_train_sampled = X_train_raw.sample(frac=frac, random_state=42)
699
+ y_train_sampled = y_train.loc[X_train_sampled.index]
700
+ X_TRAIN_SAMPLES_MAP[label] = X_train_sampled
701
+ Y_TRAIN_SAMPLES_MAP[label] = y_train_sampled
702
+
703
+ # Create warm mini dataset for instant preview
704
+ warm_size = min(WARM_MINI_ROWS, len(X_train_raw))
705
+ X_TRAIN_WARM = X_train_raw.sample(n=warm_size, random_state=42)
706
+ Y_TRAIN_WARM = y_train.loc[X_TRAIN_WARM.index]
707
+
708
+
709
+
710
+ return X_train_raw, X_test_raw, y_train, y_test
711
+
712
+ def _background_initializer():
713
+ """
714
+ Background thread that performs sequential initialization tasks.
715
+ Updates INIT_FLAGS dict with readiness booleans and captures errors.
716
+
717
+ Initialization sequence:
718
+ 1. Competition object connection
719
+ 2. Dataset cached download and core split
720
+ 3. Warm mini dataset creation
721
+ 4. Progressive sampling: small -> medium -> large -> full
722
+ 5. Leaderboard prefetch
723
+ 6. Default preprocessor fit on small sample
724
+ """
725
+ global playground, X_TRAIN_RAW, X_TEST_RAW, Y_TRAIN, Y_TEST
726
+
727
+ try:
728
+ # Step 1: Connect to competition
729
+ with INIT_LOCK:
730
+ if playground is None:
731
+ playground = Competition(MY_PLAYGROUND_ID)
732
+ INIT_FLAGS["competition"] = True
733
+ except Exception as e:
734
+ with INIT_LOCK:
735
+ INIT_FLAGS["errors"].append(f"Competition connection failed: {str(e)}")
736
+
737
+ try:
738
+ # Step 2: Load dataset core (train/test split)
739
+ X_TRAIN_RAW, X_TEST_RAW, Y_TRAIN, Y_TEST = load_and_prep_data(use_cache=True)
740
+ with INIT_LOCK:
741
+ INIT_FLAGS["dataset_core"] = True
742
+ except Exception as e:
743
+ with INIT_LOCK:
744
+ INIT_FLAGS["errors"].append(f"Dataset loading failed: {str(e)}")
745
+ return # Cannot proceed without data
746
+
747
+ try:
748
+ # Step 3: Warm mini dataset (already created in load_and_prep_data)
749
+ if X_TRAIN_WARM is not None and len(X_TRAIN_WARM) > 0:
750
+ with INIT_LOCK:
751
+ INIT_FLAGS["warm_mini"] = True
752
+ except Exception as e:
753
+ with INIT_LOCK:
754
+ INIT_FLAGS["errors"].append(f"Warm mini dataset failed: {str(e)}")
755
+
756
+ # Progressive sampling - samples are already created in load_and_prep_data
757
+ # Just mark them as ready sequentially with delays to simulate progressive loading
758
+
759
+ try:
760
+ # Step 4a: Small sample (20%)
761
+ time.sleep(0.5) # Simulate processing
762
+ with INIT_LOCK:
763
+ INIT_FLAGS["pre_samples_small"] = True
764
+ except Exception as e:
765
+ with INIT_LOCK:
766
+ INIT_FLAGS["errors"].append(f"Small sample failed: {str(e)}")
767
+
768
+ try:
769
+ # Step 4b: Medium sample (60%)
770
+ time.sleep(0.5)
771
+ with INIT_LOCK:
772
+ INIT_FLAGS["pre_samples_medium"] = True
773
+ except Exception as e:
774
+ with INIT_LOCK:
775
+ INIT_FLAGS["errors"].append(f"Medium sample failed: {str(e)}")
776
+
777
+ try:
778
+ # Step 4c: Large sample (80%)
779
+ time.sleep(0.5)
780
+ with INIT_LOCK:
781
+ INIT_FLAGS["pre_samples_large"] = True
782
+ except Exception as e:
783
+ with INIT_LOCK:
784
+ INIT_FLAGS["errors"].append(f"Large sample failed: {str(e)}")
785
+ print(f"✗ Large sample failed: {e}")
786
+
787
+ try:
788
+ # Step 4d: Full sample (100%)
789
+ print("Background init: Full sample (100%)...")
790
+ time.sleep(0.5)
791
+ with INIT_LOCK:
792
+ INIT_FLAGS["pre_samples_full"] = True
793
+ except Exception as e:
794
+ with INIT_LOCK:
795
+ INIT_FLAGS["errors"].append(f"Full sample failed: {str(e)}")
796
+
797
+ try:
798
+ # Step 5: Leaderboard prefetch (best-effort, unauthenticated)
799
+ # Concurrency Note: Do NOT use os.environ for ambient token - prefetch
800
+ # anonymously to warm the cache for initial page loads.
801
+ if playground is not None:
802
+ _ = _get_leaderboard_with_optional_token(playground, None)
803
+ with INIT_LOCK:
804
+ INIT_FLAGS["leaderboard"] = True
805
+ except Exception as e:
806
+ with INIT_LOCK:
807
+ INIT_FLAGS["errors"].append(f"Leaderboard prefetch failed: {str(e)}")
808
+
809
+ try:
810
+ # Step 6: Default preprocessor on small sample
811
+ _fit_default_preprocessor()
812
+ with INIT_LOCK:
813
+ INIT_FLAGS["default_preprocessor"] = True
814
+ except Exception as e:
815
+ with INIT_LOCK:
816
+ INIT_FLAGS["errors"].append(f"Default preprocessor failed: {str(e)}")
817
+ print(f"✗ Default preprocessor failed: {e}")
818
+
819
+
820
+ def _fit_default_preprocessor():
821
+ """
822
+ Pre-fit a default preprocessor on the small sample with default features.
823
+ Uses memoized preprocessor builder for efficiency.
824
+ """
825
+ if "Small (20%)" not in X_TRAIN_SAMPLES_MAP:
826
+ return
827
+
828
+ X_sample = X_TRAIN_SAMPLES_MAP["Small (20%)"]
829
+
830
+ # Use default feature set
831
+ numeric_cols = [f for f in DEFAULT_FEATURE_SET if f in ALL_NUMERIC_COLS]
832
+ categorical_cols = [f for f in DEFAULT_FEATURE_SET if f in ALL_CATEGORICAL_COLS]
833
+
834
+ if not numeric_cols and not categorical_cols:
835
+ return
836
+
837
+ # Use memoized builder
838
+ preprocessor, selected_cols = build_preprocessor(numeric_cols, categorical_cols)
839
+ preprocessor.fit(X_sample[selected_cols])
840
+
841
+ def start_background_init():
842
+ """
843
+ Start the background initialization thread.
844
+ Should be called once at app creation.
845
+ """
846
+ thread = threading.Thread(target=_background_initializer, daemon=True)
847
+ thread.start()
848
+
849
+ def poll_init_status():
850
+ """
851
+ Poll the initialization status and return readiness bool.
852
+ Returns empty string for HTML so users don't see the checklist.
853
+
854
+ Returns:
855
+ tuple: (status_html, ready_bool)
856
+ """
857
+ with INIT_LOCK:
858
+ flags = INIT_FLAGS.copy()
859
+
860
+ # Determine if minimum requirements met
861
+ ready = flags["competition"] and flags["dataset_core"] and flags["pre_samples_small"]
862
+
863
+ return "", ready
864
+
865
+ def get_available_data_sizes():
866
+ """
867
+ Return list of data sizes that are currently available based on init flags.
868
+ """
869
+ with INIT_LOCK:
870
+ flags = INIT_FLAGS.copy()
871
+
872
+ available = []
873
+ if flags["pre_samples_small"]:
874
+ available.append("Small (20%)")
875
+ if flags["pre_samples_medium"]:
876
+ available.append("Medium (60%)")
877
+ if flags["pre_samples_large"]:
878
+ available.append("Large (80%)")
879
+ if flags["pre_samples_full"]:
880
+ available.append("Full (100%)")
881
+
882
+ return available if available else ["Small (20%)"] # Fallback
883
+
884
+ def _is_ready() -> bool:
885
+ """
886
+ Check if initialization is complete and system is ready for real submissions.
887
+
888
+ Returns:
889
+ bool: True if competition, dataset, and small sample are initialized
890
+ """
891
+ with INIT_LOCK:
892
+ flags = INIT_FLAGS.copy()
893
+ return flags["competition"] and flags["dataset_core"] and flags["pre_samples_small"]
894
+
895
+ def _get_user_latest_accuracy(df: Optional[pd.DataFrame], username: str) -> Optional[float]:
896
+ """
897
+ Extract the user's latest submission accuracy from the leaderboard.
898
+
899
+ Uses timestamp sorting when available; otherwise assumes last row is latest.
900
+
901
+ Args:
902
+ df: Leaderboard DataFrame
903
+ username: Username to extract accuracy for
904
+
905
+ Returns:
906
+ float: Latest submission accuracy, or None if not found/invalid
907
+ """
908
+ if df is None or df.empty:
909
+ return None
910
+
911
+ try:
912
+ user_rows = df[df["username"] == username]
913
+ if user_rows.empty or "accuracy" not in user_rows.columns:
914
+ return None
915
+
916
+ # Try timestamp-based sorting if available
917
+ if "timestamp" in user_rows.columns:
918
+ user_rows = user_rows.copy()
919
+ user_rows["__parsed_ts"] = pd.to_datetime(user_rows["timestamp"], errors="coerce")
920
+ valid_ts = user_rows[user_rows["__parsed_ts"].notna()]
921
+
922
+ if not valid_ts.empty:
923
+ # Sort by timestamp and get latest
924
+ latest_row = valid_ts.sort_values("__parsed_ts", ascending=False).iloc[0]
925
+ return float(latest_row["accuracy"])
926
+
927
+ # Fallback: assume last row is latest (append order)
928
+ return float(user_rows.iloc[-1]["accuracy"])
929
+
930
+ except Exception as e:
931
+ _log(f"Error extracting latest accuracy for {username}: {e}")
932
+ return None
933
+
934
+ def _get_user_latest_ts(df: Optional[pd.DataFrame], username: str) -> Optional[float]:
935
+ """
936
+ Extract the user's latest valid timestamp from the leaderboard.
937
+
938
+ Args:
939
+ df: Leaderboard DataFrame
940
+ username: Username to extract timestamp for
941
+
942
+ Returns:
943
+ float: Latest timestamp as unix epoch, or None if not found/invalid
944
+ """
945
+ if df is None or df.empty:
946
+ return None
947
+
948
+ try:
949
+ user_rows = df[df["username"] == username]
950
+ if user_rows.empty or "timestamp" not in user_rows.columns:
951
+ return None
952
+
953
+ # Parse timestamps and get the latest
954
+ user_rows = user_rows.copy()
955
+ user_rows["__parsed_ts"] = pd.to_datetime(user_rows["timestamp"], errors="coerce")
956
+ valid_ts = user_rows[user_rows["__parsed_ts"].notna()]
957
+
958
+ if valid_ts.empty:
959
+ return None
960
+
961
+ latest_ts = valid_ts["__parsed_ts"].max()
962
+ return latest_ts.timestamp() if pd.notna(latest_ts) else None
963
+ except Exception as e:
964
+ _log(f"Error extracting latest timestamp for {username}: {e}")
965
+ return None
966
+
967
+ def _user_rows_changed(
968
+ refreshed_leaderboard: Optional[pd.DataFrame],
969
+ username: str,
970
+ old_row_count: int,
971
+ old_best_score: float,
972
+ old_latest_ts: Optional[float] = None,
973
+ old_latest_score: Optional[float] = None
974
+ ) -> bool:
975
+ """
976
+ Check if user's leaderboard entries have changed after submission.
977
+
978
+ Used after polling to detect if the leaderboard has updated with the new submission.
979
+ Checks row count (new submission added), best score (score improved), latest timestamp,
980
+ and latest accuracy (handles backend overwrite without append).
981
+
982
+ Args:
983
+ refreshed_leaderboard: Fresh leaderboard data
984
+ username: Username to check for
985
+ old_row_count: Previous number of submissions for this user
986
+ old_best_score: Previous best accuracy score
987
+ old_latest_ts: Previous latest timestamp (unix epoch), optional
988
+ old_latest_score: Previous latest submission accuracy, optional
989
+
990
+ Returns:
991
+ bool: True if user has more rows, better score, newer timestamp, or changed latest accuracy
992
+ """
993
+ if refreshed_leaderboard is None or refreshed_leaderboard.empty:
994
+ return False
995
+
996
+ try:
997
+ user_rows = refreshed_leaderboard[refreshed_leaderboard["username"] == username]
998
+ if user_rows.empty:
999
+ return False
1000
+
1001
+ new_row_count = len(user_rows)
1002
+ new_best_score = float(user_rows["accuracy"].max()) if "accuracy" in user_rows.columns else 0.0
1003
+ new_latest_ts = _get_user_latest_ts(refreshed_leaderboard, username)
1004
+ new_latest_score = _get_user_latest_accuracy(refreshed_leaderboard, username)
1005
+
1006
+ # Changed if we have more submissions, better score, newer timestamp, or changed latest accuracy
1007
+ changed = (new_row_count > old_row_count) or (new_best_score > old_best_score + 0.0001)
1008
+
1009
+ # Check timestamp if available
1010
+ if old_latest_ts is not None and new_latest_ts is not None:
1011
+ changed = changed or (new_latest_ts > old_latest_ts)
1012
+
1013
+ # Check latest accuracy change (handles overwrite-without-append case)
1014
+ if old_latest_score is not None and new_latest_score is not None:
1015
+ accuracy_changed = abs(new_latest_score - old_latest_score) >= 0.00001
1016
+ if accuracy_changed:
1017
+ _log(f"Latest accuracy changed: {old_latest_score:.4f} -> {new_latest_score:.4f}")
1018
+ changed = changed or accuracy_changed
1019
+
1020
+ if changed:
1021
+ _log(f"User rows changed for {username}:")
1022
+ _log(f" Row count: {old_row_count} -> {new_row_count}")
1023
+ _log(f" Best score: {old_best_score:.4f} -> {new_best_score:.4f}")
1024
+ _log(f" Latest score: {old_latest_score if old_latest_score else 'N/A'} -> {new_latest_score if new_latest_score else 'N/A'}")
1025
+ _log(f" Timestamp: {old_latest_ts} -> {new_latest_ts}")
1026
+
1027
+ return changed
1028
+ except Exception as e:
1029
+ _log(f"Error checking user rows: {e}")
1030
+ return False
1031
+
1032
+ @functools.lru_cache(maxsize=32)
1033
+ def _get_cached_preprocessor_config(numeric_cols_tuple, categorical_cols_tuple):
1034
+ """
1035
+ Create and return preprocessor configuration (memoized).
1036
+ Uses tuples for hashability in lru_cache.
1037
+
1038
+ Concurrency Note: Uses sparse_output=True for OneHotEncoder to reduce memory
1039
+ footprint under concurrent requests. Downstream models that require dense
1040
+ arrays (DecisionTree, RandomForest) will convert via .toarray() as needed.
1041
+ LogisticRegression and KNeighborsClassifier handle sparse matrices natively.
1042
+
1043
+ Returns tuple of (transformers_list, selected_columns) ready for ColumnTransformer.
1044
+ """
1045
+ numeric_cols = list(numeric_cols_tuple)
1046
+ categorical_cols = list(categorical_cols_tuple)
1047
+
1048
+ transformers = []
1049
+ selected_cols = []
1050
+
1051
+ if numeric_cols:
1052
+ num_tf = Pipeline(steps=[
1053
+ ("imputer", SimpleImputer(strategy="median")),
1054
+ ("scaler", StandardScaler())
1055
+ ])
1056
+ transformers.append(("num", num_tf, numeric_cols))
1057
+ selected_cols.extend(numeric_cols)
1058
+
1059
+ if categorical_cols:
1060
+ # Use sparse_output=True to reduce memory footprint
1061
+ cat_tf = Pipeline(steps=[
1062
+ ("imputer", SimpleImputer(strategy="constant", fill_value="missing")),
1063
+ ("onehot", OneHotEncoder(handle_unknown="ignore", sparse_output=True))
1064
+ ])
1065
+ transformers.append(("cat", cat_tf, categorical_cols))
1066
+ selected_cols.extend(categorical_cols)
1067
+
1068
+ return transformers, selected_cols
1069
+
1070
+ def build_preprocessor(numeric_cols, categorical_cols):
1071
+ """
1072
+ Build a preprocessor using cached configuration.
1073
+ The configuration (pipeline structure) is memoized; the actual fit is not.
1074
+
1075
+ Note: Returns sparse matrices when categorical columns are present.
1076
+ Use _ensure_dense() helper if model requires dense input.
1077
+ """
1078
+ # Convert to tuples for caching
1079
+ numeric_tuple = tuple(sorted(numeric_cols))
1080
+ categorical_tuple = tuple(sorted(categorical_cols))
1081
+
1082
+ transformers, selected_cols = _get_cached_preprocessor_config(numeric_tuple, categorical_tuple)
1083
+
1084
+ # Create new ColumnTransformer with cached config
1085
+ preprocessor = ColumnTransformer(transformers=transformers, remainder="drop")
1086
+
1087
+ return preprocessor, selected_cols
1088
+
1089
+ def _ensure_dense(X):
1090
+ """
1091
+ Convert sparse matrix to dense if necessary.
1092
+
1093
+ Helper function for models that don't support sparse input
1094
+ (DecisionTree, RandomForest). LogisticRegression and KNN
1095
+ handle sparse matrices natively.
1096
+ """
1097
+ from scipy import sparse
1098
+ if sparse.issparse(X):
1099
+ return X.toarray()
1100
+ return X
1101
+
1102
+ def tune_model_complexity(model, level):
1103
+ """
1104
+ Map a 1–10 slider value to model hyperparameters.
1105
+ Levels 1–3: Conservative / simple
1106
+ Levels 4–7: Balanced
1107
+ Levels 8–10: Aggressive / risk of overfitting
1108
+ """
1109
+ level = int(level)
1110
+ if isinstance(model, LogisticRegression):
1111
+ c_map = {1: 0.01, 2: 0.025, 3: 0.05, 4: 0.1, 5: 0.25, 6: 0.5, 7: 1.0, 8: 2.0, 9: 5.0, 10: 10.0}
1112
+ model.C = c_map.get(level, 1.0)
1113
+ model.max_iter = max(getattr(model, "max_iter", 0), 500)
1114
+ elif isinstance(model, RandomForestClassifier):
1115
+ depth_map = {1: 3, 2: 5, 3: 7, 4: 9, 5: 11, 6: 15, 7: 20, 8: 25, 9: None, 10: None}
1116
+ est_map = {1: 20, 2: 30, 3: 40, 4: 60, 5: 80, 6: 100, 7: 120, 8: 150, 9: 180, 10: 220}
1117
+ model.max_depth = depth_map.get(level, 10)
1118
+ model.n_estimators = est_map.get(level, 100)
1119
+ elif isinstance(model, DecisionTreeClassifier):
1120
+ depth_map = {1: 2, 2: 3, 3: 4, 4: 5, 5: 6, 6: 8, 7: 10, 8: 12, 9: 15, 10: None}
1121
+ model.max_depth = depth_map.get(level, 6)
1122
+ elif isinstance(model, KNeighborsClassifier):
1123
+ k_map = {1: 100, 2: 75, 3: 60, 4: 50, 5: 40, 6: 30, 7: 25, 8: 15, 9: 7, 10: 3}
1124
+ model.n_neighbors = k_map.get(level, 25)
1125
+ return model
1126
+
1127
+ # --- New Helper Functions for HTML Generation ---
1128
+
1129
+ def _normalize_team_name(name: str) -> str:
1130
+ """
1131
+ Normalize team name for consistent comparison and storage.
1132
+
1133
+ Strips leading/trailing whitespace and collapses multiple spaces into single spaces.
1134
+ This ensures consistent formatting across environment variables, state, and leaderboard rendering.
1135
+
1136
+ Args:
1137
+ name: Team name to normalize (can be None or empty)
1138
+
1139
+ Returns:
1140
+ str: Normalized team name, or empty string if input is None/empty
1141
+
1142
+ Examples:
1143
+ >>> _normalize_team_name(" The Ethical Explorers ")
1144
+ 'The Ethical Explorers'
1145
+ >>> _normalize_team_name("The Moral Champions")
1146
+ 'The Moral Champions'
1147
+ >>> _normalize_team_name(None)
1148
+ ''
1149
+ """
1150
+ if not name:
1151
+ return ""
1152
+ return " ".join(str(name).strip().split())
1153
+
1154
+
1155
+
1156
+ def _build_skeleton_leaderboard(rows=6, is_team=True, submit_button_label="5. 🔬 Build & Submit Model"):
1157
+ context_label = "Team" if is_team else "Individual"
1158
+ return f"""
1159
+ <div class='lb-placeholder' aria-live='polite'>
1160
+ <div class='lb-placeholder-title'>{context_label} Standings Pending</div>
1161
+ <div class='lb-placeholder-sub'>
1162
+ <p style='margin:0 0 6px 0;'>Submit your first model to populate this table.</p>
1163
+ <p style='margin:0;'><strong>Click “{submit_button_label}” (bottom-left)</strong> to begin!</p>
1164
+ </div>
1165
+ </div>
1166
+ """
1167
+ # --- FIX APPLIED HERE ---
1168
+ def build_login_prompt_html():
1169
+ """
1170
+ Generate HTML for the login prompt text *only*.
1171
+ The styled preview card will be prepended to this.
1172
+ """
1173
+ return f"""
1174
+ <h2 style='color: #111827; margin-top:20px; border-top: 2px solid #e5e7eb; padding-top: 20px;'>🔐 Sign in to submit & rank</h2>
1175
+ <div style='margin-top:16px; text-align:left; font-size:1rem; line-height:1.6; color:#374151;'>
1176
+ <p style='margin:12px 0;'>
1177
+ This is a preview run only. Sign in to publish your score to the live leaderboard,
1178
+ earn promotions, and contribute team points.
1179
+ </p>
1180
+ <p style='margin:12px 0;'>
1181
+ <strong>New user?</strong> Create a free account at
1182
+ <a href='https://www.modelshare.ai/login' target='_blank'
1183
+ style='color:#4f46e5; text-decoration:underline;'>modelshare.ai/login</a>
1184
+ </p>
1185
+ </div>
1186
+ """
1187
+ # --- END OF FIX ---
1188
+
1189
+ def _build_kpi_card_html(new_score, last_score, new_rank, last_rank, submission_count, is_preview=False, is_pending=False, local_test_accuracy=None):
1190
+ """Generates the HTML for the KPI feedback card. Supports preview mode label and pending state."""
1191
+
1192
+ # Handle pending state - show processing message with provisional diff
1193
+ if is_pending:
1194
+ title = "⏳ Submission Processing"
1195
+ acc_color = "#3b82f6" # Blue
1196
+ acc_text = f"{(local_test_accuracy * 100):.2f}%" if local_test_accuracy is not None else "N/A"
1197
+
1198
+ # Compute provisional diff between local (new) and last score
1199
+ if local_test_accuracy is not None and last_score is not None and last_score > 0:
1200
+ score_diff = local_test_accuracy - last_score
1201
+ if abs(score_diff) < 0.0001:
1202
+ acc_diff_html = "<p style='font-size: 1.5rem; font-weight: 600; color: #6b7280; margin:0;'>No Change (↔) <span style='font-size: 0.9rem; color: #9ca3af;'>(Provisional)</span></p><p style='font-size: 1.2rem; font-weight: 500; color: #6b7280; margin:0; padding-top: 8px;'>Pending leaderboard update...</p>"
1203
+ elif score_diff > 0:
1204
+ acc_diff_html = f"<p style='font-size: 1.5rem; font-weight: 600; color: #16a34a; margin:0;'>+{(score_diff * 100):.2f} (⬆️) <span style='font-size: 0.9rem; color: #9ca3af;'>(Provisional)</span></p><p style='font-size: 1.2rem; font-weight: 500; color: #6b7280; margin:0; padding-top: 8px;'>Pending leaderboard update...</p>"
1205
+ else:
1206
+ acc_diff_html = f"<p style='font-size: 1.5rem; font-weight: 600; color: #ef4444; margin:0;'>{(score_diff * 100):.2f} (⬇️) <span style='font-size: 0.9rem; color: #9ca3af;'>(Provisional)</span></p><p style='font-size: 1.2rem; font-weight: 500; color: #6b7280; margin:0; padding-top: 8px;'>Pending leaderboard update...</p>"
1207
+ else:
1208
+ # No last score available - just show pending message
1209
+ acc_diff_html = "<p style='font-size: 1.2rem; font-weight: 500; color: #6b7280; margin:0; padding-top: 8px;'>Pending leaderboard update...</p>"
1210
+
1211
+ border_color = acc_color
1212
+ rank_color = "#6b7280" # Gray
1213
+ rank_text = "Pending"
1214
+ rank_diff_html = "<p style='font-size: 1.2rem; font-weight: 500; color: #6b7280; margin:0;'>Calculating rank...</p>"
1215
+
1216
+ # Handle preview mode - Styled to match "success" card
1217
+ elif is_preview:
1218
+ title = "🔬 Successful Preview Run!"
1219
+ acc_color = "#16a34a" # Green (like success)
1220
+ acc_text = f"{(new_score * 100):.2f}%" if new_score > 0 else "N/A"
1221
+ acc_diff_html = "<p style='font-size: 1.2rem; font-weight: 500; color: #6b7280; margin:0; padding-top: 8px;'>(Preview only - not submitted)</p>" # Neutral color
1222
+ border_color = acc_color # Green border
1223
+ rank_color = "#3b82f6" # Blue (like rank)
1224
+ rank_text = "N/A" # Placeholder
1225
+ rank_diff_html = "<p style='font-size: 1.2rem; font-weight: 500; color: #6b7280; margin:0;'>Not ranked (preview)</p>" # Neutral color
1226
+
1227
+ # 1. Handle First Submission
1228
+ elif submission_count == 0:
1229
+ title = "🎉 First Model Submitted!"
1230
+ acc_color = "#16a34a" # green
1231
+ acc_text = f"{(new_score * 100):.2f}%"
1232
+ acc_diff_html = "<p style='font-size: 1.2rem; font-weight: 500; color: #6b7280; margin:0; padding-top: 8px;'>(Your first score!)</p>"
1233
+
1234
+ rank_color = "#3b82f6" # blue
1235
+ rank_text = f"#{new_rank}"
1236
+ rank_diff_html = "<p style='font-size: 1.5rem; font-weight: 600; color: #3b82f6; margin:0;'>You're on the board!</p>"
1237
+ border_color = acc_color
1238
+
1239
+ else:
1240
+ # 2. Handle Score Changes
1241
+ score_diff = new_score - last_score
1242
+ if abs(score_diff) < 0.0001:
1243
+ title = "✅ Submission Successful"
1244
+ acc_color = "#6b7280" # gray
1245
+ acc_text = f"{(new_score * 100):.2f}%"
1246
+ acc_diff_html = f"<p style='font-size: 1.5rem; font-weight: 600; color: {acc_color}; margin:0;'>No Change (↔)</p>"
1247
+ border_color = acc_color
1248
+ elif score_diff > 0:
1249
+ title = "✅ Submission Successful!"
1250
+ acc_color = "#16a34a" # green
1251
+ acc_text = f"{(new_score * 100):.2f}%"
1252
+ acc_diff_html = f"<p style='font-size: 1.5rem; font-weight: 600; color: {acc_color}; margin:0;'>+{(score_diff * 100):.2f} (⬆️)</p>"
1253
+ border_color = acc_color
1254
+ else:
1255
+ title = "📉 Score Dropped"
1256
+ acc_color = "#ef4444" # red
1257
+ acc_text = f"{(new_score * 100):.2f}%"
1258
+ acc_diff_html = f"<p style='font-size: 1.5rem; font-weight: 600; color: {acc_color}; margin:0;'>{(score_diff * 100):.2f} (⬇️)</p>"
1259
+ border_color = acc_color
1260
+
1261
+ # 3. Handle Rank Changes
1262
+ rank_diff = last_rank - new_rank
1263
+ rank_color = "#3b82f6" # blue
1264
+ rank_text = f"#{new_rank}"
1265
+ if last_rank == 0: # Handle first rank
1266
+ rank_diff_html = "<p style='font-size: 1.5rem; font-weight: 600; color: #3b82f6; margin:0;'>You're on the board!</p>"
1267
+ elif rank_diff > 0:
1268
+ rank_diff_html = f"<p style='font-size: 1.5rem; font-weight: 600; color: #16a34a; margin:0;'>🚀 Moved up {rank_diff} spot{'s' if rank_diff > 1 else ''}!</p>"
1269
+ elif rank_diff < 0:
1270
+ rank_diff_html = f"<p style='font-size: 1.5rem; font-weight: 600; color: #ef4444; margin:0;'>🔻 Dropped {abs(rank_diff)} spot{'s' if abs(rank_diff) > 1 else ''}</p>"
1271
+ else:
1272
+ rank_diff_html = f"<p style='font-size: 1.5rem; font-weight: 600; color: {rank_color}; margin:0;'>No Change (↔)</p>"
1273
+
1274
+ return f"""
1275
+ <div class='kpi-card' style='border-color: {border_color};'>
1276
+ <h2 style='color: #111827; margin-top:0;'>{title}</h2>
1277
+ <div class='kpi-card-body'>
1278
+ <div class='kpi-metric-box'>
1279
+ <p class='kpi-label'>New Accuracy</p>
1280
+ <p class='kpi-score' style='color: {acc_color};'>{acc_text}</p>
1281
+ {acc_diff_html}
1282
+ </div>
1283
+ <div class='kpi-metric-box'>
1284
+ <p class='kpi-label'>Your Rank</p>
1285
+ <p class='kpi-score' style='color: {rank_color};'>{rank_text}</p>
1286
+ {rank_diff_html}
1287
+ </div>
1288
+ </div>
1289
+ </div>
1290
+ """
1291
+
1292
+ def _build_team_html(team_summary_df, team_name):
1293
+ """
1294
+ Generates the HTML for the team leaderboard.
1295
+
1296
+ Uses normalized, case-insensitive comparison to highlight the user's team row,
1297
+ ensuring reliable highlighting even with whitespace or casing variations.
1298
+ """
1299
+ if team_summary_df is None or team_summary_df.empty:
1300
+ return "<p style='text-align:center; color:#6b7280; padding-top:20px;'>No team submissions yet.</p>"
1301
+
1302
+ # Normalize the current user's team name for comparison
1303
+ normalized_user_team = _normalize_team_name(team_name).lower()
1304
+
1305
+ header = """
1306
+ <table class='leaderboard-html-table'>
1307
+ <thead>
1308
+ <tr>
1309
+ <th>Rank</th>
1310
+ <th>Team</th>
1311
+ <th>Best_Score</th>
1312
+ <th>Avg_Score</th>
1313
+ <th>Submissions</th>
1314
+ </tr>
1315
+ </thead>
1316
+ <tbody>
1317
+ """
1318
+
1319
+ body = ""
1320
+ for index, row in team_summary_df.iterrows():
1321
+ # Normalize the row's team name and compare case-insensitively
1322
+ normalized_row_team = _normalize_team_name(row["Team"]).lower()
1323
+ is_user_team = normalized_row_team == normalized_user_team
1324
+ row_class = "class='user-row-highlight'" if is_user_team else ""
1325
+ body += f"""
1326
+ <tr {row_class}>
1327
+ <td>{index}</td>
1328
+ <td>{row['Team']}</td>
1329
+ <td>{(row['Best_Score'] * 100):.2f}%</td>
1330
+ <td>{(row['Avg_Score'] * 100):.2f}%</td>
1331
+ <td>{row['Submissions']}</td>
1332
+ </tr>
1333
+ """
1334
+
1335
+ footer = "</tbody></table>"
1336
+ return header + body + footer
1337
+
1338
+ def _build_individual_html(individual_summary_df, username):
1339
+ """Generates the HTML for the individual leaderboard."""
1340
+ if individual_summary_df is None or individual_summary_df.empty:
1341
+ return "<p style='text-align:center; color:#6b7280; padding-top:20px;'>No individual submissions yet.</p>"
1342
+
1343
+ header = """
1344
+ <table class='leaderboard-html-table'>
1345
+ <thead>
1346
+ <tr>
1347
+ <th>Rank</th>
1348
+ <th>Engineer</th>
1349
+ <th>Best_Score</th>
1350
+ <th>Submissions</th>
1351
+ </tr>
1352
+ </thead>
1353
+ <tbody>
1354
+ """
1355
+
1356
+ body = ""
1357
+ for index, row in individual_summary_df.iterrows():
1358
+ is_user = row["Engineer"] == username
1359
+ row_class = "class='user-row-highlight'" if is_user else ""
1360
+ body += f"""
1361
+ <tr {row_class}>
1362
+ <td>{index}</td>
1363
+ <td>{row['Engineer']}</td>
1364
+ <td>{(row['Best_Score'] * 100):.2f}%</td>
1365
+ <td>{row['Submissions']}</td>
1366
+ </tr>
1367
+ """
1368
+
1369
+ footer = "</tbody></table>"
1370
+ return header + body + footer
1371
+
1372
+
1373
+
1374
+
1375
+ # --- End Helper Functions ---
1376
+
1377
+
1378
+ def generate_competitive_summary(leaderboard_df, team_name, username, last_submission_score, last_rank, submission_count):
1379
+ """
1380
+ Build summaries, HTML, and KPI card.
1381
+
1382
+ Concurrency Note: Uses the team_name parameter directly for team highlighting,
1383
+ NOT os.environ, to prevent cross-user data leakage under concurrent requests.
1384
+
1385
+ Returns (team_html, individual_html, kpi_card_html, new_best_accuracy, new_rank, this_submission_score).
1386
+ """
1387
+ team_summary_df = pd.DataFrame(columns=["Team", "Best_Score", "Avg_Score", "Submissions"])
1388
+ individual_summary_df = pd.DataFrame(columns=["Engineer", "Best_Score", "Submissions"])
1389
+
1390
+ if leaderboard_df is None or leaderboard_df.empty or "accuracy" not in leaderboard_df.columns:
1391
+ return (
1392
+ "<p style='text-align:center; color:#6b7280; padding-top:20px;'>Leaderboard empty.</p>",
1393
+ "<p style='text-align:center; color:#6b7280; padding-top:20px;'>Leaderboard empty.</p>",
1394
+ _build_kpi_card_html(0, 0, 0, 0, 0, is_preview=False, is_pending=False, local_test_accuracy=None),
1395
+ 0.0, 0, 0.0
1396
+ )
1397
+
1398
+ # Team summary
1399
+ if "Team" in leaderboard_df.columns:
1400
+ team_summary_df = (
1401
+ leaderboard_df.groupby("Team")["accuracy"]
1402
+ .agg(Best_Score="max", Avg_Score="mean", Submissions="count")
1403
+ .reset_index()
1404
+ .sort_values("Best_Score", ascending=False)
1405
+ .reset_index(drop=True)
1406
+ )
1407
+ team_summary_df.index = team_summary_df.index + 1
1408
+
1409
+ # Individual summary
1410
+ user_bests = leaderboard_df.groupby("username")["accuracy"].max()
1411
+ user_counts = leaderboard_df.groupby("username")["accuracy"].count()
1412
+ individual_summary_df = pd.DataFrame(
1413
+ {"Engineer": user_bests.index, "Best_Score": user_bests.values, "Submissions": user_counts.values}
1414
+ ).sort_values("Best_Score", ascending=False).reset_index(drop=True)
1415
+ individual_summary_df.index = individual_summary_df.index + 1
1416
+
1417
+ # Get stats for KPI card
1418
+ new_rank = 0
1419
+ new_best_accuracy = 0.0
1420
+ this_submission_score = 0.0
1421
+
1422
+ try:
1423
+ # All submissions for this user
1424
+ user_rows = leaderboard_df[leaderboard_df["username"] == username].copy()
1425
+
1426
+ if not user_rows.empty:
1427
+ # Attempt robust timestamp parsing
1428
+ if "timestamp" in user_rows.columns:
1429
+ parsed_ts = pd.to_datetime(user_rows["timestamp"], errors="coerce")
1430
+
1431
+ if parsed_ts.notna().any():
1432
+ # At least one valid timestamp → use parsed ordering
1433
+ user_rows["__parsed_ts"] = parsed_ts
1434
+ user_rows = user_rows.sort_values("__parsed_ts", ascending=False)
1435
+ this_submission_score = float(user_rows.iloc[0]["accuracy"])
1436
+ else:
1437
+ # All timestamps invalid → assume append order, take last as "latest"
1438
+ this_submission_score = float(user_rows.iloc[-1]["accuracy"])
1439
+ else:
1440
+ # No timestamp column → fallback to last row
1441
+ this_submission_score = float(user_rows.iloc[-1]["accuracy"])
1442
+
1443
+ # Rank & best accuracy (unchanged logic, but make sure we use the same best row)
1444
+ my_rank_row = None
1445
+ # Build individual summary before this block (already done above)
1446
+ my_rank_row = individual_summary_df[individual_summary_df["Engineer"] == username]
1447
+ if not my_rank_row.empty:
1448
+ new_rank = my_rank_row.index[0]
1449
+ new_best_accuracy = float(my_rank_row["Best_Score"].iloc[0])
1450
+
1451
+ except Exception as e:
1452
+ _log(f"Latest submission score extraction failed: {e}")
1453
+
1454
+ # Generate HTML outputs
1455
+ # Concurrency Note: Use team_name parameter directly, not os.environ
1456
+ team_html = _build_team_html(team_summary_df, team_name)
1457
+ individual_html = _build_individual_html(individual_summary_df, username)
1458
+ kpi_card_html = _build_kpi_card_html(
1459
+ this_submission_score, last_submission_score, new_rank, last_rank, submission_count,
1460
+ is_preview=False, is_pending=False, local_test_accuracy=None
1461
+ )
1462
+
1463
+ return team_html, individual_html, kpi_card_html, new_best_accuracy, new_rank, this_submission_score
1464
+
1465
+
1466
+ def get_model_card(model_name):
1467
+ return MODEL_TYPES.get(model_name, {}).get("card", "No description available.")
1468
+
1469
+ def compute_rank_settings(
1470
+ submission_count,
1471
+ current_model,
1472
+ current_complexity,
1473
+ current_feature_set,
1474
+ current_data_size
1475
+ ):
1476
+ """Returns rank gating settings (updated for 1–10 complexity scale)."""
1477
+
1478
+
1479
+ # Always allow all options
1480
+ return {
1481
+ "rank_message": "# 👑 Rank: Lead Engineer\n<p style='font-size:24px; line-height:1.4;'>All tools unlocked — optimize freely!</p>",
1482
+ "model_choices": list(MODEL_TYPES.keys()),
1483
+ "model_value": current_model if current_model in MODEL_TYPES else "The Balanced Generalist",
1484
+ "model_interactive": True,
1485
+ "complexity_max": 10,
1486
+ "complexity_value": current_complexity,
1487
+ "feature_set_choices": FEATURE_SET_ALL_OPTIONS,
1488
+ "feature_set_value": current_feature_set,
1489
+ "feature_set_interactive": True,
1490
+ "data_size_choices": ["Small (20%)", "Medium (60%)", "Large (80%)", "Full (100%)"],
1491
+ "data_size_value": current_data_size if current_data_size in DATA_SIZE_MAP else "Small (20%)",
1492
+ "data_size_interactive": True,
1493
+ }
1494
+
1495
+
1496
+ # Find components by name to yield updates
1497
+ # --- Existing global component placeholders ---
1498
+ submit_button = None
1499
+ submission_feedback_display = None
1500
+ team_leaderboard_display = None
1501
+ individual_leaderboard_display = None
1502
+ last_submission_score_state = None
1503
+ last_rank_state = None
1504
+ best_score_state = None
1505
+ submission_count_state = None
1506
+ rank_message_display = None
1507
+ model_type_radio = None
1508
+ complexity_slider = None
1509
+ feature_set_checkbox = None
1510
+ data_size_radio = None
1511
+ attempts_tracker_display = None
1512
+ team_name_state = None
1513
+ # Login components
1514
+ login_username = None
1515
+ login_password = None
1516
+ login_submit = None
1517
+ login_error = None
1518
+ # Add missing placeholders for auth states (FIX)
1519
+ username_state = None
1520
+ token_state = None
1521
+ first_submission_score_state = None # (already commented as "will be assigned globally")
1522
+ # Add state placeholders for readiness gating and preview tracking
1523
+ readiness_state = None
1524
+ was_preview_state = None
1525
+ kpi_meta_state = None
1526
+ last_seen_ts_state = None # Track last seen user timestamp from leaderboard
1527
+
1528
+
1529
+ def get_or_assign_team(username, token=None):
1530
+ """
1531
+ Get the existing team for a user from the leaderboard, or assign a new random team.
1532
+
1533
+ Queries the playground leaderboard to check if the user has prior submissions with
1534
+ a team assignment. If found, returns that team (most recent if multiple submissions).
1535
+ Otherwise assigns a random team. All team names are normalized for consistency.
1536
+
1537
+ Args:
1538
+ username: str, the username to check for existing team
1539
+ token: str, optional authentication token for leaderboard fetch
1540
+
1541
+ Returns:
1542
+ tuple: (team_name: str, is_new: bool)
1543
+ - team_name: The normalized team name (existing or newly assigned)
1544
+ - is_new: True if newly assigned, False if existing team recovered
1545
+ """
1546
+ try:
1547
+ # Query the leaderboard
1548
+ if playground is None:
1549
+ # Fallback to random assignment if playground not available
1550
+ print("Playground not available, assigning random team")
1551
+ new_team = _normalize_team_name(random.choice(TEAM_NAMES))
1552
+ return new_team, True
1553
+
1554
+ # Use centralized helper for authenticated leaderboard fetch
1555
+ leaderboard_df = _get_leaderboard_with_optional_token(playground, token)
1556
+
1557
+ # Check if leaderboard has data and Team column
1558
+ if leaderboard_df is not None and not leaderboard_df.empty and "Team" in leaderboard_df.columns:
1559
+ # Filter for this user's submissions
1560
+ user_submissions = leaderboard_df[leaderboard_df["username"] == username]
1561
+
1562
+ if not user_submissions.empty:
1563
+ # Sort by timestamp (most recent first) if timestamp column exists
1564
+ # Use contextlib.suppress for resilient timestamp parsing
1565
+ if "timestamp" in user_submissions.columns:
1566
+ try:
1567
+ # Attempt to coerce timestamp column to datetime and sort descending
1568
+ user_submissions = user_submissions.copy()
1569
+ user_submissions["timestamp"] = pd.to_datetime(user_submissions["timestamp"], errors='coerce')
1570
+ user_submissions = user_submissions.sort_values("timestamp", ascending=False)
1571
+ print(f"Sorted {len(user_submissions)} submissions by timestamp for {username}")
1572
+ except Exception as ts_error:
1573
+ # If timestamp parsing fails, continue with unsorted DataFrame
1574
+ print(f"Warning: Could not sort by timestamp for {username}: {ts_error}")
1575
+
1576
+ # Get the most recent team assignment (first row after sorting)
1577
+ existing_team = user_submissions.iloc[0]["Team"]
1578
+
1579
+ # Check if team value is valid (not null/empty)
1580
+ if pd.notna(existing_team) and existing_team and str(existing_team).strip():
1581
+ normalized_team = _normalize_team_name(existing_team)
1582
+ print(f"Found existing team for {username}: {normalized_team}")
1583
+ return normalized_team, False
1584
+
1585
+ # No existing team found - assign random
1586
+ new_team = _normalize_team_name(random.choice(TEAM_NAMES))
1587
+ print(f"Assigning new team to {username}: {new_team}")
1588
+ return new_team, True
1589
+
1590
+ except Exception as e:
1591
+ # On any error, fall back to random assignment
1592
+ print(f"Error checking leaderboard for team: {e}")
1593
+ new_team = _normalize_team_name(random.choice(TEAM_NAMES))
1594
+ print(f"Fallback: assigning random team to {username}: {new_team}")
1595
+ return new_team, True
1596
+
1597
+ def perform_inline_login(username_input, password_input):
1598
+ """
1599
+ Perform inline authentication and return credentials via gr.State updates.
1600
+
1601
+ Concurrency Note: This function NO LONGER stores per-user credentials in
1602
+ os.environ to prevent cross-user data leakage. Authentication state is
1603
+ returned exclusively via gr.State updates (username_state, token_state,
1604
+ team_name_state). Password is never stored server-side.
1605
+
1606
+ Args:
1607
+ username_input: str, the username entered by user
1608
+ password_input: str, the password entered by user
1609
+
1610
+ Returns:
1611
+ dict: Gradio component updates for login UI elements and submit button
1612
+ - On success: hides login form, shows success message, enables submit
1613
+ - On failure: keeps login form visible, shows error with signup link
1614
+ """
1615
+ from aimodelshare.aws import get_aws_token
1616
+
1617
+ # Validate inputs
1618
+ if not username_input or not username_input.strip():
1619
+ error_html = """
1620
+ <div style='background:#fef2f2; padding:12px; border-radius:8px; border-left:4px solid #ef4444; margin-top:12px;'>
1621
+ <p style='margin:0; color:#991b1b; font-weight:500;'>⚠️ Username is required</p>
1622
+ </div>
1623
+ """
1624
+ return {
1625
+ login_username: gr.update(),
1626
+ login_password: gr.update(),
1627
+ login_submit: gr.update(),
1628
+ login_error: gr.update(value=error_html, visible=True),
1629
+ submit_button: gr.update(),
1630
+ submission_feedback_display: gr.update(),
1631
+ team_name_state: gr.update(),
1632
+ username_state: gr.update(),
1633
+ token_state: gr.update()
1634
+ }
1635
+
1636
+ if not password_input or not password_input.strip():
1637
+ error_html = """
1638
+ <div style='background:#fef2f2; padding:12px; border-radius:8px; border-left:4px solid #ef4444; margin-top:12px;'>
1639
+ <p style='margin:0; color:#991b1b; font-weight:500;'>⚠️ Password is required</p>
1640
+ </div>
1641
+ """
1642
+ return {
1643
+ login_username: gr.update(),
1644
+ login_password: gr.update(),
1645
+ login_submit: gr.update(),
1646
+ login_error: gr.update(value=error_html, visible=True),
1647
+ submit_button: gr.update(),
1648
+ submission_feedback_display: gr.update(),
1649
+ team_name_state: gr.update(),
1650
+ username_state: gr.update(),
1651
+ token_state: gr.update()
1652
+ }
1653
+
1654
+ # Concurrency Note: get_aws_token() reads credentials from os.environ, which creates
1655
+ # a race condition in multi-threaded environments. We use _auth_lock to serialize
1656
+ # credential injection, preventing concurrent requests from seeing each other's
1657
+ # credentials. The password is immediately cleared after the auth attempt.
1658
+ #
1659
+ # FUTURE: Ideally get_aws_token() would be refactored to accept credentials as
1660
+ # parameters instead of reading from os.environ. This lock is a workaround.
1661
+ username_clean = username_input.strip()
1662
+
1663
+ # Attempt to get AWS token with serialized credential injection
1664
+ try:
1665
+ with _auth_lock:
1666
+ os.environ["username"] = username_clean
1667
+ os.environ["password"] = password_input.strip() # Only for get_aws_token() call
1668
+ try:
1669
+ token = get_aws_token()
1670
+ finally:
1671
+ # SECURITY: Always clear credentials from environment, even on exception
1672
+ # Also clear stale env vars from previous implementations within the lock
1673
+ # to prevent any race conditions during cleanup
1674
+ os.environ.pop("password", None)
1675
+ os.environ.pop("username", None)
1676
+ os.environ.pop("AWS_TOKEN", None)
1677
+ os.environ.pop("TEAM_NAME", None)
1678
+
1679
+ # Get or assign team for this user with explicit token (already normalized by get_or_assign_team)
1680
+ team_name, is_new_team = get_or_assign_team(username_clean, token=token)
1681
+ # Normalize team name before storing (defensive - already normalized by get_or_assign_team)
1682
+ team_name = _normalize_team_name(team_name)
1683
+
1684
+ # Build success message based on whether team is new or existing
1685
+ if is_new_team:
1686
+ team_message = f"You have been assigned to a new team: <b>{team_name}</b> 🎉"
1687
+ else:
1688
+ team_message = f"Welcome back! You remain on team: <b>{team_name}</b> ✅"
1689
+
1690
+ # Success: hide login form, show success message with team info, enable submit button
1691
+ success_html = f"""
1692
+ <div style='background:#f0fdf4; padding:16px; border-radius:8px; border-left:4px solid #16a34a; margin-top:12px;'>
1693
+ <p style='margin:0; color:#15803d; font-weight:600; font-size:1.1rem;'>✓ Signed in successfully!</p>
1694
+ <p style='margin:8px 0 0 0; color:#166534; font-size:0.95rem;'>
1695
+ {team_message}
1696
+ </p>
1697
+ <p style='margin:8px 0 0 0; color:#166534; font-size:0.95rem;'>
1698
+ Click "Build & Submit Model" again to publish your score.
1699
+ </p>
1700
+ </div>
1701
+ """
1702
+ return {
1703
+ login_username: gr.update(visible=False),
1704
+ login_password: gr.update(visible=False),
1705
+ login_submit: gr.update(visible=False),
1706
+ login_error: gr.update(value=success_html, visible=True),
1707
+ submit_button: gr.update(value="🔬 Build & Submit Model", interactive=True),
1708
+ submission_feedback_display: gr.update(visible=False),
1709
+ team_name_state: gr.update(value=team_name),
1710
+ username_state: gr.update(value=username_clean),
1711
+ token_state: gr.update(value=token)
1712
+ }
1713
+
1714
+ except Exception as e:
1715
+ # Note: Credentials are already cleaned up by the finally block in the try above.
1716
+ # The lock ensures no race condition during cleanup.
1717
+
1718
+ # Authentication failed: show error with signup link
1719
+ error_html = f"""
1720
+ <div style='background:#fef2f2; padding:16px; border-radius:8px; border-left:4px solid #ef4444; margin-top:12px;'>
1721
+ <p style='margin:0; color:#991b1b; font-weight:600; font-size:1.1rem;'>⚠️ Authentication failed</p>
1722
+ <p style='margin:8px 0; color:#7f1d1d; font-size:0.95rem;'>
1723
+ Could not verify your credentials. Please check your username and password.
1724
+ </p>
1725
+ <p style='margin:8px 0 0 0; color:#7f1d1d; font-size:0.95rem;'>
1726
+ <strong>New user?</strong> Create a free account at
1727
+ <a href='https://www.modelshare.ai/login' target='_blank'
1728
+ style='color:#dc2626; text-decoration:underline;'>modelshare.ai/login</a>
1729
+ </p>
1730
+ <details style='margin-top:12px; font-size:0.85rem; color:#7f1d1d;'>
1731
+ <summary style='cursor:pointer;'>Technical details</summary>
1732
+ <pre style='margin-top:8px; padding:8px; background:#fee; border-radius:4px; overflow-x:auto;'>{str(e)}</pre>
1733
+ </details>
1734
+ </div>
1735
+ """
1736
+ return {
1737
+ login_username: gr.update(visible=True),
1738
+ login_password: gr.update(visible=True),
1739
+ login_submit: gr.update(visible=True),
1740
+ login_error: gr.update(value=error_html, visible=True),
1741
+ submit_button: gr.update(),
1742
+ submission_feedback_display: gr.update(),
1743
+ team_name_state: gr.update(),
1744
+ username_state: gr.update(),
1745
+ token_state: gr.update()
1746
+ }
1747
+
1748
+ def run_experiment(
1749
+ model_name_key,
1750
+ complexity_level,
1751
+ feature_set,
1752
+ data_size_str,
1753
+ team_name,
1754
+ last_submission_score,
1755
+ last_rank,
1756
+ submission_count,
1757
+ first_submission_score,
1758
+ best_score,
1759
+ username=None,
1760
+ token=None,
1761
+ readiness_flag=None,
1762
+ was_preview_prev=None,
1763
+ progress=gr.Progress()
1764
+ ):
1765
+ """
1766
+ Core experiment: Uses 'yield' for visual updates and progress bar.
1767
+ Updated with "Look-Before-You-Leap" caching strategy.
1768
+ """
1769
+ # --- COLLISION GUARDS ---
1770
+ # Log types of potentially shadowed names to ensure they refer to component objects, not dicts
1771
+ _log(f"DEBUG guard: types — submit_button={type(submit_button)} submission_feedback_display={type(submission_feedback_display)} kpi_meta_state={type(kpi_meta_state)} was_preview_state={type(was_preview_state)} readiness_flag_param={type(readiness_flag)}")
1772
+
1773
+ # If any of the component names are found as dicts (indicating parameter shadowing), short-circuit
1774
+ if isinstance(submit_button, dict) or isinstance(submission_feedback_display, dict) or isinstance(kpi_meta_state, dict) or isinstance(was_preview_state, dict):
1775
+ error_html = """
1776
+ <div class='kpi-card' style='border-color: #ef4444;'>
1777
+ <h2 style='color: #111827; margin-top:0;'>⚠️ Configuration Error</h2>
1778
+ <div class='kpi-card-body'>
1779
+ <p style='color: #991b1b;'>Parameter shadowing detected. Global component variables were shadowed by local parameters.</p>
1780
+ <p style='color: #7f1d1d; margin-top: 8px;'>Please refresh the page and try again. If the issue persists, contact support.</p>
1781
+ </div>
1782
+ </div>
1783
+ """
1784
+ yield {
1785
+ submission_feedback_display: gr.update(value=error_html, visible=True),
1786
+ submit_button: gr.update(value="🔬 Build & Submit Model", interactive=True)
1787
+ }
1788
+ return
1789
+
1790
+ # Sanitize feature_set: convert dicts/tuples to their string values
1791
+ sanitized_feature_set = []
1792
+ for feat in (feature_set or []):
1793
+ if isinstance(feat, dict):
1794
+ # Extract 'value' key if present, otherwise use string representation
1795
+ sanitized_feature_set.append(feat.get("value", str(feat)))
1796
+ elif isinstance(feat, tuple):
1797
+ # For tuples like ("Label", "value"), take the second element
1798
+ sanitized_feature_set.append(feat[1] if len(feat) > 1 else str(feat))
1799
+ else:
1800
+ # Already a string
1801
+ sanitized_feature_set.append(str(feat))
1802
+ feature_set = sanitized_feature_set
1803
+
1804
+ # Use readiness_flag parameter if provided, otherwise check readiness
1805
+ if readiness_flag is not None:
1806
+ ready = readiness_flag
1807
+ else:
1808
+ ready = _is_ready()
1809
+ _log(f"run_experiment: ready={ready}, username={username}, token_present={token is not None}")
1810
+
1811
+ # Add debug log (optional)
1812
+ _log(f"run_experiment received username={username} token_present={token is not None}")
1813
+ # Concurrency Note: Use provided parameters exclusively, not os.environ.
1814
+ # Default to "Unknown_User" only if no username provided via state.
1815
+ if not username:
1816
+ username = "Unknown_User"
1817
+
1818
+ # Helper to generate the animated HTML
1819
+ def get_status_html(step_num, title, subtitle):
1820
+ return f"""
1821
+ <div class='processing-status'>
1822
+ <span class='processing-icon'>⚙️</span>
1823
+ <div class='processing-text'>Step {step_num}/5: {title}</div>
1824
+ <div class='processing-subtext'>{subtitle}</div>
1825
+ </div>
1826
+ """
1827
+
1828
+ # --- Stage 1: Lock UI and give initial feedback ---
1829
+ progress(0.1, desc="Starting Experiment...")
1830
+ initial_updates = {
1831
+ submit_button: gr.update(value="⏳ Experiment Running...", interactive=False),
1832
+ submission_feedback_display: gr.update(value=get_status_html(1, "Initializing", "Preparing your data ingredients..."), visible=True), # Make sure it's visible
1833
+ login_error: gr.update(visible=False), # Hide login success/error message
1834
+ attempts_tracker_display: gr.update(value=_build_attempts_tracker_html(submission_count))
1835
+ }
1836
+ yield initial_updates
1837
+
1838
+ if not model_name_key or model_name_key not in MODEL_TYPES:
1839
+ model_name_key = DEFAULT_MODEL
1840
+ complexity_level = safe_int(complexity_level, 2)
1841
+
1842
+ log_output = f"▶ New Experiment\nModel: {model_name_key}\n..."
1843
+
1844
+ # Check readiness
1845
+ # If playground is None or not ready, fallback error
1846
+ if playground is None or not ready:
1847
+ settings = compute_rank_settings(
1848
+ submission_count, model_name_key, complexity_level, feature_set, data_size_str
1849
+ )
1850
+
1851
+ error_msg = "<p style='text-align:center; color:red; padding:20px 0;'>"
1852
+ if playground is None:
1853
+ error_msg += "Playground not connected. Please try again later."
1854
+ else:
1855
+ error_msg += "Data still initializing. Please wait a moment and try again."
1856
+ error_msg += "</p>"
1857
+
1858
+ error_kpi_meta = {
1859
+ "was_preview": False, "preview_score": None, "ready_at_run_start": False,
1860
+ "poll_iterations": 0, "local_test_accuracy": None, "this_submission_score": None,
1861
+ "new_best_accuracy": None, "rank": None
1862
+ }
1863
+
1864
+ error_updates = {
1865
+ submission_feedback_display: gr.update(value=error_msg, visible=True),
1866
+ submit_button: gr.update(value="🔬 Build & Submit Model", interactive=True),
1867
+ team_leaderboard_display: _build_skeleton_leaderboard(rows=6, is_team=True),
1868
+ individual_leaderboard_display: _build_skeleton_leaderboard(rows=6, is_team=False),
1869
+ last_submission_score_state: last_submission_score,
1870
+ last_rank_state: last_rank,
1871
+ best_score_state: best_score,
1872
+ submission_count_state: submission_count,
1873
+ first_submission_score_state: first_submission_score,
1874
+ rank_message_display: settings["rank_message"],
1875
+ model_type_radio: gr.update(choices=settings["model_choices"], value=settings["model_value"], interactive=settings["model_interactive"]),
1876
+ complexity_slider: gr.update(minimum=1, maximum=settings["complexity_max"], value=settings["complexity_value"]),
1877
+ feature_set_checkbox: gr.update(choices=settings["feature_set_choices"], value=settings["feature_set_value"], interactive=settings["feature_set_interactive"]),
1878
+ data_size_radio: gr.update(choices=settings["data_size_choices"], value=settings["data_size_value"], interactive=settings["data_size_interactive"]),
1879
+ login_username: gr.update(visible=False),
1880
+ login_password: gr.update(visible=False),
1881
+ login_submit: gr.update(visible=False),
1882
+ login_error: gr.update(visible=False),
1883
+ attempts_tracker_display: gr.update(value=_build_attempts_tracker_html(submission_count)),
1884
+ was_preview_state: False,
1885
+ kpi_meta_state: error_kpi_meta,
1886
+ last_seen_ts_state: None
1887
+ }
1888
+ yield error_updates
1889
+ return
1890
+
1891
+ try:
1892
+ # --- Stage 2: Smart Build (Cache vs Train) ---
1893
+ progress(0.3, desc="Building Model...")
1894
+
1895
+ # 1. Generate Cache Key (Matches format in precompute_cache.py)
1896
+ # Key: "ModelName|Complexity|DataSize|SortedFeatures"
1897
+ sanitized_features = sorted([str(f) for f in feature_set])
1898
+ feature_key = ",".join(sanitized_features)
1899
+ cache_key = f"{model_name_key}|{complexity_level}|{data_size_str}|{feature_key}"
1900
+
1901
+ # 2. Check Cache
1902
+ cached_predictions = get_cached_prediction(cache_key)
1903
+
1904
+ # Initialize submission variables
1905
+ predictions = None
1906
+ tuned_model = None
1907
+ preprocessor = None
1908
+
1909
+ if cached_predictions:
1910
+ # === FAST PATH (Zero CPU) ===
1911
+ _log(f"⚡ CACHE HIT: {cache_key}")
1912
+ yield {
1913
+ submission_feedback_display: gr.update(value=get_status_html(2, "Training Model", "⚡ The machine is learning from history..."), visible=True),
1914
+ login_error: gr.update(visible=False)
1915
+ }
1916
+
1917
+ # --- DECOMPRESSION STEP (Vital) ---
1918
+ # If string "01010...", convert to [0, 1, 0, 1...]
1919
+ if isinstance(cached_predictions, str):
1920
+ predictions = [int(c) for c in cached_predictions]
1921
+ else:
1922
+ predictions = cached_predictions
1923
+
1924
+ # Pass None to submit_model to skip training overhead validation
1925
+ tuned_model = None
1926
+ preprocessor = None
1927
+
1928
+
1929
+ else:
1930
+ # === SLOW PATH (Fallback Training) ===
1931
+ _log(f"🐢 CACHE MISS: {cache_key} (Training for real...)")
1932
+ yield {
1933
+ submission_feedback_display: gr.update(value=get_status_html(2, "Training Model", "The machine is learning from history..."), visible=True),
1934
+ login_error: gr.update(visible=False)
1935
+ }
1936
+
1937
+ # A. Get pre-sampled data
1938
+ sample_frac = DATA_SIZE_MAP.get(data_size_str, 0.2)
1939
+ X_train_sampled = X_TRAIN_SAMPLES_MAP[data_size_str]
1940
+ y_train_sampled = Y_TRAIN_SAMPLES_MAP[data_size_str]
1941
+ log_output += f"Using {int(sample_frac * 100)}% data.\n"
1942
+
1943
+ # B. Determine features...
1944
+ numeric_cols = [f for f in feature_set if f in ALL_NUMERIC_COLS]
1945
+ categorical_cols = [f for f in feature_set if f in ALL_CATEGORICAL_COLS]
1946
+ for feat in feature_set:
1947
+ if feat in ALL_NUMERIC_COLS: numeric_cols.append(feat)
1948
+ elif feat in ALL_CATEGORICAL_COLS: categorical_cols.append(feat)
1949
+
1950
+ # De-dupe logic just in case (though loop above covers it, ensuring lists are clean)
1951
+ numeric_cols = sorted(list(set([f for f in feature_set if f in ALL_NUMERIC_COLS])))
1952
+ categorical_cols = sorted(list(set([f for f in feature_set if f in ALL_CATEGORICAL_COLS])))
1953
+
1954
+ if not numeric_cols and not categorical_cols:
1955
+ raise ValueError("No features selected for modeling.")
1956
+
1957
+ # C. Preprocessing (uses memoized preprocessor builder)
1958
+ preprocessor, selected_cols = build_preprocessor(numeric_cols, categorical_cols)
1959
+
1960
+ X_train_processed = preprocessor.fit_transform(X_train_sampled[selected_cols])
1961
+ X_test_processed = preprocessor.transform(X_TEST_RAW[selected_cols])
1962
+
1963
+ # D. Model build & tune
1964
+ base_model = MODEL_TYPES[model_name_key]["model_builder"]()
1965
+ tuned_model = tune_model_complexity(base_model, complexity_level)
1966
+
1967
+ # E. Train
1968
+ # Concurrency Note: DecisionTree and RandomForest require dense arrays.
1969
+ if isinstance(tuned_model, (DecisionTreeClassifier, RandomForestClassifier)):
1970
+ X_train_for_fit = _ensure_dense(X_train_processed)
1971
+ X_test_for_predict = _ensure_dense(X_test_processed)
1972
+ else:
1973
+ X_train_for_fit = X_train_processed
1974
+ X_test_for_predict = X_test_processed
1975
+
1976
+ tuned_model.fit(X_train_for_fit, y_train_sampled)
1977
+ log_output += "Training done.\n"
1978
+
1979
+ # F. Predict
1980
+ predictions = tuned_model.predict(X_test_for_predict)
1981
+
1982
+ # --- Stage 3: Submit (API Call 1) ---
1983
+ # AUTHENTICATION GATE: Check for token before submission
1984
+ if token is None:
1985
+ # User not authenticated - compute preview score and show login prompt
1986
+ progress(0.6, desc="Computing Preview Score...")
1987
+
1988
+ # We need to calculate accuracy for the preview card
1989
+ from sklearn.metrics import accuracy_score
1990
+ # Ensure predictions are in correct format (list or array)
1991
+ if isinstance(predictions, list):
1992
+ # Cached predictions are lists
1993
+ preds_array = np.array(predictions)
1994
+ else:
1995
+ preds_array = predictions
1996
+
1997
+ preview_score = accuracy_score(Y_TEST, preds_array)
1998
+
1999
+ preview_kpi_meta = {
2000
+ "was_preview": True, "preview_score": preview_score, "ready_at_run_start": ready,
2001
+ "poll_iterations": 0, "local_test_accuracy": preview_score,
2002
+ "this_submission_score": None, "new_best_accuracy": None, "rank": None
2003
+ }
2004
+
2005
+ # 1. Generate the styled preview card
2006
+ preview_card_html = _build_kpi_card_html(
2007
+ new_score=preview_score, last_score=0, new_rank=0, last_rank=0,
2008
+ submission_count=-1, is_preview=True, is_pending=False, local_test_accuracy=None
2009
+ )
2010
+
2011
+ # 2. Inject login text
2012
+ login_prompt_text_html = build_login_prompt_html()
2013
+ closing_div_index = preview_card_html.rfind("</div>")
2014
+ if closing_div_index != -1:
2015
+ combined_html = preview_card_html[:closing_div_index] + login_prompt_text_html + "</div>"
2016
+ else:
2017
+ combined_html = preview_card_html + login_prompt_text_html
2018
+
2019
+ settings = compute_rank_settings(submission_count, model_name_key, complexity_level, feature_set, data_size_str)
2020
+
2021
+ gate_updates = {
2022
+ submission_feedback_display: gr.update(value=combined_html, visible=True),
2023
+ submit_button: gr.update(value="Sign In Required", interactive=False),
2024
+ login_username: gr.update(visible=True), login_password: gr.update(visible=True),
2025
+ login_submit: gr.update(visible=True), login_error: gr.update(value="", visible=False),
2026
+ team_leaderboard_display: _build_skeleton_leaderboard(rows=6, is_team=True),
2027
+ individual_leaderboard_display: _build_skeleton_leaderboard(rows=6, is_team=False),
2028
+ last_submission_score_state: last_submission_score, last_rank_state: last_rank,
2029
+ best_score_state: best_score, submission_count_state: submission_count,
2030
+ first_submission_score_state: first_submission_score,
2031
+ rank_message_display: settings["rank_message"],
2032
+ model_type_radio: gr.update(choices=settings["model_choices"], value=settings["model_value"], interactive=settings["model_interactive"]),
2033
+ complexity_slider: gr.update(minimum=1, maximum=settings["complexity_max"], value=settings["complexity_value"]),
2034
+ feature_set_checkbox: gr.update(choices=settings["feature_set_choices"], value=settings["feature_set_value"], interactive=settings["feature_set_interactive"]),
2035
+ data_size_radio: gr.update(choices=settings["data_size_choices"], value=settings["data_size_value"], interactive=settings["data_size_interactive"]),
2036
+ attempts_tracker_display: gr.update(value=_build_attempts_tracker_html(submission_count)),
2037
+ was_preview_state: True, kpi_meta_state: preview_kpi_meta, last_seen_ts_state: None
2038
+ }
2039
+ yield gate_updates
2040
+ return # Stop here
2041
+
2042
+ # --- ATTEMPT LIMIT CHECK ---
2043
+ if submission_count >= ATTEMPT_LIMIT:
2044
+ limit_warning_html = f"""
2045
+ <div class='kpi-card' style='border-color: #ef4444;'>
2046
+ <h2 style='color: #111827; margin-top:0;'>🛑 Submission Limit Reached</h2>
2047
+ <div class='kpi-card-body'>
2048
+ <div class='kpi-metric-box'>
2049
+ <p class='kpi-label'>Attempts Used</p>
2050
+ <p class='kpi-score' style='color: #ef4444;'>{ATTEMPT_LIMIT} / {ATTEMPT_LIMIT}</p>
2051
+ </div>
2052
+ </div>
2053
+ <div style='margin-top: 16px; background:#fef2f2; padding:16px; border-radius:12px; text-align:left; font-size:0.98rem; line-height:1.4;'>
2054
+ <p style='margin:0; color:#991b1b;'><b>Nice Work!</b> Scroll down to "Finish and Reflect".</p>
2055
+ </div>
2056
+ </div>"""
2057
+ settings = compute_rank_settings(submission_count, model_name_key, complexity_level, feature_set, data_size_str)
2058
+ limit_reached_updates = {
2059
+ submission_feedback_display: gr.update(value=limit_warning_html, visible=True),
2060
+ submit_button: gr.update(value="🛑 Submission Limit Reached", interactive=False),
2061
+ model_type_radio: gr.update(interactive=False), complexity_slider: gr.update(interactive=False),
2062
+ feature_set_checkbox: gr.update(interactive=False), data_size_radio: gr.update(interactive=False),
2063
+ attempts_tracker_display: gr.update(value=f"<div style='text-align:center; padding:8px; margin:8px 0; background:#fef2f2; border-radius:8px; border:1px solid #ef4444;'><p style='margin:0; color:#991b1b; font-weight:600;'>🛑 Attempts used: {ATTEMPT_LIMIT}/{ATTEMPT_LIMIT}</p></div>"),
2064
+ team_leaderboard_display: team_leaderboard_display, individual_leaderboard_display: individual_leaderboard_display,
2065
+ last_submission_score_state: last_submission_score, last_rank_state: last_rank,
2066
+ best_score_state: best_score, submission_count_state: submission_count,
2067
+ first_submission_score_state: first_submission_score, rank_message_display: settings["rank_message"],
2068
+ login_username: gr.update(visible=False), login_password: gr.update(visible=False),
2069
+ login_submit: gr.update(visible=False), login_error: gr.update(visible=False),
2070
+ was_preview_state: False, kpi_meta_state: {}, last_seen_ts_state: None
2071
+ }
2072
+ yield limit_reached_updates
2073
+ return
2074
+
2075
+ progress(0.5, desc="Submitting to Cloud...")
2076
+ yield {
2077
+ submission_feedback_display: gr.update(value=get_status_html(3, "Submitting", "Sending model to the competition server..."), visible=True),
2078
+ login_error: gr.update(visible=False)
2079
+ }
2080
+
2081
+ description = f"{model_name_key} (Cplx:{complexity_level} Size:{data_size_str})"
2082
+ tags = f"team:{team_name},model:{model_name_key}"
2083
+
2084
+ # 1. FETCH BASELINE
2085
+ baseline_leaderboard_df = _get_leaderboard_with_optional_token(playground, token)
2086
+
2087
+ from sklearn.metrics import accuracy_score
2088
+ # Ensure correct type for local accuracy calc
2089
+ if isinstance(predictions, list):
2090
+ local_accuracy_preds = np.array(predictions)
2091
+ else:
2092
+ local_accuracy_preds = predictions
2093
+ local_test_accuracy = accuracy_score(Y_TEST, local_accuracy_preds)
2094
+
2095
+ # 2. SUBMIT & CAPTURE ACCURACY
2096
+ def _submit():
2097
+ # If using cache (tuned_model is None), we pass None for model/preprocessor
2098
+ # and explicitly pass predictions.
2099
+ return playground.submit_model(
2100
+ model=tuned_model,
2101
+ preprocessor=preprocessor,
2102
+ prediction_submission=predictions,
2103
+ input_dict={'description': description, 'tags': tags},
2104
+ custom_metadata={'Team': team_name, 'Moral_Compass': 0},
2105
+ token=token,
2106
+ return_metrics=["accuracy"]
2107
+ )
2108
+
2109
+ try:
2110
+ submit_result = _retry_with_backoff(_submit, description="model submission")
2111
+ if isinstance(submit_result, tuple) and len(submit_result) == 3:
2112
+ _, _, metrics = submit_result
2113
+ if metrics and "accuracy" in metrics and metrics["accuracy"] is not None:
2114
+ this_submission_score = float(metrics["accuracy"])
2115
+ else:
2116
+ this_submission_score = local_test_accuracy
2117
+ else:
2118
+ this_submission_score = local_test_accuracy
2119
+ except Exception as e:
2120
+ _log(f"Submission return parsing failed: {e}. Using local accuracy.")
2121
+ this_submission_score = local_test_accuracy
2122
+
2123
+ _log(f"Submission successful. Server Score: {this_submission_score}")
2124
+
2125
+ try:
2126
+ # Short timeout to trigger the lambda without hanging the UI
2127
+ _log("Triggering backend merge...")
2128
+ playground.get_leaderboard(token=token)
2129
+ except Exception:
2130
+ # We ignore errors here because the 'submit_model' post
2131
+ # already succeeded. This is just a cleanup task.
2132
+ pass
2133
+ # -------------------------------------------------------------------------
2134
+
2135
+ # Immediately increment submission count...
2136
+ new_submission_count = submission_count + 1
2137
+ new_first_submission_score = first_submission_score
2138
+ if submission_count == 0 and first_submission_score is None:
2139
+ new_first_submission_score = this_submission_score
2140
+
2141
+ # --- Stage 4: Local Rank Calculation (Optimistic) ---
2142
+ progress(0.9, desc="Calculating Rank...")
2143
+
2144
+ # 3. SIMULATE UPDATED LEADERBOARD
2145
+ simulated_df = baseline_leaderboard_df.copy() if baseline_leaderboard_df is not None else pd.DataFrame()
2146
+
2147
+ # We use pd.Timestamp.now() to ensure pandas sorting logic sees this as the absolute latest
2148
+ new_row = pd.DataFrame([{
2149
+ "username": username,
2150
+ "accuracy": this_submission_score,
2151
+ "Team": team_name,
2152
+ "timestamp": pd.Timestamp.now(),
2153
+ "version": "latest"
2154
+ }])
2155
+
2156
+ if not simulated_df.empty:
2157
+ simulated_df = pd.concat([simulated_df, new_row], ignore_index=True)
2158
+ else:
2159
+ simulated_df = new_row
2160
+
2161
+ # 4. GENERATE TABLES (Use helper for tables only)
2162
+ # We ignore the kpi_card return from this function because it might use internal sorting
2163
+ # that doesn't respect our new row perfectly.
2164
+ team_html, individual_html, _, new_best_accuracy, new_rank, _ = generate_competitive_summary(
2165
+ simulated_df, team_name, username, last_submission_score, last_rank, submission_count
2166
+ )
2167
+
2168
+ # 5. GENERATE KPI CARD EXPLICITLY (The Authority Fix)
2169
+ # We manually build the card using the score we KNOW we just got.
2170
+ kpi_card_html = _build_kpi_card_html(
2171
+ new_score=this_submission_score,
2172
+ last_score=last_submission_score,
2173
+ new_rank=new_rank,
2174
+ last_rank=last_rank,
2175
+ submission_count=submission_count,
2176
+ is_preview=False,
2177
+ is_pending=False
2178
+ )
2179
+
2180
+ # --- Stage 5: Final UI Update ---
2181
+ progress(1.0, desc="Complete!")
2182
+
2183
+ success_kpi_meta = {
2184
+ "was_preview": False, "preview_score": None, "ready_at_run_start": ready,
2185
+ "poll_iterations": 0, "local_test_accuracy": local_test_accuracy,
2186
+ "this_submission_score": this_submission_score, "new_best_accuracy": new_best_accuracy,
2187
+ "rank": new_rank, "pending": False, "optimistic_fallback": True
2188
+ }
2189
+
2190
+ settings = compute_rank_settings(new_submission_count, model_name_key, complexity_level, feature_set, data_size_str)
2191
+
2192
+ # -------------------------------------------------------------------------
2193
+ # NEW LOGIC: Check for Limit Reached immediately AFTER this submission
2194
+ # -------------------------------------------------------------------------
2195
+ limit_reached = new_submission_count >= ATTEMPT_LIMIT
2196
+
2197
+ # Prepare the UI state based on whether limit is reached
2198
+ if limit_reached:
2199
+ # 1. Append the Limit Warning HTML *below* the Result Card
2200
+ limit_html = f"""
2201
+ <div style='margin-top: 16px; border: 2px solid #ef4444; background:#fef2f2; padding:16px; border-radius:12px; text-align:left;'>
2202
+ <h3 style='margin:0 0 8px 0; color:#991b1b;'>🛑 Submission Limit Reached ({ATTEMPT_LIMIT}/{ATTEMPT_LIMIT})</h3>
2203
+ <p style='margin:0; color:#7f1d1d; line-height:1.4;'>
2204
+ <b>You have used all your attempts for this session.</b><br>
2205
+ Review your final results above, then scroll down to "Finish and Reflect" to continue.
2206
+ </p>
2207
+ </div>
2208
+ """
2209
+ final_html_display = kpi_card_html + limit_html
2210
+
2211
+ # 2. Disable all controls
2212
+ button_update = gr.update(value="🛑 Limit Reached", interactive=False)
2213
+ interactive_state = False
2214
+ tracker_html = f"<div style='text-align:center; padding:8px; margin:8px 0; background:#fef2f2; border-radius:8px; border:1px solid #ef4444;'><p style='margin:0; color:#991b1b; font-weight:600;'>🛑 Attempts used: {ATTEMPT_LIMIT}/{ATTEMPT_LIMIT} (Max)</p></div>"
2215
+
2216
+ else:
2217
+ # Normal State: Show just the result card and keep controls active
2218
+ final_html_display = kpi_card_html
2219
+ button_update = gr.update(value="🔬 Build & Submit Model", interactive=True)
2220
+ interactive_state = True
2221
+ tracker_html = _build_attempts_tracker_html(new_submission_count)
2222
+
2223
+ # -------------------------------------------------------------------------
2224
+
2225
+ final_updates = {
2226
+ submission_feedback_display: gr.update(value=final_html_display, visible=True),
2227
+ team_leaderboard_display: team_html,
2228
+ individual_leaderboard_display: individual_html,
2229
+ last_submission_score_state: this_submission_score,
2230
+ last_rank_state: new_rank,
2231
+ best_score_state: new_best_accuracy,
2232
+ submission_count_state: new_submission_count,
2233
+ first_submission_score_state: new_first_submission_score,
2234
+ rank_message_display: settings["rank_message"],
2235
+
2236
+ # Apply the interactive state calculated above
2237
+ model_type_radio: gr.update(choices=settings["model_choices"], value=settings["model_value"], interactive=(settings["model_interactive"] and interactive_state)),
2238
+ complexity_slider: gr.update(minimum=1, maximum=settings["complexity_max"], value=settings["complexity_value"], interactive=interactive_state),
2239
+ feature_set_checkbox: gr.update(choices=settings["feature_set_choices"], value=settings["feature_set_value"], interactive=(settings["feature_set_interactive"] and interactive_state)),
2240
+ data_size_radio: gr.update(choices=settings["data_size_choices"], value=settings["data_size_value"], interactive=(settings["data_size_interactive"] and interactive_state)),
2241
+
2242
+ submit_button: button_update,
2243
+
2244
+ login_username: gr.update(visible=False), login_password: gr.update(visible=False),
2245
+ login_submit: gr.update(visible=False), login_error: gr.update(visible=False),
2246
+ attempts_tracker_display: gr.update(value=tracker_html),
2247
+ was_preview_state: False,
2248
+ kpi_meta_state: success_kpi_meta,
2249
+ last_seen_ts_state: time.time()
2250
+ }
2251
+ yield final_updates
2252
+
2253
+ except Exception as e:
2254
+ error_msg = f"ERROR: {e}"
2255
+ _log(f"Exception in run_experiment: {error_msg}")
2256
+ settings = compute_rank_settings(
2257
+ submission_count, model_name_key, complexity_level, feature_set, data_size_str
2258
+ )
2259
+
2260
+ exception_kpi_meta = {
2261
+ "was_preview": False, "preview_score": None, "ready_at_run_start": ready if 'ready' in locals() else False,
2262
+ "poll_iterations": 0, "local_test_accuracy": None, "this_submission_score": None,
2263
+ "new_best_accuracy": None, "rank": None, "error": str(e)
2264
+ }
2265
+
2266
+ error_updates = {
2267
+ submission_feedback_display: gr.update(
2268
+ f"<p style='text-align:center; color:red; padding:20px 0;'>An error occurred: {error_msg}</p>", visible=True
2269
+ ),
2270
+ team_leaderboard_display: f"<p style='text-align:center; color:red; padding-top:20px;'>An error occurred: {error_msg}</p>",
2271
+ individual_leaderboard_display: f"<p style='text-align:center; color:red; padding-top:20px;'>An error occurred: {error_msg}</p>",
2272
+ last_submission_score_state: last_submission_score,
2273
+ last_rank_state: last_rank,
2274
+ best_score_state: best_score,
2275
+ submission_count_state: submission_count,
2276
+ first_submission_score_state: first_submission_score,
2277
+ rank_message_display: settings["rank_message"],
2278
+ model_type_radio: gr.update(choices=settings["model_choices"], value=settings["model_value"], interactive=settings["model_interactive"]),
2279
+ complexity_slider: gr.update(minimum=1, maximum=settings["complexity_max"], value=settings["complexity_value"]),
2280
+ feature_set_checkbox: gr.update(choices=settings["feature_set_choices"], value=settings["feature_set_value"], interactive=settings["feature_set_interactive"]),
2281
+ data_size_radio: gr.update(choices=settings["data_size_choices"], value=settings["data_size_value"], interactive=settings["data_size_interactive"]),
2282
+ submit_button: gr.update(value="🔬 Build & Submit Model", interactive=True),
2283
+ login_username: gr.update(visible=False),
2284
+ login_password: gr.update(visible=False),
2285
+ login_submit: gr.update(visible=False),
2286
+ login_error: gr.update(visible=False),
2287
+ attempts_tracker_display: gr.update(value=_build_attempts_tracker_html(submission_count)),
2288
+ was_preview_state: False,
2289
+ kpi_meta_state: exception_kpi_meta,
2290
+ last_seen_ts_state: None
2291
+ }
2292
+ yield error_updates
2293
+
2294
+ def on_initial_load(username, token=None, team_name=""):
2295
+ """
2296
+ Updated to show "Welcome & CTA" if the SPECIFIC USER has 0 submissions,
2297
+ even if the leaderboard/team already has data from others.
2298
+ """
2299
+ initial_ui = compute_rank_settings(
2300
+ 0, DEFAULT_MODEL, 2, DEFAULT_FEATURE_SET, DEFAULT_DATA_SIZE
2301
+ )
2302
+
2303
+ # 1. Prepare the Welcome HTML
2304
+ display_team = team_name if team_name else "Your Team"
2305
+
2306
+ welcome_html = f"""
2307
+ <div style='text-align:center; padding: 30px 20px;'>
2308
+ <div style='font-size: 3rem; margin-bottom: 10px;'>👋</div>
2309
+ <h3 style='margin: 0 0 8px 0; color: #111827; font-size: 1.5rem;'>Welcome to <b>{display_team}</b>!</h3>
2310
+ <p style='font-size: 1.1rem; color: #4b5563; margin: 0 0 20px 0;'>
2311
+ Your team is waiting for your help to improve the AI.
2312
+ </p>
2313
+
2314
+ <div style='background:#eff6ff; padding:16px; border-radius:12px; border:2px solid #bfdbfe; display:inline-block;'>
2315
+ <p style='margin:0; color:#1e40af; font-weight:bold; font-size:1.1rem;'>
2316
+ 👈 Click "Build & Submit Model" to Start Playing!
2317
+ </p>
2318
+ </div>
2319
+ </div>
2320
+ """
2321
+
2322
+ # Check background init
2323
+ with INIT_LOCK:
2324
+ background_ready = INIT_FLAGS["leaderboard"]
2325
+
2326
+ should_attempt_fetch = background_ready or (token is not None)
2327
+ full_leaderboard_df = None
2328
+
2329
+ if should_attempt_fetch:
2330
+ try:
2331
+ if playground:
2332
+ full_leaderboard_df = _get_leaderboard_with_optional_token(playground, token)
2333
+ except Exception as e:
2334
+ print(f"Error on initial load fetch: {e}")
2335
+ full_leaderboard_df = None
2336
+
2337
+ # -------------------------------------------------------------------------
2338
+ # LOGIC UPDATE: Check if THIS user has submitted anything
2339
+ # -------------------------------------------------------------------------
2340
+ user_has_submitted = False
2341
+ if full_leaderboard_df is not None and not full_leaderboard_df.empty:
2342
+ if "username" in full_leaderboard_df.columns and username:
2343
+ # Check if the username exists in the dataframe
2344
+ user_has_submitted = username in full_leaderboard_df["username"].values
2345
+
2346
+ # Decision Logic
2347
+ if not user_has_submitted:
2348
+ # CASE 1: New User (or first time loading session) -> FORCE WELCOME
2349
+ # regardless of whether the leaderboard has other people's data.
2350
+ team_html = welcome_html
2351
+ individual_html = "<p style='text-align:center; color:#6b7280; padding-top:40px;'>Submit your model to see where you rank!</p>"
2352
+
2353
+ elif full_leaderboard_df is None or full_leaderboard_df.empty:
2354
+ # CASE 2: Returning user, but data fetch failed -> Show Skeleton
2355
+ team_html = _build_skeleton_leaderboard(rows=6, is_team=True)
2356
+ individual_html = _build_skeleton_leaderboard(rows=6, is_team=False)
2357
+
2358
+ else:
2359
+ # CASE 3: Returning user WITH data -> Show Real Tables
2360
+ try:
2361
+ team_html, individual_html, _, _, _, _ = generate_competitive_summary(
2362
+ full_leaderboard_df,
2363
+ team_name,
2364
+ username,
2365
+ 0, 0, -1
2366
+ )
2367
+ except Exception as e:
2368
+ print(f"Error generating summary HTML: {e}")
2369
+ team_html = "<p style='text-align:center; color:red; padding-top:20px;'>Error rendering leaderboard.</p>"
2370
+ individual_html = "<p style='text-align:center; color:red; padding-top:20px;'>Error rendering leaderboard.</p>"
2371
+
2372
+ return (
2373
+ get_model_card(DEFAULT_MODEL),
2374
+ team_html,
2375
+ individual_html,
2376
+ initial_ui["rank_message"],
2377
+ gr.update(choices=initial_ui["model_choices"], value=initial_ui["model_value"], interactive=initial_ui["model_interactive"]),
2378
+ gr.update(minimum=1, maximum=initial_ui["complexity_max"], value=initial_ui["complexity_value"]),
2379
+ gr.update(choices=initial_ui["feature_set_choices"], value=initial_ui["feature_set_value"], interactive=initial_ui["feature_set_interactive"]),
2380
+ gr.update(choices=initial_ui["data_size_choices"], value=initial_ui["data_size_value"], interactive=initial_ui["data_size_interactive"]),
2381
+ )
2382
+
2383
+
2384
+ # -------------------------------------------------------------------------
2385
+ # Conclusion helpers (dark/light mode aware)
2386
+ # -------------------------------------------------------------------------
2387
+ def build_final_conclusion_html(best_score, submissions, rank, first_score, feature_set):
2388
+ """
2389
+ Build the final conclusion HTML with performance summary.
2390
+ Colors are handled via CSS classes so that light/dark mode work correctly.
2391
+ """
2392
+ unlocked_tiers = min(3, max(0, submissions - 1)) # 0..3
2393
+ tier_names = ["Trainee", "Junior", "Senior", "Lead"]
2394
+ reached = tier_names[: unlocked_tiers + 1]
2395
+ tier_line = " → ".join([f"{t}{' ✅' if t in reached else ''}" for t in tier_names])
2396
+
2397
+ improvement = (best_score - first_score) if (first_score is not None and submissions > 1) else 0.0
2398
+ strong_predictors = {"age", "length_of_stay", "priors_count", "age_cat"}
2399
+ strong_used = [f for f in feature_set if f in strong_predictors]
2400
+
2401
+ ethical_note = (
2402
+ "You unlocked powerful predictors. Consider: Would removing demographic fields change fairness? "
2403
+ "In the next section we will begin to investigate this question further."
2404
+ )
2405
+
2406
+ # Tailor message for very few submissions
2407
+ tip_html = ""
2408
+ if submissions < 2:
2409
+ tip_html = """
2410
+ <div class="final-conclusion-tip">
2411
+ <b>Tip:</b> Try at least 2–3 submissions changing ONE setting at a time to see clear cause/effect.
2412
+ </div>
2413
+ """
2414
+
2415
+ # Add note if user reached the attempt cap
2416
+ attempt_cap_html = ""
2417
+ if submissions >= ATTEMPT_LIMIT:
2418
+ attempt_cap_html = f"""
2419
+ <div class="final-conclusion-attempt-cap">
2420
+ <p style="margin:0;">
2421
+ <b>📊 Attempt Limit Reached:</b> You used all {ATTEMPT_LIMIT} allowed submission attempts for this session.
2422
+ We will open up submissions again after you complete some new activities next.
2423
+ </p>
2424
+ </div>
2425
+ """
2426
+
2427
+ return f"""
2428
+ <div class="final-conclusion-root">
2429
+ <h1 class="final-conclusion-title">🎉 Engineering Phase Complete</h1>
2430
+ <div class="final-conclusion-card">
2431
+ <h2 class="final-conclusion-subtitle">Your Performance Snapshot</h2>
2432
+ <ul class="final-conclusion-list">
2433
+ <li>🏁 <b>Best Accuracy:</b> {(best_score * 100):.2f}%</li>
2434
+ <li>📊 <b>Rank Achieved:</b> {('#' + str(rank)) if rank > 0 else '—'}</li>
2435
+ <li>🔁 <b>Submissions Made This Session:</b> {submissions}{' / ' + str(ATTEMPT_LIMIT) if submissions >= ATTEMPT_LIMIT else ''}</li>
2436
+ <li>🧗 <b>Improvement Over First Score This Session:</b> {(improvement * 100):+.2f}</li>
2437
+ <li>🎖️ <b>Tier Progress:</b> {tier_line}</li>
2438
+ <li>🧪 <b>Strong Predictors Used:</b> {len(strong_used)} ({', '.join(strong_used) if strong_used else 'None yet'})</li>
2439
+ </ul>
2440
+
2441
+ {tip_html}
2442
+
2443
+ <div class="final-conclusion-ethics">
2444
+ <p style="margin:0;"><b>Ethical Reflection:</b> {ethical_note}</p>
2445
+ </div>
2446
+
2447
+ {attempt_cap_html}
2448
+
2449
+ <hr class="final-conclusion-divider" />
2450
+
2451
+ <div class="final-conclusion-next">
2452
+ <h2>➡️ Next: Real-World Consequences</h2>
2453
+ <p>Scroll below this app to continue. You'll examine how models like yours shape judicial outcomes.</p>
2454
+ <h1 class="final-conclusion-scroll">👇 SCROLL DOWN 👇</h1>
2455
+ </div>
2456
+ </div>
2457
+ </div>
2458
+ """
2459
+
2460
+
2461
+
2462
+ def build_conclusion_from_state(best_score, submissions, rank, first_score, feature_set):
2463
+ return build_final_conclusion_html(best_score, submissions, rank, first_score, feature_set)
2464
+ def create_model_building_game_es_final_app(theme_primary_hue: str = "indigo") -> "gr.Blocks":
2465
+ """
2466
+ Create (but do not launch) the model building game app.
2467
+ """
2468
+ start_background_init()
2469
+
2470
+ # Add missing globals (FIX)
2471
+ global submit_button, submission_feedback_display, team_leaderboard_display
2472
+ global individual_leaderboard_display, last_submission_score_state, last_rank_state
2473
+ global best_score_state, submission_count_state, first_submission_score_state
2474
+ global rank_message_display, model_type_radio, complexity_slider
2475
+ global feature_set_checkbox, data_size_radio
2476
+ global login_username, login_password, login_submit, login_error
2477
+ global attempts_tracker_display, team_name_state
2478
+ global username_state, token_state # <-- Added
2479
+ global readiness_state, was_preview_state, kpi_meta_state # <-- Added for parameter shadowing guards
2480
+ global last_seen_ts_state # <-- Added for timestamp tracking
2481
+
2482
+ css = """
2483
+ /* ------------------------------
2484
+ Shared Design Tokens (local)
2485
+ ------------------------------ */
2486
+
2487
+ /* We keep everything driven by Gradio theme vars:
2488
+ --body-background-fill, --body-text-color, --secondary-text-color,
2489
+ --border-color-primary, --block-background-fill, --color-accent,
2490
+ --shadow-drop, --prose-background-fill
2491
+ */
2492
+
2493
+ :root {
2494
+ --slide-radius-md: 12px;
2495
+ --slide-radius-lg: 16px;
2496
+ --slide-radius-xl: 18px;
2497
+ --slide-spacing-lg: 24px;
2498
+
2499
+ /* Local, non-brand tokens built *on top of* theme vars */
2500
+ --card-bg-soft: var(--block-background-fill);
2501
+ --card-bg-strong: var(--prose-background-fill, var(--block-background-fill));
2502
+ --card-border-subtle: var(--border-color-primary);
2503
+ --accent-strong: var(--color-accent);
2504
+ --text-main: var(--body-text-color);
2505
+ --text-muted: var(--secondary-text-color);
2506
+ }
2507
+
2508
+ /* ------------------------------------------------------------------
2509
+ Base Layout Helpers
2510
+ ------------------------------------------------------------------ */
2511
+
2512
+ .slide-content {
2513
+ max-width: 900px;
2514
+ margin-left: auto;
2515
+ margin-right: auto;
2516
+ }
2517
+
2518
+ /* Shared card-like panels used throughout slides */
2519
+ .panel-box {
2520
+ background: var(--card-bg-soft);
2521
+ padding: 20px;
2522
+ border-radius: var(--slide-radius-lg);
2523
+ border: 2px solid var(--card-border-subtle);
2524
+ margin-bottom: 18px;
2525
+ color: var(--text-main);
2526
+ box-shadow: var(--shadow-drop, 0 2px 4px rgba(0,0,0,0.04));
2527
+ }
2528
+
2529
+ .leaderboard-box {
2530
+ background: var(--card-bg-soft);
2531
+ padding: 20px;
2532
+ border-radius: var(--slide-radius-lg);
2533
+ border: 1px solid var(--card-border-subtle);
2534
+ margin-top: 12px;
2535
+ color: var(--text-main);
2536
+ }
2537
+
2538
+ /* For “explanatory UI” scaffolding */
2539
+ .mock-ui-box {
2540
+ background: var(--card-bg-strong);
2541
+ border: 2px solid var(--card-border-subtle);
2542
+ padding: 24px;
2543
+ border-radius: var(--slide-radius-lg);
2544
+ color: var(--text-main);
2545
+ }
2546
+
2547
+ .mock-ui-inner {
2548
+ background: var(--block-background-fill);
2549
+ border: 1px solid var(--card-border-subtle);
2550
+ padding: 24px;
2551
+ border-radius: var(--slide-radius-md);
2552
+ }
2553
+
2554
+ /* “Control box” inside the mock UI */
2555
+ .mock-ui-control-box {
2556
+ padding: 12px;
2557
+ background: var(--block-background-fill);
2558
+ border-radius: 8px;
2559
+ border: 1px solid var(--card-border-subtle);
2560
+ }
2561
+
2562
+ /* Little radio / check icons */
2563
+ .mock-ui-radio-on {
2564
+ font-size: 1.5rem;
2565
+ vertical-align: middle;
2566
+ color: var(--accent-strong);
2567
+ }
2568
+
2569
+ .mock-ui-radio-off {
2570
+ font-size: 1.5rem;
2571
+ vertical-align: middle;
2572
+ color: var(--text-muted);
2573
+ }
2574
+
2575
+ .mock-ui-slider-text {
2576
+ font-size: 1.5rem;
2577
+ margin: 0;
2578
+ color: var(--accent-strong);
2579
+ letter-spacing: 4px;
2580
+ }
2581
+
2582
+ .mock-ui-slider-bar {
2583
+ color: var(--text-muted);
2584
+ }
2585
+
2586
+ /* Simple mock button representation */
2587
+ .mock-button {
2588
+ width: 100%;
2589
+ font-size: 1.25rem;
2590
+ font-weight: 600;
2591
+ padding: 16px 24px;
2592
+ background-color: var(--accent-strong);
2593
+ color: var(--body-background-fill);
2594
+ border: none;
2595
+ border-radius: 8px;
2596
+ cursor: not-allowed;
2597
+ }
2598
+
2599
+ /* Step visuals on slides */
2600
+ .step-visual {
2601
+ display: flex;
2602
+ flex-wrap: wrap;
2603
+ justify-content: space-around;
2604
+ align-items: center;
2605
+ margin: 24px 0;
2606
+ text-align: center;
2607
+ font-size: 1rem;
2608
+ }
2609
+
2610
+ .step-visual-box {
2611
+ padding: 16px;
2612
+ background: var(--block-background-fill); /* ✅ theme-aware */
2613
+ border-radius: 8px;
2614
+ border: 2px solid var(--border-color-primary);
2615
+ margin: 5px;
2616
+ color: var(--body-text-color); /* optional, safe */
2617
+ }
2618
+
2619
+ .step-visual-arrow {
2620
+ font-size: 2rem;
2621
+ margin: 5px;
2622
+ /* no explicit color – inherit from theme or override in dark mode */
2623
+ }
2624
+
2625
+ /* ------------------------------------------------------------------
2626
+ KPI Card (score feedback)
2627
+ ------------------------------------------------------------------ */
2628
+
2629
+ .kpi-card {
2630
+ background: var(--card-bg-strong);
2631
+ border: 2px solid var(--accent-strong);
2632
+ padding: 24px;
2633
+ border-radius: var(--slide-radius-lg);
2634
+ text-align: center;
2635
+ max-width: 600px;
2636
+ margin: auto;
2637
+ color: var(--text-main);
2638
+ box-shadow: var(--shadow-drop, 0 4px 6px -1px rgba(0,0,0,0.08));
2639
+ min-height: 200px; /* prevent layout shift */
2640
+ }
2641
+
2642
+ .kpi-card-body {
2643
+ display: flex;
2644
+ flex-wrap: wrap;
2645
+ justify-content: space-around;
2646
+ align-items: flex-end;
2647
+ margin-top: 24px;
2648
+ }
2649
+
2650
+ .kpi-metric-box {
2651
+ min-width: 150px;
2652
+ margin: 10px;
2653
+ }
2654
+
2655
+ .kpi-label {
2656
+ font-size: 1rem;
2657
+ color: var(--text-muted);
2658
+ margin: 0;
2659
+ }
2660
+
2661
+ .kpi-score {
2662
+ font-size: 3rem;
2663
+ font-weight: 700;
2664
+ margin: 0;
2665
+ line-height: 1.1;
2666
+ color: var(--accent-strong);
2667
+ }
2668
+
2669
+ .kpi-subtext-muted {
2670
+ font-size: 1.2rem;
2671
+ font-weight: 500;
2672
+ color: var(--text-muted);
2673
+ margin: 0;
2674
+ padding-top: 8px;
2675
+ }
2676
+
2677
+ /* Small variants to hint semantic state without hard-coded colors */
2678
+ .kpi-card--neutral {
2679
+ border-color: var(--card-border-subtle);
2680
+ }
2681
+
2682
+ .kpi-card--subtle-accent {
2683
+ border-color: var(--accent-strong);
2684
+ }
2685
+
2686
+ .kpi-score--muted {
2687
+ color: var(--text-muted);
2688
+ }
2689
+
2690
+ /* ------------------------------------------------------------------
2691
+ Leaderboard Table + Placeholder
2692
+ ------------------------------------------------------------------ */
2693
+
2694
+ .leaderboard-html-table {
2695
+ width: 100%;
2696
+ border-collapse: collapse;
2697
+ text-align: left;
2698
+ font-size: 1rem;
2699
+ color: var(--text-main);
2700
+ min-height: 300px; /* Stable height */
2701
+ }
2702
+
2703
+ .leaderboard-html-table thead {
2704
+ background: var(--block-background-fill);
2705
+ }
2706
+
2707
+ .leaderboard-html-table th {
2708
+ padding: 12px 16px;
2709
+ font-size: 0.9rem;
2710
+ color: var(--text-muted);
2711
+ font-weight: 500;
2712
+ }
2713
+
2714
+ .leaderboard-html-table tbody tr {
2715
+ border-bottom: 1px solid var(--card-border-subtle);
2716
+ }
2717
+
2718
+ .leaderboard-html-table td {
2719
+ padding: 12px 16px;
2720
+ }
2721
+
2722
+ .leaderboard-html-table .user-row-highlight {
2723
+ background: rgba( var(--color-accent-rgb, 59,130,246), 0.1 );
2724
+ font-weight: 600;
2725
+ color: var(--accent-strong);
2726
+ }
2727
+
2728
+ /* Static placeholder (no shimmer, no animation) */
2729
+ .lb-placeholder {
2730
+ min-height: 300px;
2731
+ display: flex;
2732
+ flex-direction: column;
2733
+ align-items: center;
2734
+ justify-content: center;
2735
+ background: var(--block-background-fill);
2736
+ border: 1px solid var(--card-border-subtle);
2737
+ border-radius: 12px;
2738
+ padding: 40px 20px;
2739
+ text-align: center;
2740
+ }
2741
+
2742
+ .lb-placeholder-title {
2743
+ font-size: 1.25rem;
2744
+ font-weight: 500;
2745
+ color: var(--text-muted);
2746
+ margin-bottom: 8px;
2747
+ }
2748
+
2749
+ .lb-placeholder-sub {
2750
+ font-size: 1rem;
2751
+ color: var(--text-muted);
2752
+ }
2753
+
2754
+ /* ------------------------------------------------------------------
2755
+ Processing / “Experiment running” status
2756
+ ------------------------------------------------------------------ */
2757
+
2758
+ .processing-status {
2759
+ background: var(--block-background-fill);
2760
+ border: 2px solid var(--accent-strong);
2761
+ border-radius: 16px;
2762
+ padding: 30px;
2763
+ text-align: center;
2764
+ box-shadow: var(--shadow-drop, 0 4px 6px rgba(0,0,0,0.12));
2765
+ animation: pulse-indigo 2s infinite;
2766
+ color: var(--text-main);
2767
+ }
2768
+
2769
+ .processing-icon {
2770
+ font-size: 4rem;
2771
+ margin-bottom: 10px;
2772
+ display: block;
2773
+ animation: spin-slow 3s linear infinite;
2774
+ }
2775
+
2776
+ .processing-text {
2777
+ font-size: 1.5rem;
2778
+ font-weight: 700;
2779
+ color: var(--accent-strong);
2780
+ }
2781
+
2782
+ .processing-subtext {
2783
+ font-size: 1.1rem;
2784
+ color: var(--text-muted);
2785
+ margin-top: 8px;
2786
+ }
2787
+
2788
+ /* Pulse & spin animations */
2789
+ @keyframes pulse-indigo {
2790
+ 0% { box-shadow: 0 0 0 0 rgba(99, 102, 241, 0.4); }
2791
+ 70% { box-shadow: 0 0 0 15px rgba(99, 102, 241, 0); }
2792
+ 100% { box-shadow: 0 0 0 0 rgba(99, 102, 241, 0); }
2793
+ }
2794
+
2795
+ @keyframes spin-slow {
2796
+ from { transform: rotate(0deg); }
2797
+ to { transform: rotate(360deg); }
2798
+ }
2799
+
2800
+ /* Conclusion arrow pulse */
2801
+ @keyframes pulseArrow {
2802
+ 0% { transform: scale(1); opacity: 1; }
2803
+ 50% { transform: scale(1.08); opacity: 0.85; }
2804
+ 100% { transform: scale(1); opacity: 1; }
2805
+ }
2806
+
2807
+ @media (prefers-reduced-motion: reduce) {
2808
+ [style*='pulseArrow'] {
2809
+ animation: none !important;
2810
+ }
2811
+ .processing-status,
2812
+ .processing-icon {
2813
+ animation: none !important;
2814
+ }
2815
+ }
2816
+
2817
+ /* ------------------------------------------------------------------
2818
+ Attempts Tracker + Init Banner + Alerts
2819
+ ------------------------------------------------------------------ */
2820
+
2821
+ .init-banner {
2822
+ background: var(--card-bg-strong);
2823
+ padding: 12px;
2824
+ border-radius: 8px;
2825
+ text-align: center;
2826
+ margin-bottom: 16px;
2827
+ border: 1px solid var(--card-border-subtle);
2828
+ color: var(--text-main);
2829
+ }
2830
+
2831
+ .init-banner__text {
2832
+ margin: 0;
2833
+ font-weight: 500;
2834
+ color: var(--text-muted);
2835
+ }
2836
+
2837
+ /* Attempts tracker shell */
2838
+ .attempts-tracker {
2839
+ text-align: center;
2840
+ padding: 8px;
2841
+ margin: 8px 0;
2842
+ background: var(--block-background-fill);
2843
+ border-radius: 8px;
2844
+ border: 1px solid var(--card-border-subtle);
2845
+ }
2846
+
2847
+ .attempts-tracker__text {
2848
+ margin: 0;
2849
+ font-weight: 600;
2850
+ font-size: 1rem;
2851
+ color: var(--accent-strong);
2852
+ }
2853
+
2854
+ /* Limit reached variant – we *still* stick to theme colors */
2855
+ .attempts-tracker--limit .attempts-tracker__text {
2856
+ color: var(--text-main);
2857
+ }
2858
+
2859
+ /* Generic alert helpers used in inline login messages */
2860
+ .alert {
2861
+ padding: 12px 16px;
2862
+ border-radius: 8px;
2863
+ margin-top: 12px;
2864
+ text-align: left;
2865
+ font-size: 0.95rem;
2866
+ }
2867
+
2868
+ .alert--error {
2869
+ border-left: 4px solid var(--accent-strong);
2870
+ background: var(--block-background-fill);
2871
+ color: var(--text-main);
2872
+ }
2873
+
2874
+ .alert--success {
2875
+ border-left: 4px solid var(--accent-strong);
2876
+ background: var(--block-background-fill);
2877
+ color: var(--text-main);
2878
+ }
2879
+
2880
+ .alert__title {
2881
+ margin: 0;
2882
+ font-weight: 600;
2883
+ color: var(--text-main);
2884
+ }
2885
+
2886
+ .alert__body {
2887
+ margin: 8px 0 0 0;
2888
+ color: var(--text-muted);
2889
+ }
2890
+
2891
+ /* ------------------------------------------------------------------
2892
+ Navigation Loading Overlay
2893
+ ------------------------------------------------------------------ */
2894
+
2895
+ #nav-loading-overlay {
2896
+ position: fixed;
2897
+ top: 0;
2898
+ left: 0;
2899
+ width: 100%;
2900
+ height: 100%;
2901
+ background: color-mix(in srgb, var(--body-background-fill) 90%, transparent);
2902
+ z-index: 9999;
2903
+ display: none;
2904
+ flex-direction: column;
2905
+ align-items: center;
2906
+ justify-content: center;
2907
+ opacity: 0;
2908
+ transition: opacity 0.3s ease;
2909
+ }
2910
+
2911
+ .nav-spinner {
2912
+ width: 50px;
2913
+ height: 50px;
2914
+ border: 5px solid var(--card-border-subtle);
2915
+ border-top: 5px solid var(--accent-strong);
2916
+ border-radius: 50%;
2917
+ animation: nav-spin 1s linear infinite;
2918
+ margin-bottom: 20px;
2919
+ }
2920
+
2921
+ @keyframes nav-spin {
2922
+ 0% { transform: rotate(0deg); }
2923
+ 100% { transform: rotate(360deg); }
2924
+ }
2925
+
2926
+ #nav-loading-text {
2927
+ font-size: 1.3rem;
2928
+ font-weight: 600;
2929
+ color: var(--accent-strong);
2930
+ }
2931
+
2932
+ /* ------------------------------------------------------------------
2933
+ Utility: Image inversion for dark mode (if needed)
2934
+ ------------------------------------------------------------------ */
2935
+
2936
+ .dark-invert-image {
2937
+ filter: invert(0);
2938
+ }
2939
+
2940
+ @media (prefers-color-scheme: dark) {
2941
+ .dark-invert-image {
2942
+ filter: invert(1) hue-rotate(180deg);
2943
+ }
2944
+ }
2945
+
2946
+ /* ------------------------------------------------------------------
2947
+ Dark Mode Specific Fine Tuning
2948
+ ------------------------------------------------------------------ */
2949
+
2950
+ @media (prefers-color-scheme: dark) {
2951
+ .panel-box,
2952
+ .leaderboard-box,
2953
+ .mock-ui-box,
2954
+ .mock-ui-inner,
2955
+ .processing-status,
2956
+ .kpi-card {
2957
+ background: color-mix(in srgb, var(--block-background-fill) 85%, #000 15%);
2958
+ border-color: color-mix(in srgb, var(--card-border-subtle) 70%, var(--accent-strong) 30%);
2959
+ }
2960
+
2961
+ .leaderboard-html-table thead {
2962
+ background: color-mix(in srgb, var(--block-background-fill) 75%, #000 25%);
2963
+ }
2964
+
2965
+ .lb-placeholder {
2966
+ background: color-mix(in srgb, var(--block-background-fill) 75%, #000 25%);
2967
+ }
2968
+
2969
+ #nav-loading-overlay {
2970
+ background: color-mix(in srgb, #000 70%, var(--body-background-fill) 30%);
2971
+ }
2972
+ }
2973
+
2974
+ /* ---------- Conclusion Card Theme Tokens ---------- */
2975
+
2976
+ /* Light theme defaults */
2977
+ :root,
2978
+ :root[data-theme="light"] {
2979
+ --conclusion-card-bg: #e0f2fe; /* light sky */
2980
+ --conclusion-card-border: #0369a1; /* sky-700 */
2981
+ --conclusion-card-fg: #0f172a; /* slate-900 */
2982
+
2983
+ --conclusion-tip-bg: #fef9c3; /* amber-100 */
2984
+ --conclusion-tip-border: #f59e0b; /* amber-500 */
2985
+ --conclusion-tip-fg: #713f12; /* amber-900 */
2986
+
2987
+ --conclusion-ethics-bg: #fef2f2; /* red-50 */
2988
+ --conclusion-ethics-border: #ef4444; /* red-500 */
2989
+ --conclusion-ethics-fg: #7f1d1d; /* red-900 */
2990
+
2991
+ --conclusion-attempt-bg: #fee2e2; /* red-100 */
2992
+ --conclusion-attempt-border: #ef4444; /* red-500 */
2993
+ --conclusion-attempt-fg: #7f1d1d; /* red-900 */
2994
+
2995
+ --conclusion-next-fg: #0f172a; /* main text color */
2996
+ }
2997
+
2998
+ /* Dark theme overrides – keep contrast high on dark background */
2999
+ [data-theme="dark"] {
3000
+ --conclusion-card-bg: #020617; /* slate-950 */
3001
+ --conclusion-card-border: #38bdf8; /* sky-400 */
3002
+ --conclusion-card-fg: #e5e7eb; /* slate-200 */
3003
+
3004
+ --conclusion-tip-bg: rgba(250, 204, 21, 0.08); /* soft amber tint */
3005
+ --conclusion-tip-border: #facc15; /* amber-400 */
3006
+ --conclusion-tip-fg: #facc15;
3007
+
3008
+ --conclusion-ethics-bg: rgba(248, 113, 113, 0.10); /* soft red tint */
3009
+ --conclusion-ethics-border: #f97373; /* red-ish */
3010
+ --conclusion-ethics-fg: #fecaca;
3011
+
3012
+ --conclusion-attempt-bg: rgba(248, 113, 113, 0.16);
3013
+ --conclusion-attempt-border: #f97373;
3014
+ --conclusion-attempt-fg: #fee2e2;
3015
+
3016
+ --conclusion-next-fg: #e5e7eb;
3017
+ }
3018
+
3019
+ /* ---------- Conclusion Layout ---------- */
3020
+
3021
+ .app-conclusion-wrapper {
3022
+ text-align: center;
3023
+ }
3024
+
3025
+ .app-conclusion-title {
3026
+ font-size: 2.4rem;
3027
+ margin: 0;
3028
+ }
3029
+
3030
+ .app-conclusion-card {
3031
+ margin-top: 24px;
3032
+ max-width: 950px;
3033
+ margin-left: auto;
3034
+ margin-right: auto;
3035
+ padding: 28px;
3036
+ border-radius: 18px;
3037
+ border-width: 3px;
3038
+ border-style: solid;
3039
+ background: var(--conclusion-card-bg);
3040
+ border-color: var(--conclusion-card-border);
3041
+ color: var(--conclusion-card-fg);
3042
+ box-shadow: 0 20px 40px rgba(15, 23, 42, 0.25);
3043
+ }
3044
+
3045
+ .app-conclusion-subtitle {
3046
+ margin-top: 0;
3047
+ font-size: 1.5rem;
3048
+ }
3049
+
3050
+ .app-conclusion-metrics {
3051
+ list-style: none;
3052
+ padding: 0;
3053
+ font-size: 1.05rem;
3054
+ text-align: left;
3055
+ max-width: 640px;
3056
+ margin: 20px auto;
3057
+ }
3058
+
3059
+ /* ---------- Generic panel helpers reused here ---------- */
3060
+
3061
+ .app-panel-tip,
3062
+ .app-panel-critical,
3063
+ .app-panel-warning {
3064
+ padding: 16px;
3065
+ border-radius: 12px;
3066
+ border-left-width: 6px;
3067
+ border-left-style: solid;
3068
+ text-align: left;
3069
+ font-size: 0.98rem;
3070
+ line-height: 1.4;
3071
+ margin-top: 16px;
3072
+ }
3073
+
3074
+ .app-panel-title {
3075
+ margin: 0 0 4px 0;
3076
+ font-weight: 700;
3077
+ }
3078
+
3079
+ .app-panel-body {
3080
+ margin: 0;
3081
+ }
3082
+
3083
+ /* Specific variants */
3084
+
3085
+ .app-conclusion-tip.app-panel-tip {
3086
+ background: var(--conclusion-tip-bg);
3087
+ border-left-color: var(--conclusion-tip-border);
3088
+ color: var(--conclusion-tip-fg);
3089
+ }
3090
+
3091
+ .app-conclusion-ethics.app-panel-critical {
3092
+ background: var(--conclusion-ethics-bg);
3093
+ border-left-color: var(--conclusion-ethics-border);
3094
+ color: var(--conclusion-ethics-fg);
3095
+ }
3096
+
3097
+ .app-conclusion-attempt-cap.app-panel-warning {
3098
+ background: var(--conclusion-attempt-bg);
3099
+ border-left-color: var(--conclusion-attempt-border);
3100
+ color: var(--conclusion-attempt-fg);
3101
+ }
3102
+
3103
+ /* Divider + next section */
3104
+
3105
+ .app-conclusion-divider {
3106
+ margin: 28px 0;
3107
+ border: 0;
3108
+ border-top: 2px solid rgba(148, 163, 184, 0.8); /* slate-400-ish */
3109
+ }
3110
+
3111
+ .app-conclusion-next-title {
3112
+ margin: 0;
3113
+ color: var(--conclusion-next-fg);
3114
+ }
3115
+
3116
+ .app-conclusion-next-body {
3117
+ font-size: 1rem;
3118
+ color: var(--conclusion-next-fg);
3119
+ }
3120
+
3121
+ /* Arrow inherits the same color, keeps pulse animation defined earlier */
3122
+ .app-conclusion-arrow {
3123
+ margin: 12px 0;
3124
+ font-size: 3rem;
3125
+ animation: pulseArrow 2.5s infinite;
3126
+ color: var(--conclusion-next-fg);
3127
+ }
3128
+
3129
+ /* ---------------------------------------------------- */
3130
+ /* Final Conclusion Slide (Light Mode Defaults) */
3131
+ /* ---------------------------------------------------- */
3132
+
3133
+ .final-conclusion-root {
3134
+ text-align: center;
3135
+ color: var(--body-text-color);
3136
+ }
3137
+
3138
+ .final-conclusion-title {
3139
+ font-size: 2.4rem;
3140
+ margin: 0;
3141
+ }
3142
+
3143
+ .final-conclusion-card {
3144
+ background-color: var(--block-background-fill);
3145
+ color: var(--body-text-color);
3146
+ padding: 28px;
3147
+ border-radius: 18px;
3148
+ border: 2px solid var(--border-color-primary);
3149
+ margin-top: 24px;
3150
+ max-width: 950px;
3151
+ margin-left: auto;
3152
+ margin-right: auto;
3153
+ box-shadow: var(--shadow-drop, 0 4px 10px rgba(15, 23, 42, 0.08));
3154
+ }
3155
+
3156
+ .final-conclusion-subtitle {
3157
+ margin-top: 0;
3158
+ margin-bottom: 8px;
3159
+ }
3160
+
3161
+ .final-conclusion-list {
3162
+ list-style: none;
3163
+ padding: 0;
3164
+ font-size: 1.05rem;
3165
+ text-align: left;
3166
+ max-width: 640px;
3167
+ margin: 20px auto;
3168
+ }
3169
+
3170
+ .final-conclusion-list li {
3171
+ margin: 4px 0;
3172
+ }
3173
+
3174
+ .final-conclusion-tip {
3175
+ margin-top: 16px;
3176
+ padding: 16px;
3177
+ border-radius: 12px;
3178
+ border-left: 6px solid var(--color-accent);
3179
+ background-color: color-mix(in srgb, var(--color-accent) 12%, transparent);
3180
+ text-align: left;
3181
+ font-size: 0.98rem;
3182
+ line-height: 1.4;
3183
+ }
3184
+
3185
+ .final-conclusion-ethics {
3186
+ margin-top: 16px;
3187
+ padding: 18px;
3188
+ border-radius: 12px;
3189
+ border-left: 6px solid #ef4444;
3190
+ background-color: color-mix(in srgb, #ef4444 10%, transparent);
3191
+ text-align: left;
3192
+ font-size: 0.98rem;
3193
+ line-height: 1.4;
3194
+ }
3195
+
3196
+ .final-conclusion-attempt-cap {
3197
+ margin-top: 16px;
3198
+ padding: 16px;
3199
+ border-radius: 12px;
3200
+ border-left: 6px solid #ef4444;
3201
+ background-color: color-mix(in srgb, #ef4444 16%, transparent);
3202
+ text-align: left;
3203
+ font-size: 0.98rem;
3204
+ line-height: 1.4;
3205
+ }
3206
+
3207
+ .final-conclusion-divider {
3208
+ margin: 28px 0;
3209
+ border: 0;
3210
+ border-top: 2px solid var(--border-color-primary);
3211
+ }
3212
+
3213
+ .final-conclusion-next h2 {
3214
+ margin: 0;
3215
+ }
3216
+
3217
+ .final-conclusion-next p {
3218
+ font-size: 1rem;
3219
+ margin-top: 4px;
3220
+ margin-bottom: 0;
3221
+ }
3222
+
3223
+ .final-conclusion-scroll {
3224
+ margin: 12px 0 0 0;
3225
+ font-size: 3rem;
3226
+ animation: pulseArrow 2.5s infinite;
3227
+ }
3228
+
3229
+ /* ---------------------------------------------------- */
3230
+ /* Dark Mode Overrides for Final Slide */
3231
+ /* ---------------------------------------------------- */
3232
+
3233
+ @media (prefers-color-scheme: dark) {
3234
+ .final-conclusion-card {
3235
+ background-color: #0b1120; /* deep slate */
3236
+ color: white; /* 100% contrast confidence */
3237
+ border-color: #38bdf8;
3238
+ box-shadow: none;
3239
+ }
3240
+
3241
+ .final-conclusion-tip {
3242
+ background-color: rgba(56, 189, 248, 0.18);
3243
+ }
3244
+
3245
+ .final-conclusion-ethics {
3246
+ background-color: rgba(248, 113, 113, 0.18);
3247
+ }
3248
+
3249
+ .final-conclusion-attempt-cap {
3250
+ background-color: rgba(248, 113, 113, 0.26);
3251
+ }
3252
+ }
3253
+ /* ---------------------------------------------------- */
3254
+ /* Slide 3: INPUT → MODEL → OUTPUT flow (theme-aware) */
3255
+ /* ---------------------------------------------------- */
3256
+
3257
+
3258
+ .model-flow {
3259
+ text-align: center;
3260
+ font-weight: 600;
3261
+ font-size: 1.2rem;
3262
+ margin: 20px 0;
3263
+ /* No explicit color – inherit from the card */
3264
+ }
3265
+
3266
+ .model-flow-label {
3267
+ padding: 0 0.1rem;
3268
+ /* No explicit color – inherit */
3269
+ }
3270
+
3271
+ .model-flow-arrow {
3272
+ margin: 0 0.35rem;
3273
+ font-size: 1.4rem;
3274
+ /* No explicit color – inherit */
3275
+ }
3276
+
3277
+ @media (prefers-color-scheme: dark) {
3278
+ .model-flow {
3279
+ color: var(--body-text-color);
3280
+ }
3281
+ .model-flow-arrow {
3282
+ /* In dark mode, nudge arrows toward accent for contrast/confidence */
3283
+ color: color-mix(in srgb, var(--color-accent) 75%, var(--body-text-color) 25%);
3284
+ }
3285
+ }
3286
+ """
3287
+
3288
+
3289
+ # Define globals for yield
3290
+ global submit_button, submission_feedback_display, team_leaderboard_display
3291
+ # --- THIS IS THE FIXED LINE ---
3292
+ global individual_leaderboard_display, last_submission_score_state, last_rank_state, best_score_state, submission_count_state, first_submission_score_state
3293
+ # --- END OF FIX ---
3294
+ global rank_message_display, model_type_radio, complexity_slider
3295
+ global feature_set_checkbox, data_size_radio
3296
+ global login_username, login_password, login_submit, login_error
3297
+ global attempts_tracker_display, team_name_state
3298
+
3299
+ with gr.Blocks(theme=gr.themes.Soft(primary_hue="indigo"), css=css) as demo:
3300
+ # Persistent top anchor for scroll-to-top navigation
3301
+ gr.HTML("<div id='app_top_anchor' style='height:0;'></div>")
3302
+
3303
+ # Navigation loading overlay with spinner and dynamic message
3304
+ gr.HTML("""
3305
+ <div id='nav-loading-overlay'>
3306
+ <div class='nav-spinner'></div>
3307
+ <span id='nav-loading-text'>Loading...</span>
3308
+ </div>
3309
+ """)
3310
+
3311
+ # Concurrency Note: Do NOT read per-user state from os.environ here.
3312
+ # Username and other per-user data are managed via gr.State objects
3313
+ # and populated during handle_load_with_session_auth.
3314
+
3315
+ # Loading screen
3316
+ with gr.Column(visible=False) as loading_screen:
3317
+ gr.Markdown(
3318
+ """
3319
+ <div style='text-align:center; padding:100px 0;'>
3320
+ <h2 style='font-size:2rem; color:#6b7280;'>⏳ Loading...</h2>
3321
+ </div>
3322
+ """
3323
+ )
3324
+
3325
+ # --- Briefing Slideshow (Updated with New Cards) ---
3326
+
3327
+ # Slide 7: The Final Transition
3328
+ with gr.Column(visible=True, elem_id="intro-slide") as intro_slide:
3329
+ gr.Markdown("<h1 style='text-align:center;'>🚀 The Final Frontier</h1>")
3330
+
3331
+ gr.HTML(
3332
+ """
3333
+ <div class='slide-content'>
3334
+ <div class='panel-box'>
3335
+ <div style="text-align:center; margin-bottom: 25px;">
3336
+ <p style="font-size:1.15rem; line-height:1.6;">
3337
+ You have explored the ethics. You understand the risks.
3338
+ <br>
3339
+ Now, it is time to prove you have the technical <strong>Skill</strong>.
3340
+ </p>
3341
+ </div>
3342
+
3343
+ <div style="background:linear-gradient(to right, #eff6ff, white); border:2px solid #3b82f6; border-radius:12px; padding:24px; margin-bottom: 25px;">
3344
+ <h3 style="margin-top:0; color:#1e40af; text-align:center; font-size:1.4rem;">🛠️ The Accuracy Competition</h3>
3345
+ <div style="font-size:1.1rem; line-height:1.6; color:#1f2937;">
3346
+ <p>Your <strong>final mission</strong> is to compete against your peers to build the <strong>most accurate model possible</strong>.</p>
3347
+
3348
+ <p>✨ <strong>Unrestricted Access:</strong> You are now a Lead Engineer. All data inputs and modeling tools are <strong>unlocked immediately</strong>.</p>
3349
+
3350
+ <p>Use every tool at your disposal to climb the leaderboard, but remember the lessons you just learned:
3351
+ <em>Accuracy is the goal, but data choices have consequences.</em></p>
3352
+ </div>
3353
+ </div>
3354
+
3355
+ <div style="text-align:center; margin-top:20px; padding-top:10px; border-top: 1px solid #e5e7eb;">
3356
+ <p style="font-size:1.2rem; font-weight:700; color:#4b5563; margin-bottom:5px;">
3357
+ Ready to begin?
3358
+ </p>
3359
+ <p style="font-size:1rem; color:#6b7280; margin-top:0;">
3360
+ 👇 Click the <b>"Enter the Arena"</b> button below.
3361
+ </p>
3362
+ </div>
3363
+ </div>
3364
+ </div>
3365
+ """
3366
+ )
3367
+
3368
+ # Only ONE button needed now
3369
+ intro_next_btn = gr.Button("Enter the Arena ▶️", variant="primary", size="lg")
3370
+
3371
+ # --- End Briefing Slideshow ---
3372
+
3373
+
3374
+ # Model Building App (Main Interface)
3375
+ with gr.Column(visible=False, elem_id="model-step") as model_building_step:
3376
+ gr.Markdown("<h1 style='text-align:center;'>🛠️ Model Building Arena</h1>")
3377
+
3378
+ # Status panel for initialization progress - HIDDEN
3379
+ init_status_display = gr.HTML(value="", visible=False)
3380
+
3381
+ # Banner for UI state
3382
+
3383
+ init_banner = gr.HTML(
3384
+ value=(
3385
+ "<div class='init-banner'>"
3386
+ "<p class='init-banner__text'>"
3387
+ "⏳ Initializing data & leaderboard… you can explore but must wait for readiness to submit."
3388
+ "</p>"
3389
+ "</div>"
3390
+ ),
3391
+ visible=True)
3392
+
3393
+ # Session-based authentication state objects
3394
+ # Concurrency Note: These are initialized to None/empty and populated
3395
+ # during handle_load_with_session_auth. Do NOT use os.environ here.
3396
+ username_state = gr.State(None)
3397
+ token_state = gr.State(None)
3398
+
3399
+ team_name_state = gr.State(None) # Populated via handle_load_with_session_auth
3400
+ last_submission_score_state = gr.State(0.0)
3401
+ last_rank_state = gr.State(0)
3402
+ best_score_state = gr.State(0.0)
3403
+ submission_count_state = gr.State(0)
3404
+ first_submission_score_state = gr.State(None)
3405
+
3406
+ # New states for readiness gating and preview tracking
3407
+ readiness_state = gr.State(False)
3408
+ was_preview_state = gr.State(False)
3409
+ kpi_meta_state = gr.State({})
3410
+ last_seen_ts_state = gr.State(None) # Track last seen user timestamp
3411
+
3412
+ # Buffered states for all dynamic inputs
3413
+ model_type_state = gr.State(DEFAULT_MODEL)
3414
+ complexity_state = gr.State(2)
3415
+ feature_set_state = gr.State(DEFAULT_FEATURE_SET)
3416
+ data_size_state = gr.State(DEFAULT_DATA_SIZE)
3417
+
3418
+ rank_message_display = gr.Markdown("### Rank loading...")
3419
+ with gr.Row():
3420
+ with gr.Column(scale=1):
3421
+
3422
+ model_type_radio = gr.Radio(
3423
+ label="1. Model Strategy",
3424
+ choices=[],
3425
+ value=None,
3426
+ interactive=False
3427
+ )
3428
+ model_card_display = gr.Markdown(get_model_card(DEFAULT_MODEL))
3429
+
3430
+ gr.Markdown("---") # Separator
3431
+
3432
+ complexity_slider = gr.Slider(
3433
+ label="2. Model Complexity (1–10)",
3434
+ minimum=1, maximum=3, step=1, value=2,
3435
+ info="Higher values allow deeper pattern learning; very high values may overfit."
3436
+ )
3437
+
3438
+ gr.Markdown("---") # Separator
3439
+
3440
+ feature_set_checkbox = gr.CheckboxGroup(
3441
+ label="3. Select Data Ingredients",
3442
+ choices=FEATURE_SET_ALL_OPTIONS,
3443
+ value=DEFAULT_FEATURE_SET,
3444
+ interactive=False,
3445
+ info="More ingredients unlock as you rank up!"
3446
+ )
3447
+
3448
+ gr.Markdown("---") # Separator
3449
+
3450
+ data_size_radio = gr.Radio(
3451
+ label="4. Data Size",
3452
+ choices=[DEFAULT_DATA_SIZE],
3453
+ value=DEFAULT_DATA_SIZE,
3454
+ interactive=False
3455
+ )
3456
+
3457
+ gr.Markdown("---") # Separator
3458
+
3459
+ # Attempt tracker display
3460
+ attempts_tracker_display = gr.HTML(
3461
+ value="<div style='text-align:center; padding:8px; margin:8px 0; background:#f0f9ff; border-radius:8px; border:1px solid #bae6fd;'>"
3462
+ "<p style='margin:0; color:#0369a1; font-weight:600; font-size:1rem;'>📊 Attempts used: 0/10</p>"
3463
+ "</div>",
3464
+ visible=True
3465
+ )
3466
+
3467
+ submit_button = gr.Button(
3468
+ value="5. 🔬 Build & Submit Model",
3469
+ variant="primary",
3470
+ size="lg"
3471
+ )
3472
+
3473
+ with gr.Column(scale=1):
3474
+ gr.HTML(
3475
+ """
3476
+ <div class='leaderboard-box'>
3477
+ <h3 style='margin-top:0;'>🏆 Live Standings</h3>
3478
+ <p style='margin:0;'>Submit a model to see your rank.</p>
3479
+ </div>
3480
+ """
3481
+ )
3482
+
3483
+ # KPI Card
3484
+ submission_feedback_display = gr.HTML(
3485
+ "<p style='text-align:center; color:#6b7280; padding:20px 0;'>Submit your first model to get feedback!</p>"
3486
+ )
3487
+
3488
+ # Inline Login Components (initially hidden)
3489
+ login_username = gr.Textbox(
3490
+ label="Username",
3491
+ placeholder="Enter your modelshare.ai username",
3492
+ visible=False
3493
+ )
3494
+ login_password = gr.Textbox(
3495
+ label="Password",
3496
+ type="password",
3497
+ placeholder="Enter your password",
3498
+ visible=False
3499
+ )
3500
+ login_submit = gr.Button(
3501
+ "Sign In & Submit",
3502
+ variant="primary",
3503
+ visible=False
3504
+ )
3505
+ login_error = gr.HTML(
3506
+ value="",
3507
+ visible=False
3508
+ )
3509
+
3510
+ with gr.Tabs():
3511
+ with gr.TabItem("Team Standings"):
3512
+ team_leaderboard_display = gr.HTML(
3513
+ "<p style='text-align:center; color:#6b7280; padding-top:20px;'>Submit a model to see team rankings.</p>"
3514
+ )
3515
+ with gr.TabItem("Individual Standings"):
3516
+ individual_leaderboard_display = gr.HTML(
3517
+ "<p style='text-align:center; color:#6b7280; padding-top:20px;'>Submit a model to see individual rankings.</p>"
3518
+ )
3519
+
3520
+ # REMOVED: Ethical Reminder HTML Block
3521
+ step_2_next = gr.Button("Finish & Reflect ▶️", variant="secondary")
3522
+
3523
+ # Conclusion Step
3524
+ with gr.Column(visible=False, elem_id="conclusion-step") as conclusion_step:
3525
+ gr.Markdown("<h1 style='text-align:center;'>✅ Section Complete</h1>")
3526
+ final_score_display = gr.HTML(value="<p>Preparing final summary...</p>")
3527
+ step_3_back = gr.Button("◀️ Back to Experiment")
3528
+
3529
+ # --- Navigation Logic ---
3530
+ all_steps_nav = [
3531
+ intro_slide,
3532
+ model_building_step,
3533
+ conclusion_step,
3534
+ loading_screen
3535
+ ]
3536
+
3537
+ def create_nav(current_step, next_step):
3538
+ """
3539
+ Simplified navigation: directly switches visibility without artificial loading screen.
3540
+ Loading screen only shown when entering arena if not yet ready.
3541
+ """
3542
+ def _nav():
3543
+ # Direct single-step navigation
3544
+ updates = {next_step: gr.update(visible=True)}
3545
+ for s in all_steps_nav:
3546
+ if s != next_step:
3547
+ updates[s] = gr.update(visible=False)
3548
+ return updates
3549
+ return _nav
3550
+
3551
+ def finalize_and_show_conclusion(best_score, submissions, rank, first_score, feature_set):
3552
+ """Build dynamic conclusion HTML and navigate to conclusion step."""
3553
+ html = build_final_conclusion_html(best_score, submissions, rank, first_score, feature_set)
3554
+ updates = {
3555
+ conclusion_step: gr.update(visible=True),
3556
+ final_score_display: gr.update(value=html)
3557
+ }
3558
+ for s in all_steps_nav:
3559
+ if s != conclusion_step:
3560
+ updates[s] = gr.update(visible=False)
3561
+ return [updates[s] if s in updates else gr.update() for s in all_steps_nav] + [html]
3562
+
3563
+ # Helper function to generate navigation JS with loading overlay
3564
+ def nav_js(target_id: str, message: str, min_show_ms: int = 1200) -> str:
3565
+ """
3566
+ Generate JavaScript for enhanced slide navigation with loading overlay.
3567
+
3568
+ Args:
3569
+ target_id: Element ID of the target slide (e.g., 'slide-2', 'model-step')
3570
+ message: Loading message to display during transition
3571
+ min_show_ms: Minimum time to show overlay (prevents flicker)
3572
+
3573
+ Returns:
3574
+ JavaScript arrow function string for Gradio's js parameter
3575
+ """
3576
+ return f"""
3577
+ ()=>{{
3578
+ try {{
3579
+ // Show overlay immediately
3580
+ const overlay = document.getElementById('nav-loading-overlay');
3581
+ const messageEl = document.getElementById('nav-loading-text');
3582
+ if(overlay && messageEl) {{
3583
+ messageEl.textContent = '{message}';
3584
+ overlay.style.display = 'flex';
3585
+ setTimeout(() => {{ overlay.style.opacity = '1'; }}, 10);
3586
+ }}
3587
+
3588
+ const startTime = Date.now();
3589
+
3590
+ // Scroll to top after brief delay
3591
+ setTimeout(() => {{
3592
+ const anchor = document.getElementById('app_top_anchor');
3593
+ const container = document.querySelector('.gradio-container') || document.scrollingElement || document.documentElement;
3594
+
3595
+ function doScroll() {{
3596
+ if(anchor) {{ anchor.scrollIntoView({{behavior:'smooth', block:'start'}}); }}
3597
+ else {{ container.scrollTo({{top:0, behavior:'smooth'}}); }}
3598
+
3599
+ // Best-effort Colab iframe scroll
3600
+ try {{
3601
+ if(window.parent && window.parent !== window && window.frameElement) {{
3602
+ const top = window.frameElement.getBoundingClientRect().top + window.parent.scrollY;
3603
+ window.parent.scrollTo({{top: Math.max(top - 10, 0), behavior:'smooth'}});
3604
+ }}
3605
+ }} catch(e2) {{}}
3606
+ }}
3607
+
3608
+ doScroll();
3609
+ // Retry scroll to combat layout shifts
3610
+ let scrollAttempts = 0;
3611
+ const scrollInterval = setInterval(() => {{
3612
+ scrollAttempts++;
3613
+ doScroll();
3614
+ if(scrollAttempts >= 3) clearInterval(scrollInterval);
3615
+ }}, 130);
3616
+ }}, 40);
3617
+
3618
+ // Poll for target visibility and minimum display time
3619
+ const targetId = '{target_id}';
3620
+ const minShowMs = {min_show_ms};
3621
+ let pollCount = 0;
3622
+ const maxPolls = 77; // ~7 seconds max
3623
+
3624
+ const pollInterval = setInterval(() => {{
3625
+ pollCount++;
3626
+ const elapsed = Date.now() - startTime;
3627
+ const target = document.getElementById(targetId);
3628
+ const isVisible = target && target.offsetParent !== null &&
3629
+ window.getComputedStyle(target).display !== 'none';
3630
+
3631
+ // Hide overlay when target is visible AND minimum time elapsed
3632
+ if((isVisible && elapsed >= minShowMs) || pollCount >= maxPolls) {{
3633
+ clearInterval(pollInterval);
3634
+ if(overlay) {{
3635
+ overlay.style.opacity = '0';
3636
+ setTimeout(() => {{ overlay.style.display = 'none'; }}, 300);
3637
+ }}
3638
+ }}
3639
+ }}, 90);
3640
+
3641
+ }} catch(e) {{ console.warn('nav-js error', e); }}
3642
+ }}
3643
+ """
3644
+ # Final wiring
3645
+ intro_next_btn.click(
3646
+ fn=create_nav(intro_slide, model_building_step),
3647
+ inputs=None, outputs=all_steps_nav,
3648
+ js=nav_js("model-step", "Entering model arena...")
3649
+ )
3650
+
3651
+ # App -> Conclusion (unchanged)
3652
+ step_2_next.click(
3653
+ fn=finalize_and_show_conclusion,
3654
+ inputs=[
3655
+ best_score_state,
3656
+ submission_count_state,
3657
+ last_rank_state,
3658
+ first_submission_score_state,
3659
+ feature_set_state
3660
+ ],
3661
+ outputs=all_steps_nav + [final_score_display],
3662
+ js=nav_js("conclusion-step", "Generating performance summary...")
3663
+ )
3664
+
3665
+ # Conclusion -> App (unchanged)
3666
+ step_3_back.click(
3667
+ fn=create_nav(conclusion_step, model_building_step),
3668
+ inputs=None, outputs=all_steps_nav,
3669
+ js=nav_js("model-step", "Returning to experiment workspace...")
3670
+ )
3671
+
3672
+ # Events
3673
+ model_type_radio.change(
3674
+ fn=get_model_card,
3675
+ inputs=model_type_radio,
3676
+ outputs=model_card_display
3677
+ )
3678
+ model_type_radio.change(
3679
+ fn=lambda v: v or DEFAULT_MODEL,
3680
+ inputs=model_type_radio,
3681
+ outputs=model_type_state
3682
+ )
3683
+ complexity_slider.change(fn=lambda v: v, inputs=complexity_slider, outputs=complexity_state)
3684
+
3685
+ feature_set_checkbox.change(
3686
+ fn=lambda v: v or [],
3687
+ inputs=feature_set_checkbox,
3688
+ outputs=feature_set_state
3689
+ )
3690
+ data_size_radio.change(
3691
+ fn=lambda v: v or DEFAULT_DATA_SIZE,
3692
+ inputs=data_size_radio,
3693
+ outputs=data_size_state
3694
+ )
3695
+
3696
+ all_outputs = [
3697
+ submission_feedback_display,
3698
+ team_leaderboard_display,
3699
+ individual_leaderboard_display,
3700
+ last_submission_score_state,
3701
+ last_rank_state,
3702
+ best_score_state,
3703
+ submission_count_state,
3704
+ first_submission_score_state,
3705
+ rank_message_display,
3706
+ model_type_radio,
3707
+ complexity_slider,
3708
+ feature_set_checkbox,
3709
+ data_size_radio,
3710
+ submit_button,
3711
+ login_username,
3712
+ login_password,
3713
+ login_submit,
3714
+ login_error,
3715
+ attempts_tracker_display,
3716
+ was_preview_state,
3717
+ kpi_meta_state,
3718
+ last_seen_ts_state
3719
+ ]
3720
+
3721
+ # Wire up login button
3722
+ login_submit.click(
3723
+ fn=perform_inline_login,
3724
+ inputs=[login_username, login_password],
3725
+ outputs=[
3726
+ login_username,
3727
+ login_password,
3728
+ login_submit,
3729
+ login_error,
3730
+ submit_button,
3731
+ submission_feedback_display,
3732
+ team_name_state,
3733
+ username_state, # NEW
3734
+ token_state # NEW
3735
+ ]
3736
+ )
3737
+
3738
+ # Removed gr.State(username) from the inputs list
3739
+ submit_button.click(
3740
+ fn=run_experiment,
3741
+ inputs=[
3742
+ model_type_state,
3743
+ complexity_state,
3744
+ feature_set_state,
3745
+ data_size_state,
3746
+ team_name_state,
3747
+ last_submission_score_state,
3748
+ last_rank_state,
3749
+ submission_count_state,
3750
+ first_submission_score_state,
3751
+ best_score_state,
3752
+ username_state, # NEW: Session-based auth
3753
+ token_state, # NEW: Session-based auth
3754
+ readiness_state, # Renamed to readiness_flag in function signature
3755
+ was_preview_state, # Renamed to was_preview_prev in function signature
3756
+ # kpi_meta_state removed from inputs - used only as output
3757
+ ],
3758
+ outputs=all_outputs,
3759
+ show_progress="full",
3760
+ js=nav_js("model-step", "Running experiment...", 500)
3761
+ )
3762
+
3763
+ # Timer for polling initialization status
3764
+ status_timer = gr.Timer(value=0.5, active=True) # Poll every 0.5 seconds
3765
+
3766
+ def update_init_status():
3767
+ """
3768
+ Poll initialization status and update UI elements.
3769
+ Returns status HTML, banner visibility, submit button state, data size choices, and readiness_state.
3770
+ """
3771
+ status_html, ready = poll_init_status()
3772
+
3773
+ # Update banner visibility - hide when ready
3774
+ banner_visible = not ready
3775
+
3776
+ # Update submit button
3777
+ if ready:
3778
+ submit_label = "5. 🔬 Build & Submit Model"
3779
+ submit_interactive = True
3780
+ else:
3781
+ submit_label = "⏳ Waiting for data..."
3782
+ submit_interactive = False
3783
+
3784
+ # Get available data sizes based on init progress
3785
+ available_sizes = get_available_data_sizes()
3786
+
3787
+ # Stop timer once fully initialized
3788
+ timer_active = not (ready and INIT_FLAGS.get("pre_samples_full", False))
3789
+
3790
+ return (
3791
+ status_html,
3792
+ gr.update(visible=banner_visible),
3793
+ gr.update(value=submit_label, interactive=submit_interactive),
3794
+ gr.update(choices=available_sizes),
3795
+ timer_active,
3796
+ ready # readiness_state
3797
+ )
3798
+
3799
+ status_timer.tick(
3800
+ fn=update_init_status,
3801
+ inputs=None,
3802
+ outputs=[init_status_display, init_banner, submit_button, data_size_radio, status_timer, readiness_state]
3803
+ )
3804
+
3805
+ # Handle session-based authentication on page load
3806
+ def handle_load_with_session_auth(request: "gr.Request"):
3807
+ """
3808
+ Check for session token, auto-login if present, then load initial UI with stats.
3809
+
3810
+ Concurrency Note: This function does NOT set per-user values in os.environ.
3811
+ All authentication state is returned via gr.State objects (username_state,
3812
+ token_state, team_name_state) to prevent cross-user data leakage.
3813
+ """
3814
+ success, username, token = _try_session_based_auth(request)
3815
+
3816
+ if success and username and token:
3817
+ _log(f"Session auth successful on load for {username}")
3818
+
3819
+ # Get user stats and team from cache/leaderboard
3820
+ stats = _compute_user_stats(username, token)
3821
+ team_name = stats.get("team_name", "")
3822
+
3823
+ # Concurrency Note: Do NOT set os.environ for per-user values.
3824
+ # Return state via gr.State objects exclusively.
3825
+
3826
+ # Hide login form since user is authenticated via session
3827
+ # Return initial load results plus login form hidden
3828
+ # Pass token explicitly for authenticated leaderboard fetch
3829
+ initial_results = on_initial_load(username, token=token, team_name=team_name)
3830
+ return initial_results + (
3831
+ gr.update(visible=False), # login_username
3832
+ gr.update(visible=False), # login_password
3833
+ gr.update(visible=False), # login_submit
3834
+ gr.update(visible=False), # login_error (hide any messages)
3835
+ username, # username_state
3836
+ token, # token_state
3837
+ team_name, # team_name_state
3838
+ )
3839
+ else:
3840
+ _log("No valid session on load, showing login form")
3841
+ # No valid session, proceed with normal load (show login form)
3842
+ # No token available, call without token
3843
+ initial_results = on_initial_load(None, token=None, team_name="")
3844
+ return initial_results + (
3845
+ gr.update(visible=True), # login_username
3846
+ gr.update(visible=True), # login_password
3847
+ gr.update(visible=True), # login_submit
3848
+ gr.update(visible=False), # login_error
3849
+ None, # username_state
3850
+ None, # token_state
3851
+ "", # team_name_state
3852
+ )
3853
+
3854
+ demo.load(
3855
+ fn=handle_load_with_session_auth,
3856
+ inputs=None, # Request is auto-injected
3857
+ outputs=[
3858
+ model_card_display,
3859
+ team_leaderboard_display,
3860
+ individual_leaderboard_display,
3861
+ rank_message_display,
3862
+ model_type_radio,
3863
+ complexity_slider,
3864
+ feature_set_checkbox,
3865
+ data_size_radio,
3866
+ login_username,
3867
+ login_password,
3868
+ login_submit,
3869
+ login_error,
3870
+ username_state, # NEW
3871
+ token_state, # NEW
3872
+ team_name_state, # NEW
3873
+ ]
3874
+ )
3875
+
3876
+ return demo
3877
+
3878
+ # -------------------------------------------------------------------------
3879
+ # 4. Convenience Launcher
3880
+ # -------------------------------------------------------------------------
3881
+
3882
+ def launch_model_building_game_es_final_app(height: int = 1200, share: bool = False, debug: bool = False) -> None:
3883
+ """
3884
+ Create and directly launch the Model Building Game app inline (e.g., in notebooks).
3885
+ """
3886
+ global playground, X_TRAIN_RAW, X_TEST_RAW, Y_TRAIN, Y_TEST
3887
+ if playground is None:
3888
+ try:
3889
+ playground = Competition(MY_PLAYGROUND_ID)
3890
+ except Exception as e:
3891
+ print(f"WARNING: Could not connect to playground: {e}")
3892
+ playground = None
3893
+
3894
+ if X_TRAIN_RAW is None:
3895
+ X_TRAIN_RAW, X_TEST_RAW, Y_TRAIN, Y_TEST = load_and_prep_data()
3896
+
3897
+ demo = create_model_building_game_es_final_app()
3898
+ port = int(os.environ.get("PORT", 8080))
3899
+ demo.launch(share=share, inline=True, debug=debug, height=height, server_port=port)