Wil2200 Claude Opus 4.6 commited on
Commit
f5dc9c2
·
1 Parent(s): 4b50f68

Add GMNL variants, IP tracking, BWS fix, session activity, UI polish

Browse files

- GMNL special forms dropdown: S-MNL (gamma=0), GMNL-II (gamma=1)
- Admin IP tracking with country/region geolocation and VPN detection
- BWS early validation (J>=3) and clean error display (no traceback)
- Session timeout: JS activity monitor (scroll/mouse/keyboard) keeps heartbeat alive
- Auth: replace real name example with fictional "Jordan McAllister"
- Home: remove duplicate WeChat donation pill (keep expander)
- Admin page moved to top-right "⋯" Developer Entry menu (admin only)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

app/Home.py CHANGED
@@ -174,18 +174,10 @@ with _bro_col2:
174
  )
175
 
176
  st.markdown(
177
- "<div style='display:flex; gap:12px;'>"
178
- # ── Buy Me a Coffee ──
179
  f"<a href='{_coffee_url}' target='_blank' style='"
180
- f"{_btn_style} flex:1; background:#FFDD00; color:#000;'>"
181
  f"<img src='{_bmc_logo}' width='22' style='flex-shrink:0;'/>"
182
- f"Buy Slowbro a Coffee</a>"
183
- # ── WeChat ──
184
- f"<div style='{_btn_style} flex:1; background:#07C160; color:#fff;' "
185
- f"id='wechat-tip-btn'>"
186
- f"<img src='{_wechat_logo}' width='22' style='flex-shrink:0;'/>"
187
- f"微信打赏</div>"
188
- "</div>",
189
  unsafe_allow_html=True,
190
  )
191
 
 
174
  )
175
 
176
  st.markdown(
 
 
177
  f"<a href='{_coffee_url}' target='_blank' style='"
178
+ f"{_btn_style} max-width:300px; background:#FFDD00; color:#000;'>"
179
  f"<img src='{_bmc_logo}' width='22' style='flex-shrink:0;'/>"
180
+ f"Buy Slowbro a Coffee</a>",
 
 
 
 
 
 
181
  unsafe_allow_html=True,
182
  )
183
 
app/auth.py CHANGED
@@ -7,7 +7,7 @@ Supports two methods:
7
  MUCHE team members are stored as a whitelist.
8
  First login: match name + set password to claim account.
9
  Subsequent logins: match name + enter password.
10
- Fuzzy matching allows minor name variations (e.g. "Nora" matches "Noura").
11
 
12
  Toggle with PREFERO_AUTH_ENABLED env var ("true" to enable).
13
  """
@@ -213,7 +213,7 @@ def auth_gate() -> bool:
213
  name_input = st.text_input(
214
  "Your name",
215
  key="_auth_member_name",
216
- placeholder="e.g. Noura Saba",
217
  )
218
 
219
  matched_name = None
 
7
  MUCHE team members are stored as a whitelist.
8
  First login: match name + set password to claim account.
9
  Subsequent logins: match name + enter password.
10
+ Fuzzy matching allows minor name variations (e.g. "John" matches "Jonathan").
11
 
12
  Toggle with PREFERO_AUTH_ENABLED env var ("true" to enable).
13
  """
 
213
  name_input = st.text_input(
214
  "Your name",
215
  key="_auth_member_name",
216
+ placeholder="e.g. Jordan McAllister",
217
  )
218
 
219
  matched_name = None
app/community_db.py CHANGED
@@ -17,10 +17,82 @@ import sqlite3
17
  import threading
18
  import time
19
  from dataclasses import dataclass
 
20
  from pathlib import Path
21
 
22
  logger = logging.getLogger(__name__)
23
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  # ---------------------------------------------------------------------------
25
  # HF Dataset persistence for user accounts
26
  # ---------------------------------------------------------------------------
@@ -200,6 +272,24 @@ def init_db() -> None:
200
  CREATE INDEX IF NOT EXISTS idx_activity_time ON activity_log(timestamp DESC);
201
  """)
202
  conn.commit()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
203
  # Load persisted users from HF on first startup
204
  _sync_users_from_hf()
205
 
@@ -277,13 +367,22 @@ def create_user(username: str, email: str = "") -> User:
277
 
278
 
279
  def log_activity(username: str, action: str, details: str = "") -> None:
280
- """Record an activity event."""
281
  try:
282
  conn = _get_conn()
 
 
283
  conn.execute(
284
- "INSERT INTO activity_log (timestamp, username, action, details) "
285
- "VALUES (?, ?, ?, ?)",
286
- (time.time(), username, action, details),
 
 
 
 
 
 
 
287
  )
288
  conn.commit()
289
  except Exception as exc:
 
17
  import threading
18
  import time
19
  from dataclasses import dataclass
20
+ from functools import lru_cache
21
  from pathlib import Path
22
 
23
  logger = logging.getLogger(__name__)
24
 
25
+
26
+ # ---------------------------------------------------------------------------
27
+ # IP geolocation helpers
28
+ # ---------------------------------------------------------------------------
29
+
30
+ # In-memory cache: ip -> {country, region, city, org, is_vpn}
31
+ _geo_cache: dict[str, dict] = {}
32
+ _GEO_CACHE_MAX = 500
33
+
34
+
35
+ def get_client_ip() -> str | None:
36
+ """Extract client IP from Streamlit request headers (X-Forwarded-For)."""
37
+ try:
38
+ import streamlit as st
39
+ xff = st.context.headers.get("x-forwarded-for")
40
+ if xff and isinstance(xff, str):
41
+ return xff.split(",")[0].strip()
42
+ return None
43
+ except Exception:
44
+ return None
45
+
46
+
47
+ def geolocate_ip(ip: str | None) -> dict:
48
+ """Look up country, region, org for an IP via ipapi.co (free, 30k/month).
49
+
50
+ Returns dict with keys: country, region, city, org, is_vpn.
51
+ Results are cached in-memory.
52
+ """
53
+ if not ip:
54
+ return {}
55
+ if ip in _geo_cache:
56
+ return _geo_cache[ip]
57
+
58
+ result: dict = {}
59
+ try:
60
+ import requests
61
+ resp = requests.get(
62
+ f"https://ipapi.co/{ip}/json/",
63
+ timeout=3,
64
+ headers={"User-Agent": "Prefero/1.0"},
65
+ )
66
+ if resp.status_code == 200:
67
+ data = resp.json()
68
+ if not data.get("error"):
69
+ org = data.get("org", "")
70
+ # Heuristic VPN/datacenter detection: check org name
71
+ _vpn_keywords = [
72
+ "vpn", "proxy", "tunnel", "hosting", "datacenter",
73
+ "data center", "cloud", "server", "digitalocean",
74
+ "amazon", "aws", "google cloud", "microsoft azure",
75
+ "linode", "vultr", "ovh", "hetzner", "mullvad",
76
+ "nordvpn", "expressvpn", "surfshark", "proton",
77
+ ]
78
+ is_vpn = any(kw in org.lower() for kw in _vpn_keywords)
79
+ result = {
80
+ "country": data.get("country_name", ""),
81
+ "country_code": data.get("country_code", ""),
82
+ "region": data.get("region", ""),
83
+ "city": data.get("city", ""),
84
+ "org": org,
85
+ "is_vpn": is_vpn,
86
+ }
87
+ except Exception as exc:
88
+ logger.debug("Geo lookup failed for %s: %s", ip, exc)
89
+
90
+ # Cache result (even empty, to avoid repeated failed lookups)
91
+ if len(_geo_cache) < _GEO_CACHE_MAX:
92
+ _geo_cache[ip] = result
93
+ return result
94
+
95
+
96
  # ---------------------------------------------------------------------------
97
  # HF Dataset persistence for user accounts
98
  # ---------------------------------------------------------------------------
 
272
  CREATE INDEX IF NOT EXISTS idx_activity_time ON activity_log(timestamp DESC);
273
  """)
274
  conn.commit()
275
+ # Schema migration: add IP/geo columns to activity_log if missing
276
+ try:
277
+ conn.execute("ALTER TABLE activity_log ADD COLUMN ip_address TEXT DEFAULT ''")
278
+ except sqlite3.OperationalError:
279
+ pass # column already exists
280
+ try:
281
+ conn.execute("ALTER TABLE activity_log ADD COLUMN country TEXT DEFAULT ''")
282
+ except sqlite3.OperationalError:
283
+ pass
284
+ try:
285
+ conn.execute("ALTER TABLE activity_log ADD COLUMN region TEXT DEFAULT ''")
286
+ except sqlite3.OperationalError:
287
+ pass
288
+ try:
289
+ conn.execute("ALTER TABLE activity_log ADD COLUMN is_vpn INTEGER DEFAULT 0")
290
+ except sqlite3.OperationalError:
291
+ pass
292
+ conn.commit()
293
  # Load persisted users from HF on first startup
294
  _sync_users_from_hf()
295
 
 
367
 
368
 
369
  def log_activity(username: str, action: str, details: str = "") -> None:
370
+ """Record an activity event with IP geolocation."""
371
  try:
372
  conn = _get_conn()
373
+ ip = get_client_ip()
374
+ geo = geolocate_ip(ip) if ip else {}
375
  conn.execute(
376
+ "INSERT INTO activity_log "
377
+ "(timestamp, username, action, details, ip_address, country, region, is_vpn) "
378
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
379
+ (
380
+ time.time(), username, action, details,
381
+ ip or "",
382
+ geo.get("country", ""),
383
+ geo.get("region", ""),
384
+ 1 if geo.get("is_vpn") else 0,
385
+ ),
386
  )
387
  conn.commit()
388
  except Exception as exc:
app/pages/2_⚙️_Model.py CHANGED
@@ -333,6 +333,25 @@ elif model_type == "gmnl":
333
  "Nests both S-MNL (pure scale) and MMNL as special cases."
334
  )
335
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
336
  st.markdown("**Distribution assumptions**")
337
  st.caption("Set distributions for each variable. At least one random variable is required.")
338
  with st.expander("What do the distribution options mean?"):
@@ -369,11 +388,13 @@ elif model_type == "gmnl":
369
 
370
  with st.expander("About GMNL scale parameters"):
371
  st.markdown(
372
- "The GMNL model estimates three additional parameters:\n"
373
  "- **tau** (scale mean): controls the average scale of utility.\n"
374
  "- **sigma_tau** (scale SD): individual variation in scale.\n"
375
  "- **gamma** (mixing, 0-1): gamma=0 is pure scale heterogeneity (S-MNL), "
376
- "gamma=1 is GMNL-II (closest to standard MMNL)."
 
 
377
  )
378
 
379
  # ── Correlation structure for GMNL ────────────────────────────────
@@ -577,6 +598,8 @@ else: # latent_class
577
  if model_type not in ("mixed", "gmnl"):
578
  mxl_correlated = False
579
  mxl_correlation_groups = None
 
 
580
  if model_type != "latent_class":
581
  lc_membership_cols = []
582
  lc_method = "em"
@@ -939,6 +962,9 @@ def _suggest_model_name() -> str:
939
  """Build a descriptive default name from the current model configuration."""
940
  prefix_map = {"conditional": "CL", "mixed": "MXL", "gmnl": "GMNL", "latent_class": "LC"}
941
  prefix = prefix_map[model_type]
 
 
 
942
  parts: list[str] = [prefix]
943
 
944
  if model_type in ("mixed", "gmnl"):
@@ -1071,9 +1097,10 @@ def _build_param_names() -> list[dict[str, str]]:
1071
  rows.append({"#": str(idx), "Parameter": "raw_sigma_tau",
1072
  "Description": "Scale SD (raw, softplus-transformed)"})
1073
  idx += 1
1074
- rows.append({"#": str(idx), "Parameter": "raw_gamma",
1075
- "Description": "Mixing parameter (raw, sigmoid -> [0,1])"})
1076
- idx += 1
 
1077
 
1078
  elif model_type == "latent_class":
1079
  for q in range(1, n_classes + 1):
@@ -1188,6 +1215,7 @@ if st.button("Run Estimation", type="primary", use_container_width=True):
1188
  interactions=_het_interactions,
1189
  correlated=mxl_correlated,
1190
  correlation_groups=mxl_correlation_groups,
 
1191
  bws_worst_col=bws_worst_col if bws_mode else None,
1192
  estimate_lambda_w=bws_estimate_lambda_w if bws_mode else True,
1193
  n_classes=int(n_classes),
@@ -1200,6 +1228,16 @@ if st.button("Run Estimation", type="primary", use_container_width=True):
1200
  custom_start=_parsed_custom_start,
1201
  )
1202
 
 
 
 
 
 
 
 
 
 
 
1203
  # Pass the original DataFrame; backend handles dummy expansion
1204
  est_df = df
1205
 
@@ -1273,10 +1311,14 @@ if st.button("Run Estimation", type="primary", use_container_width=True):
1273
  st.error(f"Estimation failed: {exc}")
1274
  try:
1275
  from community_db import log_activity
 
 
1276
  log_activity(st.session_state.get("username", "anonymous"), "estimation_error", f"model={model_type}, error={str(exc)[:200]}")
 
 
 
1277
  except Exception:
1278
  pass
1279
- st.exception(exc)
1280
  st.stop()
1281
 
1282
  st.session_state["_estimation_running"] = False
 
333
  "Nests both S-MNL (pure scale) and MMNL as special cases."
334
  )
335
 
336
+ gmnl_variant_label = st.selectbox(
337
+ "GMNL variant",
338
+ ["GMNL (General)", "S-MNL (gamma=0)", "GMNL-II (gamma=1)"],
339
+ key="gmnl_variant_select",
340
+ help=(
341
+ "**GMNL (General):** gamma is freely estimated (Fiebig et al. 2010). "
342
+ "**S-MNL:** gamma fixed at 0 — pure scale heterogeneity only; "
343
+ "all taste variation comes from individual scale differences. "
344
+ "**GMNL-II:** gamma fixed at 1 — scale multiplies the full "
345
+ "individual-specific beta (mean + deviation)."
346
+ ),
347
+ )
348
+ _gmnl_variant_map = {
349
+ "GMNL (General)": "general",
350
+ "S-MNL (gamma=0)": "s-mnl",
351
+ "GMNL-II (gamma=1)": "gmnl-ii",
352
+ }
353
+ gmnl_variant = _gmnl_variant_map[gmnl_variant_label]
354
+
355
  st.markdown("**Distribution assumptions**")
356
  st.caption("Set distributions for each variable. At least one random variable is required.")
357
  with st.expander("What do the distribution options mean?"):
 
388
 
389
  with st.expander("About GMNL scale parameters"):
390
  st.markdown(
391
+ "The GMNL model estimates additional scale parameters:\n"
392
  "- **tau** (scale mean): controls the average scale of utility.\n"
393
  "- **sigma_tau** (scale SD): individual variation in scale.\n"
394
  "- **gamma** (mixing, 0-1): gamma=0 is pure scale heterogeneity (S-MNL), "
395
+ "gamma=1 is GMNL-II (closest to standard MMNL).\n\n"
396
+ "When using **S-MNL** or **GMNL-II**, gamma is fixed and not estimated — "
397
+ "reducing the parameter count by 1."
398
  )
399
 
400
  # ── Correlation structure for GMNL ────────────────────────────────
 
598
  if model_type not in ("mixed", "gmnl"):
599
  mxl_correlated = False
600
  mxl_correlation_groups = None
601
+ if model_type != "gmnl":
602
+ gmnl_variant = "general"
603
  if model_type != "latent_class":
604
  lc_membership_cols = []
605
  lc_method = "em"
 
962
  """Build a descriptive default name from the current model configuration."""
963
  prefix_map = {"conditional": "CL", "mixed": "MXL", "gmnl": "GMNL", "latent_class": "LC"}
964
  prefix = prefix_map[model_type]
965
+ if model_type == "gmnl" and gmnl_variant != "general":
966
+ variant_labels = {"s-mnl": "S-MNL", "gmnl-ii": "GMNL-II"}
967
+ prefix = variant_labels.get(gmnl_variant, prefix)
968
  parts: list[str] = [prefix]
969
 
970
  if model_type in ("mixed", "gmnl"):
 
1097
  rows.append({"#": str(idx), "Parameter": "raw_sigma_tau",
1098
  "Description": "Scale SD (raw, softplus-transformed)"})
1099
  idx += 1
1100
+ if gmnl_variant == "general":
1101
+ rows.append({"#": str(idx), "Parameter": "raw_gamma",
1102
+ "Description": "Mixing parameter (raw, sigmoid -> [0,1])"})
1103
+ idx += 1
1104
 
1105
  elif model_type == "latent_class":
1106
  for q in range(1, n_classes + 1):
 
1215
  interactions=_het_interactions,
1216
  correlated=mxl_correlated,
1217
  correlation_groups=mxl_correlation_groups,
1218
+ gmnl_variant=gmnl_variant,
1219
  bws_worst_col=bws_worst_col if bws_mode else None,
1220
  estimate_lambda_w=bws_estimate_lambda_w if bws_mode else True,
1221
  n_classes=int(n_classes),
 
1228
  custom_start=_parsed_custom_start,
1229
  )
1230
 
1231
+ # ── Early validation: BWS requires J >= 3 ──────────────────
1232
+ if bws_mode:
1233
+ _n_alts_per_task = df.groupby([id_col, task_col]).size()
1234
+ if _n_alts_per_task.min() < 3:
1235
+ st.error(
1236
+ "BWS requires at least 3 alternatives per task (J >= 3). "
1237
+ f"Your data has tasks with only {_n_alts_per_task.min()} alternatives."
1238
+ )
1239
+ st.stop()
1240
+
1241
  # Pass the original DataFrame; backend handles dummy expansion
1242
  est_df = df
1243
 
 
1311
  st.error(f"Estimation failed: {exc}")
1312
  try:
1313
  from community_db import log_activity
1314
+ import traceback as _tb
1315
+ _tb_str = _tb.format_exc()
1316
  log_activity(st.session_state.get("username", "anonymous"), "estimation_error", f"model={model_type}, error={str(exc)[:200]}")
1317
+ logger_msg = f"Estimation error: {_tb_str}"
1318
+ import logging
1319
+ logging.getLogger("prefero").warning(logger_msg)
1320
  except Exception:
1321
  pass
 
1322
  st.stop()
1323
 
1324
  st.session_state["_estimation_running"] = False
app/pages/8_🔒_Admin.py CHANGED
@@ -223,12 +223,22 @@ try:
223
  log_data = []
224
  for r in log_rows:
225
  ts = datetime.fromtimestamp(r["timestamp"], tz=timezone.utc)
226
- log_data.append({
227
  "Timestamp": ts.strftime("%Y-%m-%d %H:%M:%S UTC"),
228
  "Username": r["username"],
229
  "Action": r["action"],
230
  "Details": r["details"],
231
- })
 
 
 
 
 
 
 
 
 
 
232
  log_df = pd.DataFrame(log_data)
233
  st.dataframe(log_df, use_container_width=True, hide_index=True)
234
 
@@ -244,6 +254,44 @@ try:
244
  except Exception as _exc:
245
  st.info(f"Activity timeline not available: {_exc}")
246
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
247
  # ── 2b. Model Usage Stats ───────────────────────────────────────
248
 
249
  st.header("Model Usage Stats")
 
223
  log_data = []
224
  for r in log_rows:
225
  ts = datetime.fromtimestamp(r["timestamp"], tz=timezone.utc)
226
+ row = {
227
  "Timestamp": ts.strftime("%Y-%m-%d %H:%M:%S UTC"),
228
  "Username": r["username"],
229
  "Action": r["action"],
230
  "Details": r["details"],
231
+ }
232
+ # IP/geo columns (may not exist in older records)
233
+ ip_val = r["ip_address"] if "ip_address" in r.keys() else ""
234
+ country_val = r["country"] if "country" in r.keys() else ""
235
+ region_val = r["region"] if "region" in r.keys() else ""
236
+ vpn_val = r["is_vpn"] if "is_vpn" in r.keys() else 0
237
+ row["IP"] = ip_val or ""
238
+ row["Country"] = country_val or ""
239
+ row["Region"] = region_val or ""
240
+ row["VPN"] = "Yes" if vpn_val else ""
241
+ log_data.append(row)
242
  log_df = pd.DataFrame(log_data)
243
  st.dataframe(log_df, use_container_width=True, hide_index=True)
244
 
 
254
  except Exception as _exc:
255
  st.info(f"Activity timeline not available: {_exc}")
256
 
257
+ # ── 2a-2. Geographic Overview ──────────────────────────────────
258
+
259
+ st.header("Geographic Overview")
260
+
261
+ try:
262
+ geo_rows = conn.execute(
263
+ "SELECT country, region, ip_address, is_vpn, COUNT(*) as cnt "
264
+ "FROM activity_log WHERE country != '' GROUP BY country ORDER BY cnt DESC"
265
+ ).fetchall()
266
+ if geo_rows:
267
+ geo_data = [
268
+ {"Country": r["country"], "Activities": r["cnt"]}
269
+ for r in geo_rows
270
+ ]
271
+ geo_df = pd.DataFrame(geo_data)
272
+ g1, g2 = st.columns(2)
273
+ with g1:
274
+ st.dataframe(geo_df, use_container_width=True, hide_index=True)
275
+ with g2:
276
+ if _has_plotly:
277
+ fig_geo = px.pie(geo_df, names="Country", values="Activities", title="Activities by Country")
278
+ st.plotly_chart(fig_geo, use_container_width=True, key="admin_geo_pie")
279
+
280
+ vpn_total = conn.execute(
281
+ "SELECT SUM(is_vpn) as vpn_cnt, COUNT(*) as total "
282
+ "FROM activity_log WHERE ip_address != ''"
283
+ ).fetchone()
284
+ if vpn_total and vpn_total["total"] > 0:
285
+ vpn_count = int(vpn_total["vpn_cnt"] or 0)
286
+ total_with_ip = int(vpn_total["total"])
287
+ v1, v2 = st.columns(2)
288
+ v1.metric("Tracked requests (with IP)", total_with_ip)
289
+ v2.metric("Suspected VPN/Datacenter", f"{vpn_count} ({vpn_count/total_with_ip*100:.1f}%)" if total_with_ip else "0")
290
+ else:
291
+ st.info("No geographic data yet. IP tracking starts collecting data on the next user activity.")
292
+ except Exception as _exc:
293
+ st.info(f"Geographic data not available: {_exc}")
294
+
295
  # ── 2b. Model Usage Stats ───────────────────────────────────────
296
 
297
  st.header("Model Usage Stats")
app/utils.py CHANGED
@@ -87,6 +87,47 @@ def _check_session_timeout() -> None:
87
  st.stop()
88
 
89
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
90
  def init_session_state() -> None:
91
  """Ensure every shared session-state key exists with its default value."""
92
  for key, default in _SESSION_DEFAULTS.items():
@@ -99,6 +140,7 @@ def init_session_state() -> None:
99
  require_queue_slot()
100
  queue_heartbeat()
101
  st.session_state["_queue_admitted"] = True
 
102
  import inspect
103
  _caller = inspect.stack()[1].filename
104
  _page = Path(_caller).stem
@@ -118,12 +160,96 @@ def data_is_loaded() -> bool:
118
  return st.session_state.get("df") is not None
119
 
120
 
 
 
 
 
 
 
 
 
 
 
 
121
  def slowbro_status() -> None:
122
  """Inject global Slowbro status widget into Streamlit's fixed header."""
123
  _slowbro_img = (
124
  "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites"
125
  "/pokemon/other/official-artwork/80.png"
126
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
127
  st.markdown(
128
  f"""
129
  <style>
@@ -150,7 +276,9 @@ def slowbro_status() -> None:
150
  white-space: nowrap;
151
  z-index: 999;
152
  }}
 
153
  </style>
 
154
  """,
155
  unsafe_allow_html=True,
156
  )
 
87
  st.stop()
88
 
89
 
90
+ def _inject_activity_heartbeat() -> None:
91
+ """Inject JavaScript that detects user activity and auto-refreshes to keep the session alive.
92
+
93
+ Monitors scroll, mouse movement, keyboard, and touch events on the parent
94
+ document. If activity is detected within the last 90 seconds, triggers a
95
+ lightweight page reload every 90 seconds. Streamlit session state persists
96
+ across reloads, so the heartbeat() call in init_session_state() naturally
97
+ refreshes the server-side timestamp.
98
+ """
99
+ import os
100
+ if os.environ.get("PREFERO_QUEUE_ENABLED", "").lower() != "true":
101
+ return # no queue → no need for activity tracking
102
+
103
+ import streamlit.components.v1 as _components
104
+ _components.html(
105
+ """
106
+ <script>
107
+ (function() {
108
+ var INTERVAL = 90000; // 90 seconds
109
+ var lastActivity = Date.now();
110
+ var events = ['scroll', 'mousemove', 'keydown', 'touchstart', 'click', 'wheel'];
111
+ var doc;
112
+ try { doc = window.parent.document; } catch(e) { return; }
113
+
114
+ events.forEach(function(e) {
115
+ doc.addEventListener(e, function() { lastActivity = Date.now(); }, {passive: true});
116
+ });
117
+
118
+ setInterval(function() {
119
+ if (Date.now() - lastActivity < INTERVAL) {
120
+ // User was active — trigger Streamlit rerun to refresh heartbeat
121
+ try { window.parent.location.reload(); } catch(e) {}
122
+ }
123
+ }, INTERVAL);
124
+ })();
125
+ </script>
126
+ """,
127
+ height=0,
128
+ )
129
+
130
+
131
  def init_session_state() -> None:
132
  """Ensure every shared session-state key exists with its default value."""
133
  for key, default in _SESSION_DEFAULTS.items():
 
140
  require_queue_slot()
141
  queue_heartbeat()
142
  st.session_state["_queue_admitted"] = True
143
+ _inject_activity_heartbeat()
144
  import inspect
145
  _caller = inspect.stack()[1].filename
146
  _page = Path(_caller).stem
 
160
  return st.session_state.get("df") is not None
161
 
162
 
163
+ def _is_admin_user() -> bool:
164
+ """Check if the current user has admin privileges."""
165
+ import os
166
+ admin_csv = os.environ.get("PREFERO_ADMIN_USERS", "")
167
+ if not admin_csv.strip():
168
+ return False
169
+ admin_users = {u.strip().lower() for u in admin_csv.split(",") if u.strip()}
170
+ current = st.session_state.get("username", "").strip().lower()
171
+ return current in admin_users
172
+
173
+
174
  def slowbro_status() -> None:
175
  """Inject global Slowbro status widget into Streamlit's fixed header."""
176
  _slowbro_img = (
177
  "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites"
178
  "/pokemon/other/official-artwork/80.png"
179
  )
180
+
181
+ # Build optional developer entry menu (admin only)
182
+ _dev_menu_css = ""
183
+ _dev_menu_html = ""
184
+ if _is_admin_user():
185
+ _dev_menu_css = """
186
+ /* Hide Admin page from sidebar nav */
187
+ [data-testid="stSidebarNav"] a[href*="Admin"] {
188
+ display: none !important;
189
+ }
190
+ /* Developer entry ⋯ menu */
191
+ .dev-entry {
192
+ position: fixed;
193
+ top: 10px;
194
+ right: 14px;
195
+ z-index: 1001;
196
+ }
197
+ .dev-entry-btn {
198
+ background: rgba(50,50,50,0.7);
199
+ border: none;
200
+ color: #999;
201
+ font-size: 16px;
202
+ cursor: pointer;
203
+ border-radius: 6px;
204
+ width: 30px;
205
+ height: 30px;
206
+ display: flex;
207
+ align-items: center;
208
+ justify-content: center;
209
+ transition: background 0.15s;
210
+ }
211
+ .dev-entry-btn:hover {
212
+ background: rgba(80,80,80,0.9);
213
+ color: #ddd;
214
+ }
215
+ .dev-entry-dropdown {
216
+ display: none;
217
+ position: absolute;
218
+ right: 0;
219
+ top: 34px;
220
+ background: #1e1e1e;
221
+ border: 1px solid #333;
222
+ border-radius: 8px;
223
+ padding: 4px 0;
224
+ min-width: 170px;
225
+ box-shadow: 0 4px 16px rgba(0,0,0,0.4);
226
+ }
227
+ .dev-entry:hover .dev-entry-dropdown {
228
+ display: block;
229
+ }
230
+ .dev-entry-dropdown a {
231
+ display: flex;
232
+ align-items: center;
233
+ gap: 8px;
234
+ padding: 8px 14px;
235
+ color: #ccc;
236
+ text-decoration: none;
237
+ font-size: 0.82rem;
238
+ }
239
+ .dev-entry-dropdown a:hover {
240
+ background: rgba(255,255,255,0.08);
241
+ color: #fff;
242
+ }
243
+ """
244
+ _dev_menu_html = """
245
+ <div class="dev-entry">
246
+ <div class="dev-entry-btn">⋯</div>
247
+ <div class="dev-entry-dropdown">
248
+ <a href="/🔒_Admin" target="_self">🔧 Developer Entry</a>
249
+ </div>
250
+ </div>
251
+ """
252
+
253
  st.markdown(
254
  f"""
255
  <style>
 
276
  white-space: nowrap;
277
  z-index: 999;
278
  }}
279
+ {_dev_menu_css}
280
  </style>
281
+ {_dev_menu_html}
282
  """,
283
  unsafe_allow_html=True,
284
  )
src/dce_analyzer/config.py CHANGED
@@ -130,6 +130,9 @@ class FullModelSpec:
130
  bws_worst_col: str | None = None
131
  estimate_lambda_w: bool = True
132
 
 
 
 
133
  # Latent class
134
  n_classes: int = 2
135
  membership_cols: list[str] | None = None
 
130
  bws_worst_col: str | None = None
131
  estimate_lambda_w: bool = True
132
 
133
+ # GMNL variant: "general" (gamma free), "s-mnl" (gamma=0), "gmnl-ii" (gamma=1)
134
+ gmnl_variant: str = "general"
135
+
136
  # Latent class
137
  n_classes: int = 2
138
  membership_cols: list[str] | None = None
src/dce_analyzer/model.py CHANGED
@@ -716,6 +716,7 @@ class GmnlEstimator(MixedLogitEstimator):
716
  bws_data: BwsData | None = None,
717
  correlated: bool = False,
718
  correlation_groups: list[list[int]] | None = None,
 
719
  ) -> None:
720
  super().__init__(
721
  tensors=tensors,
@@ -727,6 +728,8 @@ class GmnlEstimator(MixedLogitEstimator):
727
  correlation_groups=correlation_groups,
728
  bws_data=bws_data,
729
  )
 
 
730
  # Extra Halton draws for scale heterogeneity (one dim per individual per draw)
731
  scale_draws_np = generate_halton_draws(
732
  n_individuals=self.n_individuals,
@@ -738,11 +741,17 @@ class GmnlEstimator(MixedLogitEstimator):
738
  scale_draws_np[:, :, 0], dtype=torch.float32, device=self.device
739
  ) # (n_individuals, n_draws)
740
 
741
- # Indices for the three extra GMNL parameters appended after MMNL params
742
  self._tau_idx = self.n_params # tau (scale mean)
743
  self._sigma_tau_idx = self.n_params + 1 # raw sigma_tau (scale SD, softplus)
744
- self._gamma_idx = self.n_params + 2 # raw gamma (sigmoid -> [0,1])
745
- self.n_params += 3
 
 
 
 
 
 
746
 
747
  def _initial_theta(self) -> np.ndarray:
748
  # Delegate to parent for MMNL params (handles both independent & correlated)
@@ -750,10 +759,12 @@ class GmnlEstimator(MixedLogitEstimator):
750
  # Extend to include GMNL-specific params (already allocated in n_params)
751
  if len(theta0) < self.n_params:
752
  theta0 = np.concatenate([theta0, np.zeros(self.n_params - len(theta0), dtype=np.float64)])
753
- # tau=0 -> mean scale=1, raw_sigma_tau=-1 -> small scale SD, raw_gamma=0 -> gamma=0.5
754
  theta0[self._tau_idx] = 0.0
755
  theta0[self._sigma_tau_idx] = -1.0
756
- theta0[self._gamma_idx] = 0.0
 
 
757
  return theta0
758
 
759
  def _betas_from_theta(self, theta: torch.Tensor) -> torch.Tensor:
@@ -768,7 +779,10 @@ class GmnlEstimator(MixedLogitEstimator):
768
 
769
  tau = theta[self._tau_idx]
770
  sigma_tau = _positive(theta[self._sigma_tau_idx])
771
- gamma = torch.sigmoid(theta[self._gamma_idx])
 
 
 
772
 
773
  # Scale factor: sigma_i = exp(tau + sigma_tau * epsilon_i)
774
  # shape: (n_individuals, n_draws)
@@ -819,23 +833,32 @@ class GmnlEstimator(MixedLogitEstimator):
819
  tau_est = float(theta_hat[self._tau_idx])
820
  raw_sigma_tau = theta_hat[self._sigma_tau_idx]
821
  sigma_tau_est = float(np.logaddexp(0.0, raw_sigma_tau) + 1e-6)
822
- raw_gamma = theta_hat[self._gamma_idx]
823
- gamma_est = float(1.0 / (1.0 + np.exp(-raw_gamma)))
824
 
825
  se_tau = float("nan")
826
  se_sigma_tau = float("nan")
827
- se_gamma = float("nan")
828
  if vcov is not None:
829
  se_tau = float(np.sqrt(max(vcov[self._tau_idx, self._tau_idx], 0.0)))
830
  var_raw_st = vcov[self._sigma_tau_idx, self._sigma_tau_idx]
831
  deriv_st = self._softplus_derivative(raw_sigma_tau)
832
  se_sigma_tau = float(abs(deriv_st) * np.sqrt(max(var_raw_st, 0.0)))
833
- var_raw_g = vcov[self._gamma_idx, self._gamma_idx]
834
- deriv_g = gamma_est * (1.0 - gamma_est) # sigmoid derivative
835
- se_gamma = float(abs(deriv_g) * np.sqrt(max(var_raw_g, 0.0)))
836
 
837
  rows.append(self._make_row("tau (scale mean)", "scale", tau_est, se_tau, theta_index=self._tau_idx))
838
  rows.append(self._make_row("sigma_tau (scale SD)", "scale", sigma_tau_est, se_sigma_tau, theta_index=self._sigma_tau_idx))
839
- rows.append(self._make_row("gamma (mixing)", "scale", gamma_est, se_gamma, theta_index=self._gamma_idx))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
840
 
841
  return pd.DataFrame(rows)
 
716
  bws_data: BwsData | None = None,
717
  correlated: bool = False,
718
  correlation_groups: list[list[int]] | None = None,
719
+ fixed_gamma: float | None = None,
720
  ) -> None:
721
  super().__init__(
722
  tensors=tensors,
 
728
  correlation_groups=correlation_groups,
729
  bws_data=bws_data,
730
  )
731
+ self._fixed_gamma = fixed_gamma # None = free, 0.0 = S-MNL, 1.0 = GMNL-II
732
+
733
  # Extra Halton draws for scale heterogeneity (one dim per individual per draw)
734
  scale_draws_np = generate_halton_draws(
735
  n_individuals=self.n_individuals,
 
741
  scale_draws_np[:, :, 0], dtype=torch.float32, device=self.device
742
  ) # (n_individuals, n_draws)
743
 
744
+ # Indices for the extra GMNL parameters appended after MMNL params
745
  self._tau_idx = self.n_params # tau (scale mean)
746
  self._sigma_tau_idx = self.n_params + 1 # raw sigma_tau (scale SD, softplus)
747
+ if self._fixed_gamma is None:
748
+ # gamma is a free parameter
749
+ self._gamma_idx = self.n_params + 2 # raw gamma (sigmoid -> [0,1])
750
+ self.n_params += 3
751
+ else:
752
+ # gamma is fixed — not a free parameter
753
+ self._gamma_idx = None
754
+ self.n_params += 2
755
 
756
  def _initial_theta(self) -> np.ndarray:
757
  # Delegate to parent for MMNL params (handles both independent & correlated)
 
759
  # Extend to include GMNL-specific params (already allocated in n_params)
760
  if len(theta0) < self.n_params:
761
  theta0 = np.concatenate([theta0, np.zeros(self.n_params - len(theta0), dtype=np.float64)])
762
+ # tau=0 -> mean scale=1, raw_sigma_tau=-1 -> small scale SD
763
  theta0[self._tau_idx] = 0.0
764
  theta0[self._sigma_tau_idx] = -1.0
765
+ if self._gamma_idx is not None:
766
+ # raw_gamma=0 -> gamma=0.5
767
+ theta0[self._gamma_idx] = 0.0
768
  return theta0
769
 
770
  def _betas_from_theta(self, theta: torch.Tensor) -> torch.Tensor:
 
779
 
780
  tau = theta[self._tau_idx]
781
  sigma_tau = _positive(theta[self._sigma_tau_idx])
782
+ if self._fixed_gamma is not None:
783
+ gamma = torch.tensor(self._fixed_gamma, dtype=theta.dtype, device=theta.device)
784
+ else:
785
+ gamma = torch.sigmoid(theta[self._gamma_idx])
786
 
787
  # Scale factor: sigma_i = exp(tau + sigma_tau * epsilon_i)
788
  # shape: (n_individuals, n_draws)
 
833
  tau_est = float(theta_hat[self._tau_idx])
834
  raw_sigma_tau = theta_hat[self._sigma_tau_idx]
835
  sigma_tau_est = float(np.logaddexp(0.0, raw_sigma_tau) + 1e-6)
 
 
836
 
837
  se_tau = float("nan")
838
  se_sigma_tau = float("nan")
 
839
  if vcov is not None:
840
  se_tau = float(np.sqrt(max(vcov[self._tau_idx, self._tau_idx], 0.0)))
841
  var_raw_st = vcov[self._sigma_tau_idx, self._sigma_tau_idx]
842
  deriv_st = self._softplus_derivative(raw_sigma_tau)
843
  se_sigma_tau = float(abs(deriv_st) * np.sqrt(max(var_raw_st, 0.0)))
 
 
 
844
 
845
  rows.append(self._make_row("tau (scale mean)", "scale", tau_est, se_tau, theta_index=self._tau_idx))
846
  rows.append(self._make_row("sigma_tau (scale SD)", "scale", sigma_tau_est, se_sigma_tau, theta_index=self._sigma_tau_idx))
847
+
848
+ if self._fixed_gamma is not None:
849
+ # gamma is fixed — report as fixed value with no SE
850
+ rows.append(self._make_row(
851
+ f"gamma (fixed={self._fixed_gamma:.1f})", "scale",
852
+ self._fixed_gamma, float("nan"), theta_index=-1,
853
+ ))
854
+ else:
855
+ raw_gamma = theta_hat[self._gamma_idx]
856
+ gamma_est = float(1.0 / (1.0 + np.exp(-raw_gamma)))
857
+ se_gamma = float("nan")
858
+ if vcov is not None:
859
+ var_raw_g = vcov[self._gamma_idx, self._gamma_idx]
860
+ deriv_g = gamma_est * (1.0 - gamma_est) # sigmoid derivative
861
+ se_gamma = float(abs(deriv_g) * np.sqrt(max(var_raw_g, 0.0)))
862
+ rows.append(self._make_row("gamma (mixing)", "scale", gamma_est, se_gamma, theta_index=self._gamma_idx))
863
 
864
  return pd.DataFrame(rows)
src/dce_analyzer/pipeline.py CHANGED
@@ -38,6 +38,7 @@ def estimate_dataframe(
38
  estimate_lambda_w: bool = True,
39
  lc_method: str = "em",
40
  custom_start: list[float] | None = None,
 
41
  ) -> PipelineResult:
42
  tensors = prepare_choice_tensors(df, spec, device=device)
43
 
@@ -94,6 +95,7 @@ def estimate_dataframe(
94
  bws_data=bws_data,
95
  correlated=correlated,
96
  correlation_groups=correlation_groups,
 
97
  )
98
  return PipelineResult(
99
  tensors=tensors,
@@ -213,6 +215,7 @@ def estimate_from_spec(
213
  estimate_lambda_w=spec.estimate_lambda_w,
214
  lc_method=spec.lc_method,
215
  custom_start=spec.custom_start,
 
216
  )
217
  result.expanded_spec = model_spec
218
  result.expanded_df = df
 
38
  estimate_lambda_w: bool = True,
39
  lc_method: str = "em",
40
  custom_start: list[float] | None = None,
41
+ fixed_gamma: float | None = None,
42
  ) -> PipelineResult:
43
  tensors = prepare_choice_tensors(df, spec, device=device)
44
 
 
95
  bws_data=bws_data,
96
  correlated=correlated,
97
  correlation_groups=correlation_groups,
98
+ fixed_gamma=fixed_gamma,
99
  )
100
  return PipelineResult(
101
  tensors=tensors,
 
215
  estimate_lambda_w=spec.estimate_lambda_w,
216
  lc_method=spec.lc_method,
217
  custom_start=spec.custom_start,
218
+ fixed_gamma={"s-mnl": 0.0, "gmnl-ii": 1.0}.get(spec.gmnl_variant),
219
  )
220
  result.expanded_spec = model_spec
221
  result.expanded_df = df