feat: enhance discovery + cache + comparison + add visuals + more

#2
.dockerignore ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ .git
2
+ __pycache__
3
+ *.pyc
4
+ .venv
5
+ venv/
6
+ .env
7
+ *.log
Dockerfile CHANGED
@@ -1,20 +1,27 @@
1
  FROM python:3.13.5-slim
2
 
 
 
 
 
3
  WORKDIR /app
4
 
5
- RUN apt-get update && apt-get install -y \
6
  build-essential \
7
  curl \
8
  git \
9
  && rm -rf /var/lib/apt/lists/*
10
 
11
  COPY requirements.txt ./
12
- COPY src/ ./src/
13
 
14
- RUN pip3 install -r requirements.txt
 
 
15
 
16
  EXPOSE 8501
17
 
18
- HEALTHCHECK CMD curl --fail http://localhost:8501/_stcore/health
 
19
 
20
  ENTRYPOINT ["streamlit", "run", "src/streamlit_app.py", "--server.port=8501", "--server.address=0.0.0.0"]
 
1
  FROM python:3.13.5-slim
2
 
3
+ ENV PYTHONDONTWRITEBYTECODE=1 \
4
+ PYTHONUNBUFFERED=1 \
5
+ PIP_NO_CACHE_DIR=1
6
+
7
  WORKDIR /app
8
 
9
+ RUN apt-get update && apt-get install -y --no-install-recommends \
10
  build-essential \
11
  curl \
12
  git \
13
  && rm -rf /var/lib/apt/lists/*
14
 
15
  COPY requirements.txt ./
16
+ RUN pip3 install --upgrade pip && pip3 install -r requirements.txt
17
 
18
+ COPY src/ ./src/
19
+ COPY app.py ./app.py
20
+ COPY data/ ./data/
21
 
22
  EXPOSE 8501
23
 
24
+ HEALTHCHECK --interval=30s --timeout=10s --start-period=20s --retries=3 \
25
+ CMD curl --fail http://localhost:8501/_stcore/health
26
 
27
  ENTRYPOINT ["streamlit", "run", "src/streamlit_app.py", "--server.port=8501", "--server.address=0.0.0.0"]
app.py CHANGED
@@ -2,9 +2,12 @@ import streamlit as st
2
  import pandas as pd
3
  from huggingface_hub import HfApi
4
  import requests
 
5
  import re
6
  import concurrent.futures
7
  import logging
 
 
8
  from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type
9
 
10
  logging.basicConfig(level=logging.WARNING, format="%(asctime)s - %(levelname)s - %(message)s")
@@ -20,13 +23,73 @@ st.markdown(
20
  "allowing you to sort and compare them easily. Click on any column header to sort!"
21
  )
22
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
  @retry(
24
  retry=retry_if_exception_type((requests.exceptions.ConnectionError, requests.exceptions.Timeout, requests.exceptions.HTTPError)),
25
  stop=stop_after_attempt(3),
26
  wait=wait_exponential(multiplier=1, min=2, max=10)
27
  )
28
  def fetch_url(url):
29
- response = requests.get(url, timeout=5)
30
  # Raise HTTPError for rate limits (429) or server errors (5xx) to trigger a retry
31
  if response.status_code in (429, 500, 502, 503, 504):
32
  response.raise_for_status()
@@ -40,14 +103,14 @@ def fetch_model_info(model):
40
  refusals_str = None
41
  refusal_rate = None
42
  initial_refusals_str = None
43
-
44
  # Download the README.md via raw URL for high-speed fetching
45
  url = f"https://huggingface.co/{model_id}/raw/main/README.md"
46
  try:
47
  response = fetch_url(url)
48
  if response.status_code == 200:
49
  readme_text = response.text
50
-
51
  # Extract KL divergence using Regex
52
  # Matches formats like: "KL divergence | 0.0033"
53
  kl_match = re.search(r"(?i)KL\s*divergence[^a-zA-Z\d\n]*?([\d\.]+)", readme_text)
@@ -56,7 +119,7 @@ def fetch_model_info(model):
56
  kl_div = float(kl_match.group(1))
57
  except ValueError as e:
58
  logging.warning(f"Could not parse KL divergence for {model_id}: {e}")
59
-
60
  # Extract Refusals using Regex
61
  # Matches formats like: "Refusals | 15/100" or "15 / 100"
62
  for line in readme_text.split('\n'):
@@ -65,19 +128,19 @@ def fetch_model_info(model):
65
  fractions = re.findall(r"(\d+)\s*/\s*(\d+)", line)
66
  if not fractions:
67
  continue
68
-
69
  if 'initial' in line_lower and initial_refusals_str is None:
70
  initial_refusals_str = f"{fractions[0][0]}/{fractions[0][1]}"
71
  elif 'initial' not in line_lower and refusals_str is None:
72
  refusals_str = f"{fractions[0][0]}/{fractions[0][1]}"
73
  try:
74
- refusal_rate = (int(fractions[0][0]) / int(fractions[0][1])) * 100
75
  except (ValueError, ZeroDivisionError) as e:
76
  logging.warning(f"Could not parse refusals for {model_id}: {e}")
77
  continue
78
  if len(fractions) >= 2 and initial_refusals_str is None:
79
  initial_refusals_str = f"{fractions[1][0]}/{fractions[1][1]}"
80
-
81
  except requests.exceptions.RequestException as e:
82
  logging.warning(f"Error fetching README for {model_id}: {e}")
83
  except Exception as e:
@@ -98,7 +161,7 @@ def fetch_model_info(model):
98
  "URL": f"https://huggingface.co/{model_id}"
99
  }
100
 
101
- # Check for reproducibility record first and then fallback to parsing data from readme.md
102
  def get_model_data(model):
103
  model_id = model.id
104
  url = f"https://huggingface.co/{model_id}/raw/main/reproduce/reproduce.json"
@@ -134,58 +197,749 @@ def get_model_data(model):
134
  logging.error(f"Unexpected error processing JSON for {model_id}: {e}")
135
  return fetch_model_info(model)
136
 
137
- @st.cache_data(ttl=3600, show_spinner=False) # Cache for 1 hour
138
- def get_heretic_models():
139
- """Fetches all heretic models and their metrics concurrently."""
 
 
 
 
 
 
 
 
140
  api = HfApi()
141
-
142
  # Query all models using the Hugging Face Hub `filter` parameter
143
  models = list(api.list_models(filter="heretic"))
144
-
145
- # Fetch details concurrently for speed (10 workers to avoid API rate limiting issues)
146
- # Skip quantised models
147
- quant_patterns = re.compile(r'(?i)(gguf|mlx|awq|nvfp4|gptq|exl[23]|-quant|int8|int4|oq[1-8]|mxfp[48])')
148
- models = [m for m in models if not quant_patterns.search(m.id)]
149
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
150
  data = []
151
-
152
- with concurrent.futures.ThreadPoolExecutor(max_workers=10) as executor:
153
- results = executor.map(get_model_data, models)
 
 
154
  for res in results:
155
  if res is not None:
156
  data.append(res)
157
-
158
  return data
159
 
160
- # Main execution
161
- with st.spinner("Fetching heretic models and parsing model cards... This might take a moment on the first run."):
162
- models_data = get_heretic_models()
 
 
 
 
 
163
 
164
- if not models_data:
165
- st.warning("No models found with the 'heretic' tag.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
166
  else:
167
- df = pd.DataFrame(models_data)
168
-
169
- # Make Model ID a clickable Markdown link
170
- df["Model"] = df["URL"]
171
-
172
- # Select and order columns for display
173
- display_df = df[["Model", "KL Divergence", "Initial Refusals", "Refusals", "Refusal Rate (%)", "Likes", "Downloads"]]
174
-
175
- st.markdown(f"**Found {len(display_df)} models.**")
176
-
177
- # Display as an interactive, sortable dataframe
178
- st.dataframe(
179
- display_df,
180
- column_config={
181
- "Model": st.column_config.LinkColumn("Model", display_text=r"https://huggingface\.co/(.*)"),
182
- "KL Divergence": st.column_config.NumberColumn("KL Divergence", format="%.4f"),
183
- "Initial Refusals": st.column_config.TextColumn("Initial Refusals"),
184
- "Refusals": st.column_config.TextColumn("Refusals"),
185
- "Refusal Rate (%)": st.column_config.NumberColumn("Refusal Rate (%)", format="%.2f%%"),
186
- "Likes": st.column_config.NumberColumn("Likes"),
187
- "Downloads": st.column_config.NumberColumn("Downloads")
188
- },
189
- hide_index=True,
190
- width='stretch'
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
191
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
  import pandas as pd
3
  from huggingface_hub import HfApi
4
  import requests
5
+ from requests.adapters import HTTPAdapter
6
  import re
7
  import concurrent.futures
8
  import logging
9
+ from datetime import datetime, timezone
10
+ from types import SimpleNamespace
11
  from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type
12
 
13
  logging.basicConfig(level=logging.WARNING, format="%(asctime)s - %(levelname)s - %(message)s")
 
23
  "allowing you to sort and compare them easily. Click on any column header to sort!"
24
  )
25
 
26
+ # ---------------------------------------------------------------------------
27
+ # Filter config (single source of truth)
28
+ # ---------------------------------------------------------------------------
29
+ FILTER_DEFAULTS = {
30
+ "query": "",
31
+ "date_range": "All time",
32
+ "unknowns": "Include unknowns",
33
+ "kl_range": (0.0, 0.5),
34
+ "refusal_range": (0.0, 100.0),
35
+ "min_likes": 0,
36
+ "min_downloads": 0,
37
+ "show_quants": False,
38
+ "sort_by": "KL Divergence (low β†’ high)",
39
+ }
40
+
41
+ DATE_OPTIONS = {
42
+ "All time": None,
43
+ "Last week": 7,
44
+ "Last month": 30,
45
+ "Last 6 months": 182,
46
+ "Last year": 365,
47
+ }
48
+
49
+ UNKNOWN_OPTIONS = (
50
+ "Include unknowns",
51
+ "Hide unknowns",
52
+ "Only unknowns",
53
+ )
54
+
55
+ SORT_OPTIONS = {
56
+ "KL Divergence (low β†’ high)": ("KL Divergence", True),
57
+ "KL Divergence (high β†’ low)": ("KL Divergence", False),
58
+ "Refusal Rate (%) (low β†’ high)": ("Refusal Rate (%)", True),
59
+ "Refusal Rate (%) (high β†’ low)": ("Refusal Rate (%)", False),
60
+ "Likes (high β†’ low)": ("Likes", False),
61
+ "Downloads (high β†’ low)": ("Downloads", False),
62
+ "Model (A β†’ Z)": ("Model", True),
63
+ }
64
+
65
+ QUANT_PATTERNS = re.compile(r'(?i)(gguf|mlx|awq|nvfp4|gptq|exl[23]|-quant|int8|int4|oq[1-8]|mxfp[48])')
66
+ QUANT_RE = r"(?i)(gguf|mlx|awq|nvfp4|gptq|exl[23]|-quant|int8|int4|oq[1-8]|mxfp[48])"
67
+
68
+ # ---------------------------------------------------------------------------
69
+ # Network: reused Session + retry
70
+ # ---------------------------------------------------------------------------
71
+ _SESSION = None
72
+
73
+
74
+ def _get_session():
75
+ global _SESSION
76
+ if _SESSION is None:
77
+ s = requests.Session()
78
+ adapter = HTTPAdapter(pool_connections=20, pool_maxsize=20, max_retries=0)
79
+ s.mount("https://", adapter)
80
+ s.mount("http://", adapter)
81
+ s.headers.update({"User-Agent": "Heretic-Models-Explorer/1.0"})
82
+ _SESSION = s
83
+ return _SESSION
84
+
85
+
86
  @retry(
87
  retry=retry_if_exception_type((requests.exceptions.ConnectionError, requests.exceptions.Timeout, requests.exceptions.HTTPError)),
88
  stop=stop_after_attempt(3),
89
  wait=wait_exponential(multiplier=1, min=2, max=10)
90
  )
91
  def fetch_url(url):
92
+ response = _get_session().get(url, timeout=(3.05, 5))
93
  # Raise HTTPError for rate limits (429) or server errors (5xx) to trigger a retry
94
  if response.status_code in (429, 500, 502, 503, 504):
95
  response.raise_for_status()
 
103
  refusals_str = None
104
  refusal_rate = None
105
  initial_refusals_str = None
106
+
107
  # Download the README.md via raw URL for high-speed fetching
108
  url = f"https://huggingface.co/{model_id}/raw/main/README.md"
109
  try:
110
  response = fetch_url(url)
111
  if response.status_code == 200:
112
  readme_text = response.text
113
+
114
  # Extract KL divergence using Regex
115
  # Matches formats like: "KL divergence | 0.0033"
116
  kl_match = re.search(r"(?i)KL\s*divergence[^a-zA-Z\d\n]*?([\d\.]+)", readme_text)
 
119
  kl_div = float(kl_match.group(1))
120
  except ValueError as e:
121
  logging.warning(f"Could not parse KL divergence for {model_id}: {e}")
122
+
123
  # Extract Refusals using Regex
124
  # Matches formats like: "Refusals | 15/100" or "15 / 100"
125
  for line in readme_text.split('\n'):
 
128
  fractions = re.findall(r"(\d+)\s*/\s*(\d+)", line)
129
  if not fractions:
130
  continue
131
+
132
  if 'initial' in line_lower and initial_refusals_str is None:
133
  initial_refusals_str = f"{fractions[0][0]}/{fractions[0][1]}"
134
  elif 'initial' not in line_lower and refusals_str is None:
135
  refusals_str = f"{fractions[0][0]}/{fractions[0][1]}"
136
  try:
137
+ refusal_rate = (int(fractions[0][0]) / int(fractions[0][1])) * 100
138
  except (ValueError, ZeroDivisionError) as e:
139
  logging.warning(f"Could not parse refusals for {model_id}: {e}")
140
  continue
141
  if len(fractions) >= 2 and initial_refusals_str is None:
142
  initial_refusals_str = f"{fractions[1][0]}/{fractions[1][1]}"
143
+
144
  except requests.exceptions.RequestException as e:
145
  logging.warning(f"Error fetching README for {model_id}: {e}")
146
  except Exception as e:
 
161
  "URL": f"https://huggingface.co/{model_id}"
162
  }
163
 
164
+ # Check for reproducibility record first and then fallback to parsing data from readme.md
165
  def get_model_data(model):
166
  model_id = model.id
167
  url = f"https://huggingface.co/{model_id}/raw/main/reproduce/reproduce.json"
 
197
  logging.error(f"Unexpected error processing JSON for {model_id}: {e}")
198
  return fetch_model_info(model)
199
 
200
+ # ---------------------------------------------------------------------------
201
+ # Two-stage cached loading (faster initial load)
202
+ # ---------------------------------------------------------------------------
203
+ @st.cache_data(ttl=3600, show_spinner=False)
204
+ def list_heretic_metadata():
205
+ """Stage 1: HfApi only, no per-model fetch. Cheap and fast.
206
+
207
+ Returns ALL models (including quants) with Created/LastModified dates
208
+ so the caller can pre-filter by date/query BEFORE the expensive
209
+ per-model README/reproduce.json parsing.
210
+ """
211
  api = HfApi()
 
212
  # Query all models using the Hugging Face Hub `filter` parameter
213
  models = list(api.list_models(filter="heretic"))
214
+ out = []
215
+ for m in models:
216
+ mid = m.id
217
+ created = getattr(m, "created_at", None)
218
+ modified = getattr(m, "last_modified", None)
219
+ # Normalise to ISO strings (cache-safe, JSON-safe)
220
+ try:
221
+ created_iso = created.isoformat() if hasattr(created, "isoformat") else (str(created) if created else None)
222
+ except Exception:
223
+ created_iso = None
224
+ try:
225
+ modified_iso = modified.isoformat() if hasattr(modified, "isoformat") else (str(modified) if modified else None)
226
+ except Exception:
227
+ modified_iso = None
228
+ out.append({
229
+ "Model ID": mid,
230
+ "Likes": getattr(m, "likes", 0) or 0,
231
+ "Downloads": getattr(m, "downloads", 0) or 0,
232
+ "URL": f"https://huggingface.co/{mid}",
233
+ "Created": created_iso,
234
+ "LastModified": modified_iso,
235
+ })
236
+ return out
237
+
238
+
239
+ @st.cache_data(ttl=3600, show_spinner=False)
240
+ def cached_get_model_data(model_id: str, likes: int = 0, downloads: int = 0):
241
+ """Stage 2: per-model reasoning cache, keyed by model_id only.
242
+
243
+ likes/downloads are accepted for back-compat but IGNORED in the key path:
244
+ popularity is merged later via popularity_store so a like-spike doesn't
245
+ invalidate the expensive KL/refusal parse. Pass model_id alone going forward.
246
+ """
247
+ proxy = SimpleNamespace(id=model_id, likes=0, downloads=0)
248
+ return get_model_data(proxy)
249
+
250
+
251
+ @st.cache_data(ttl=3600, show_spinner=False) # Cache for 1 hour, back-compat shim
252
+ def get_heretic_models():
253
+ """Fetches all heretic models and their metrics concurrently."""
254
+ meta = list_heretic_metadata()
255
  data = []
256
+ with concurrent.futures.ThreadPoolExecutor(max_workers=20) as executor:
257
+ results = executor.map(
258
+ lambda d: cached_get_model_data(d["Model ID"], d["Likes"], d["Downloads"]),
259
+ meta,
260
+ )
261
  for res in results:
262
  if res is not None:
263
  data.append(res)
 
264
  return data
265
 
266
+ # ---------------------------------------------------------------------------
267
+ # Filtering (pure, testable)
268
+ # ---------------------------------------------------------------------------
269
+ def _model_id_series(df: pd.DataFrame) -> pd.Series:
270
+ col = "Model ID" if "Model ID" in df.columns else "Model"
271
+ if col in df.columns:
272
+ return df[col].astype("string")
273
+ return pd.Series([""] * len(df), index=df.index, dtype="string")
274
 
275
+
276
+ def _parse_created(v):
277
+ """Parse Created ISO string to aware datetime, or None."""
278
+ if v is None or (isinstance(v, float) and pd.isna(v)):
279
+ return None
280
+ if isinstance(v, datetime):
281
+ dt = v
282
+ else:
283
+ try:
284
+ dt = datetime.fromisoformat(str(v).replace("Z", "+00:00"))
285
+ except Exception:
286
+ return None
287
+ if dt is not None and dt.tzinfo is None:
288
+ dt = dt.replace(tzinfo=timezone.utc)
289
+ return dt
290
+
291
+
292
+ def filter_metadata_by_date(metadata: list, date_range_key: str, unknowns_key: str = "Include unknowns") -> list:
293
+ """Pre-filter raw metadata list by Created date BEFORE expensive parsing.
294
+
295
+ Unknowns handling is now an explicit user choice (not silently kept):
296
+ - "Include unknowns": keep models with missing Created date (old default).
297
+ - "Hide unknowns": drop them β€” strictest, smallest parse set.
298
+ - "Only unknowns": parse ONLY models with missing dates (audit mode).
299
+ """
300
+ days = DATE_OPTIONS.get(date_range_key, None)
301
+ if unknowns_key not in UNKNOWN_OPTIONS:
302
+ unknowns_key = "Include unknowns"
303
+ if days is None and unknowns_key == "Include unknowns":
304
+ return list(metadata)
305
+ cutoff = None
306
+ if days is not None:
307
+ cutoff = datetime.now(timezone.utc) - pd.Timedelta(days=days).to_pytimedelta()
308
+ kept = []
309
+ for d in metadata:
310
+ dt = _parse_created(d.get("Created"))
311
+ if dt is None:
312
+ if unknowns_key in ("Include unknowns", "Only unknowns"):
313
+ kept.append(d)
314
+ elif cutoff is None:
315
+ # All time + Hide unknowns -> drop unknowns (already skipped), keep dated
316
+ kept.append(d)
317
+ elif unknowns_key == "Only unknowns":
318
+ continue # dated rows excluded in audit mode
319
+ elif dt >= cutoff:
320
+ kept.append(d)
321
+ return kept
322
+
323
+
324
+ def prefilter_metadata(metadata: list, query: str, show_quants: bool, date_range_key: str, unknowns_key: str = "Include unknowns") -> list:
325
+ """Cheap pre-fetch filter: date (+unknowns handling) + quant + query. No network."""
326
+ out = filter_metadata_by_date(metadata, date_range_key, unknowns_key)
327
+ if not show_quants:
328
+ out = [d for d in out if not QUANT_PATTERNS.search(d.get("Model ID", ""))]
329
+ if query is not None:
330
+ q = str(query).strip().lower()
331
+ if q:
332
+ out = [d for d in out if q in str(d.get("Model ID", "")).lower()]
333
+ return out
334
+
335
+
336
+ def apply_filters(df, query, kl_range, refusal_range,
337
+ min_likes, min_downloads, show_quants, date_range="All time", unknowns="Include unknowns"):
338
+ """Pure filter. Never mutates input. Handles empty df, NaNs, case-insensitive query."""
339
+ if df is None or len(df) == 0:
340
+ return df.copy() if isinstance(df, pd.DataFrame) else pd.DataFrame()
341
+ mask = pd.Series(True, index=df.index)
342
+ if query is not None:
343
+ q = str(query).strip()
344
+ if q:
345
+ ids = _model_id_series(df).fillna("")
346
+ mask &= ids.str.contains(q, case=False, na=False, regex=False)
347
+ if kl_range is not None and "KL Divergence" in df.columns:
348
+ try:
349
+ lo, hi = float(kl_range[0]), float(kl_range[1])
350
+ lo, hi = (hi, lo) if lo > hi else (lo, hi)
351
+ kl = pd.to_numeric(df["KL Divergence"], errors="coerce")
352
+ mask &= kl.between(lo, hi, inclusive="both")
353
+ except (TypeError, ValueError, IndexError):
354
+ pass
355
+ if refusal_range is not None and "Refusal Rate (%)" in df.columns:
356
+ try:
357
+ lo, hi = float(refusal_range[0]), float(refusal_range[1])
358
+ lo, hi = (hi, lo) if lo > hi else (lo, hi)
359
+ rr = pd.to_numeric(df["Refusal Rate (%)"], errors="coerce")
360
+ mask &= rr.between(lo, hi, inclusive="both")
361
+ except (TypeError, ValueError, IndexError):
362
+ pass
363
+ for col, thresh in (("Likes", min_likes), ("Downloads", min_downloads)):
364
+ if col in df.columns and thresh is not None:
365
+ try:
366
+ t = float(thresh)
367
+ except (TypeError, ValueError):
368
+ continue
369
+ if pd.isna(t) or t <= 0:
370
+ continue
371
+ vals = pd.to_numeric(df[col], errors="coerce").fillna(0)
372
+ mask &= vals >= t
373
+ if not show_quants:
374
+ ids = _model_id_series(df).fillna("")
375
+ mask &= ~ids.str.contains(QUANT_RE, case=False, na=False, regex=True)
376
+ # Date filter on Created column (post-fetch safety net; main saving is pre-fetch)
377
+ days = DATE_OPTIONS.get(date_range, None)
378
+ if unknowns not in UNKNOWN_OPTIONS:
379
+ unknowns = "Include unknowns"
380
+ if "Created" in df.columns and (days is not None or unknowns != "Include unknowns"):
381
+ try:
382
+ cutoff = None
383
+ if days is not None:
384
+ cutoff = datetime.now(timezone.utc) - pd.Timedelta(days=days).to_pytimedelta()
385
+ parsed = df["Created"].apply(_parse_created)
386
+ if unknowns == "Only unknowns":
387
+ date_mask = parsed.apply(lambda dt: dt is None)
388
+ elif unknowns == "Hide unknowns":
389
+ if cutoff is None:
390
+ date_mask = parsed.apply(lambda dt: dt is not None)
391
+ else:
392
+ date_mask = parsed.apply(lambda dt: dt is not None and dt >= cutoff)
393
+ else: # Include unknowns
394
+ if cutoff is None:
395
+ date_mask = pd.Series(True, index=df.index)
396
+ else:
397
+ date_mask = parsed.apply(lambda dt: True if dt is None else dt >= cutoff)
398
+ mask &= date_mask.fillna(True).to_numpy() if hasattr(date_mask, "fillna") else date_mask
399
+ except Exception:
400
+ pass
401
+ return df.loc[mask].copy().reset_index(drop=True)
402
+
403
+
404
+ def _qp_first(key, default=""):
405
+ v = st.query_params.get(key, default)
406
+ return v[0] if isinstance(v, list) and v else v
407
+
408
+
409
+ def _to_float(v, default):
410
+ try:
411
+ f = float(v)
412
+ return f if not pd.isna(f) else default
413
+ except (TypeError, ValueError):
414
+ return default
415
+
416
+
417
+ def _to_int(v, default):
418
+ try:
419
+ return max(0, int(float(v)))
420
+ except (TypeError, ValueError):
421
+ return default
422
+
423
+
424
+ def init_filter_state_from_query_params():
425
+ if st.session_state.get("_qp_init"):
426
+ return
427
+ d = FILTER_DEFAULTS
428
+ kl_lo = _to_float(_qp_first("kl_min", d["kl_range"][0]), d["kl_range"][0])
429
+ kl_hi = _to_float(_qp_first("kl_max", d["kl_range"][1]), d["kl_range"][1])
430
+ rr_lo = _to_float(_qp_first("rr_min", d["refusal_range"][0]), d["refusal_range"][0])
431
+ rr_hi = _to_float(_qp_first("rr_max", d["refusal_range"][1]), d["refusal_range"][1])
432
+ st.session_state.setdefault("query", str(_qp_first("q", d["query"])))
433
+ _dr = str(_qp_first("date_range", d["date_range"]))
434
+ st.session_state.setdefault("date_range", _dr if _dr in DATE_OPTIONS else d["date_range"])
435
+ _unk = str(_qp_first("unknowns", d["unknowns"]))
436
+ st.session_state.setdefault("unknowns", _unk if _unk in UNKNOWN_OPTIONS else d["unknowns"])
437
+ st.session_state.setdefault("kl_range", (min(kl_lo, kl_hi), max(kl_lo, kl_hi)))
438
+ st.session_state.setdefault("refusal_range", (min(rr_lo, rr_hi), max(rr_lo, rr_hi)))
439
+ st.session_state.setdefault("min_likes", _to_int(_qp_first("min_likes", d["min_likes"]), d["min_likes"]))
440
+ st.session_state.setdefault("min_downloads", _to_int(_qp_first("min_downloads", d["min_downloads"]), d["min_downloads"]))
441
+ st.session_state.setdefault("show_quants", str(_qp_first("show_quants", "")).lower() in ("1", "true", "yes"))
442
+ sort = str(_qp_first("sort_by", d["sort_by"]))
443
+ st.session_state.setdefault("sort_by", sort if sort in SORT_OPTIONS else d["sort_by"])
444
+ st.session_state["_qp_init"] = True
445
+
446
+
447
+ def sync_filter_state_to_query_params():
448
+ st.query_params["q"] = st.session_state.get("query", "")
449
+ st.query_params["date_range"] = str(st.session_state.get("date_range", FILTER_DEFAULTS["date_range"]))
450
+ st.query_params["unknowns"] = str(st.session_state.get("unknowns", FILTER_DEFAULTS["unknowns"]))
451
+ kl = st.session_state.get("kl_range", FILTER_DEFAULTS["kl_range"])
452
+ rr = st.session_state.get("refusal_range", FILTER_DEFAULTS["refusal_range"])
453
+ st.query_params["kl_min"] = str(float(kl[0]))
454
+ st.query_params["kl_max"] = str(float(kl[1]))
455
+ st.query_params["rr_min"] = str(float(rr[0]))
456
+ st.query_params["rr_max"] = str(float(rr[1]))
457
+ st.query_params["min_likes"] = str(int(st.session_state.get("min_likes", 0) or 0))
458
+ st.query_params["min_downloads"] = str(int(st.session_state.get("min_downloads", 0) or 0))
459
+ st.query_params["show_quants"] = "1" if st.session_state.get("show_quants") else "0"
460
+ st.query_params["sort_by"] = str(st.session_state.get("sort_by", FILTER_DEFAULTS["sort_by"]))
461
+
462
+
463
+ def _short(url: str) -> str:
464
+ s = str(url)
465
+ return s.split("huggingface.co/")[-1] if "huggingface.co/" in s else s
466
+
467
+
468
+ def _parse_fraction(s) -> tuple:
469
+ """Parse '15/100' β†’ (15, 100). Returns (None, None) on failure."""
470
+ if s is None or (isinstance(s, float) and pd.isna(s)):
471
+ return (None, None)
472
+ m = re.search(r"(\d+)\s*/\s*(\d+)", str(s))
473
+ if not m:
474
+ return (None, None)
475
+ try:
476
+ return (int(m.group(1)), int(m.group(2)))
477
+ except (ValueError, ZeroDivisionError):
478
+ return (None, None)
479
+
480
+
481
+ def _kl_band(kl) -> str:
482
+ """Rough weight-impact bands. Not a verdict β€” higher KL = more brain drift."""
483
+ if kl is None or (isinstance(kl, float) and pd.isna(kl)):
484
+ return "unknown"
485
+ try:
486
+ kl = float(kl)
487
+ except (TypeError, ValueError):
488
+ return "unknown"
489
+ if kl < 0.01:
490
+ return "minimal drift"
491
+ if kl < 0.05:
492
+ return "moderate drift"
493
+ if kl < 0.15:
494
+ return "high drift"
495
+ return "very high drift"
496
+
497
+
498
+ def _compare_note(kl, ref_num, ref_den, init_num, init_den) -> str:
499
+ """One-line grain-of-salt note per model. No trophies."""
500
+ parts = []
501
+ # Sample-size caution
502
+ if ref_den is None:
503
+ parts.append("unknown sample size β€” weak evidence")
504
+ elif ref_den < 50:
505
+ parts.append(f"small sample (n={ref_den}) β€” take with grain of salt")
506
+ # Drop vs base
507
+ if init_num is not None and ref_num is not None and init_den and ref_den:
508
+ drop = init_num - ref_num
509
+ parts.append(f"drop {drop} (base {init_num}/{init_den} β†’ {ref_num}/{ref_den})")
510
+ # KL vs refusal tradeoff β€” the core complaint: 0% refusal + high KL = stupid
511
+ try:
512
+ klf = float(kl) if kl is not None else None
513
+ except (TypeError, ValueError):
514
+ klf = None
515
+ try:
516
+ rate = (ref_num / ref_den * 100) if ref_num is not None and ref_den else None
517
+ except ZeroDivisionError:
518
+ rate = None
519
+ if rate is not None and klf is not None:
520
+ if rate == 0 and klf > 0.1:
521
+ parts.append("0 refusals BUT high KL β€” possibly lobotomized, check reasoning")
522
+ elif rate == 0 and klf > 0.05:
523
+ parts.append("0 refusals with notable drift β€” verify quality")
524
+ elif rate <= 5 and klf is not None and klf < 0.02:
525
+ parts.append("good balance: low refusal + low drift")
526
+ elif rate > 20:
527
+ parts.append("still refusal-prone, regardless of KL")
528
+ elif klf is not None and klf > 0.1:
529
+ parts.append("high KL β€” expect reasoning damage")
530
+ return "; ".join(parts) if parts else "β€”"
531
+
532
+
533
+ # ---------------------------------------------------------------------------
534
+ # Main execution: metadata instantly, pre-filter by date, then parse subset
535
+ # ---------------------------------------------------------------------------
536
+ init_filter_state_from_query_params()
537
+
538
+
539
+ def _reset_filters():
540
+ """Callback for Reset button β€” runs BEFORE widgets instantiate, so no
541
+ StreamlitWidgetAlreadyInstantiatedError (the crash you saw)."""
542
+ for k, v in FILTER_DEFAULTS.items():
543
+ st.session_state[k] = v
544
+ st.query_params.clear()
545
+ st.session_state.metrics_done = False
546
+ st.session_state.metrics_rows = []
547
+ st.session_state.metrics_signature = None
548
+
549
+
550
+ if "metrics_done" not in st.session_state:
551
+ st.session_state.metrics_done = False
552
+ if "metrics_rows" not in st.session_state:
553
+ st.session_state.metrics_rows = []
554
+ if "metrics_signature" not in st.session_state:
555
+ st.session_state.metrics_signature = None
556
+
557
+ # --- EARLY pre-filters: these decide HOW MANY models get parsed ---
558
+ # Shown before any expensive README/reproduce.json fetching.
559
+ st.sidebar.header("Load scope (saves parsing time)")
560
+ early_date = st.sidebar.selectbox(
561
+ "Release date",
562
+ list(DATE_OPTIONS.keys()),
563
+ key="date_range",
564
+ help="Pre-filters by Hugging Face creation date BEFORE parsing. Pick a recent window to avoid parsing ~2000 models.",
565
+ )
566
+ early_unknowns = st.sidebar.selectbox(
567
+ "Unknown dates",
568
+ list(UNKNOWN_OPTIONS),
569
+ key="unknowns",
570
+ help="Models with missing HF creation date. Include (old default), Hide (strictest, smallest parse), or Only (audit them).",
571
+ )
572
+ early_query = st.sidebar.text_input(
573
+ "Search model ID (pre-filter)",
574
+ key="query",
575
+ placeholder="e.g. llama-3 heretic …",
576
+ help="Case-insensitive substring. Applied BEFORE parsing to skip non-matching models.",
577
+ )
578
+ early_quants = st.sidebar.checkbox(
579
+ "Show quantised models",
580
+ key="show_quants",
581
+ help="Untick to hide GGUF/MLX/AWQ/GPTQ/EXL2/EXL3/quant/INT8/INT4/OQ/MXFP4/MXFP8 before parsing.",
582
+ )
583
+ try:
584
+ from src.popularity_store import load_popularity, save_popularity, snapshot_from_metadata
585
+ except ImportError:
586
+ try:
587
+ from popularity_store import load_popularity, save_popularity, snapshot_from_metadata
588
+ except ImportError:
589
+ load_popularity = lambda: {} # noqa: E731
590
+ save_popularity = lambda m: "" # noqa: E731
591
+ snapshot_from_metadata = lambda md: {} # noqa: E731
592
+ with st.sidebar.expander("Popularity (likes/downloads)", expanded=False):
593
+ st.caption("Stored separately in data/popularity.json β€” refreshable without re-parsing READMEs.")
594
+ if st.button("πŸ”„ Refresh popularity file", use_container_width=True):
595
+ try:
596
+ _live = list_heretic_metadata()
597
+ _path = save_popularity(snapshot_from_metadata(_live))
598
+ st.success(f"Saved {len(_live)} entries to {_path or 'data/popularity.json'}")
599
+ except Exception as e:
600
+ st.error(f"Could not save popularity: {e}")
601
+ try:
602
+ _pop = load_popularity()
603
+ st.caption(f"{len(_pop)} models in popularity file.")
604
+ except Exception:
605
+ pass
606
+
607
+ col_a, col_b = st.columns([1, 4])
608
+ with col_a:
609
+ refresh = st.button("πŸ”„ Refresh metrics", use_container_width=True)
610
+ with col_b:
611
+ auto = st.checkbox("Auto-load metrics", value=True)
612
+
613
+ try:
614
+ with st.spinner("Fetching model list (fast metadata)..."):
615
+ metadata = list_heretic_metadata()
616
+ except Exception as e:
617
+ st.error(f"Could not list heretic models: {e}")
618
+ st.stop()
619
+
620
+ # Cheap pre-filter BEFORE expensive parsing β€” this is the load-time win.
621
+ target_meta = prefilter_metadata(metadata, early_query, early_quants, early_date, early_unknowns)
622
+ st.caption(
623
+ f"Found {len(metadata)} models (metadata cached 1h). "
624
+ f"Scope '{early_date}' + '{early_unknowns}' + search β†’ **{len(target_meta)} to parse**."
625
+ )
626
+ if len(target_meta) == 0:
627
+ st.info("No models in this date/search scope β€” widen the date range, set Unknowns to Include, or clear search.")
628
+ st.stop()
629
+ if len(target_meta) > 500:
630
+ st.warning(
631
+ f"Scope selects {len(target_meta)} models β€” parsing will take a while. "
632
+ "Tip: pick 'Last month' / 'Last week', Hide unknowns, or add a search term."
633
+ )
634
+
635
+ fetch_signature = (early_date, early_unknowns, early_query.strip().lower() if early_query else "", bool(early_quants))
636
+ if st.session_state.metrics_signature is not None and st.session_state.metrics_signature != fetch_signature:
637
+ # Scope changed β†’ old parsed rows no longer valid
638
+ st.session_state.metrics_done = False
639
+ st.session_state.metrics_rows = []
640
+
641
+ should_run = (auto and not st.session_state.metrics_done) or refresh
642
+ if refresh:
643
+ st.session_state.metrics_done = False
644
+ st.session_state.metrics_rows = []
645
+
646
+ if st.session_state.metrics_done and st.session_state.metrics_signature == fetch_signature:
647
+ models_data = st.session_state.metrics_rows
648
+ elif should_run:
649
+ status_slot = st.empty()
650
+ progress = st.progress(0)
651
+ rows = []
652
+ with status_slot.status("Fetching reproduce.json β†’ README fallback...", expanded=False) as status:
653
+ with concurrent.futures.ThreadPoolExecutor(max_workers=20) as ex:
654
+ fut_to_id = {
655
+ ex.submit(cached_get_model_data, d["Model ID"], d["Likes"], d["Downloads"]): d["Model ID"]
656
+ for d in target_meta
657
+ }
658
+ done = 0
659
+ for fut in concurrent.futures.as_completed(fut_to_id):
660
+ try:
661
+ res = fut.result()
662
+ if res is not None:
663
+ rows.append(res)
664
+ except Exception as e:
665
+ logging.warning(f"Worker failed for {fut_to_id[fut]}: {e}")
666
+ done += 1
667
+ progress.progress(done / max(len(target_meta), 1))
668
+ status.update(label=f"Parsed {done}/{len(target_meta)} β€” {len(rows)} with metrics...")
669
+ status.update(label=f"Done: {len(rows)}/{len(target_meta)} with metrics", state="complete")
670
+ # Merge Created dates back in for display/filtering + popularity
671
+ # (likes/downloads live in popularity_store, NOT in the reasoning cache key)
672
+ created_map = {d["Model ID"]: d.get("Created") for d in target_meta}
673
+ meta_like_map = {d["Model ID"]: d for d in target_meta}
674
+ try:
675
+ _file_map = load_popularity()
676
+ except Exception:
677
+ _file_map = {}
678
+ for r in rows:
679
+ r.setdefault("Created", created_map.get(r.get("Model ID")))
680
+ mid = r.get("Model ID")
681
+ file_hit = (_file_map or {}).get(mid, {}) if mid else {}
682
+ meta_hit = meta_like_map.get(mid, {}) if mid else {}
683
+ if isinstance(file_hit, dict) and ("likes" in file_hit or "downloads" in file_hit):
684
+ try:
685
+ r["Likes"] = int(file_hit.get("likes", r.get("Likes", 0)) or 0)
686
+ except (TypeError, ValueError):
687
+ pass
688
+ try:
689
+ r["Downloads"] = int(file_hit.get("downloads", r.get("Downloads", 0)) or 0)
690
+ except (TypeError, ValueError):
691
+ pass
692
+ elif mid in meta_like_map:
693
+ try:
694
+ r["Likes"] = int(meta_hit.get("Likes", r.get("Likes", 0)) or 0)
695
+ except (TypeError, ValueError):
696
+ pass
697
+ try:
698
+ r["Downloads"] = int(meta_hit.get("Downloads", r.get("Downloads", 0)) or 0)
699
+ except (TypeError, ValueError):
700
+ pass
701
+ st.session_state.metrics_rows = rows
702
+ st.session_state.metrics_done = True
703
+ st.session_state.metrics_signature = fetch_signature
704
+ progress.empty()
705
+ status_slot.empty()
706
+ models_data = rows
707
+ st.rerun()
708
  else:
709
+ st.info("Tick Auto-load or press Refresh to fetch KL/Refusals.")
710
+ st.stop()
711
+
712
+ if not models_data:
713
+ st.warning("No models found with the 'heretic' tag in this scope.")
714
+ st.stop()
715
+
716
+ df = pd.DataFrame(models_data)
717
+ # Make Model ID a clickable link
718
+ df["Model"] = df["URL"]
719
+ # Select and order columns for display (Created kept for filtering, shown in table too)
720
+ cols = ["Model", "KL Divergence", "Initial Refusals", "Refusals", "Refusal Rate (%)", "Likes", "Downloads"]
721
+ if "Created" in df.columns:
722
+ cols.append("Created")
723
+ display_df = df[cols]
724
+
725
+ # --- LATE metric filters (need parsed KL/Refusals) ---
726
+ # NOTE: no writes to st.session_state widget keys here β€” that was the
727
+ # StreamlitWidgetAlreadyInstantiatedError. Slider max is extended to fit the
728
+ # stored value instead of writing back.
729
+ kl_max_data = float(pd.to_numeric(display_df["KL Divergence"], errors="coerce").max() or 0.5)
730
+ _cur_kl = st.session_state.get("kl_range", FILTER_DEFAULTS["kl_range"])
731
+ try:
732
+ _cur_hi = float(_cur_kl[1])
733
+ except Exception:
734
+ _cur_hi = 0.5
735
+ kl_slider_max = max(0.5, kl_max_data, _cur_hi)
736
+
737
+ toolbar_sort, toolbar_reset = st.columns([3, 1])
738
+ with toolbar_sort:
739
+ sort_by = st.selectbox("Sort by", list(SORT_OPTIONS.keys()), key="sort_by")
740
+ with toolbar_reset:
741
+ st.write("")
742
+ st.button("Reset filters", key="reset_filters", use_container_width=True, on_click=_reset_filters)
743
+
744
+ with st.sidebar:
745
+ st.header("Metric filters")
746
+ kl_range = st.slider(
747
+ "KL Divergence",
748
+ min_value=0.0, max_value=float(kl_slider_max), step=0.001, format="%.3f",
749
+ key="kl_range",
750
  )
751
+ refusal_range = st.slider(
752
+ "Refusal Rate (%)",
753
+ min_value=0.0, max_value=100.0, step=0.5, format="%.1f",
754
+ key="refusal_range",
755
+ )
756
+ min_likes = st.number_input("Min Likes", min_value=0, step=1, key="min_likes")
757
+ min_downloads = st.number_input("Min Downloads", min_value=0, step=10, key="min_downloads")
758
+
759
+ sync_filter_state_to_query_params()
760
+
761
+ query = st.session_state.get("query", "")
762
+ show_quants = st.session_state.get("show_quants", False)
763
+ date_range = st.session_state.get("date_range", "All time")
764
+ unknowns = st.session_state.get("unknowns", "Include unknowns")
765
+ filtered_df = apply_filters(display_df, query, kl_range, refusal_range, min_likes, min_downloads, show_quants, date_range, unknowns)
766
+ sort_col, ascending = SORT_OPTIONS.get(sort_by, ("KL Divergence", True))
767
+ if sort_col in filtered_df.columns:
768
+ filtered_df = filtered_df.sort_values(sort_col, ascending=ascending, kind="mergesort").reset_index(drop=True)
769
+
770
+ st.markdown(f"**Showing {len(filtered_df)} of {len(display_df)} models.**")
771
+
772
+ try:
773
+ import altair as alt
774
+ _ALT_OK = True
775
+ except ImportError:
776
+ alt = None # type: ignore
777
+ _ALT_OK = False
778
+
779
+ tab_table, tab_compare, tab_charts = st.tabs(["Table", "Compare", "Charts"])
780
+
781
+ # 1. Table β€” original config preserved
782
+ with tab_table:
783
+ if filtered_df.empty:
784
+ st.info("No models match the current filter.")
785
+ else:
786
+ st.dataframe(
787
+ filtered_df,
788
+ column_config={
789
+ "Model": st.column_config.LinkColumn("Model", display_text=r"https://huggingface\.co/(.*)"),
790
+ "KL Divergence": st.column_config.NumberColumn("KL Divergence", format="%.4f"),
791
+ "Initial Refusals": st.column_config.TextColumn("Initial Refusals"),
792
+ "Refusals": st.column_config.TextColumn("Refusals"),
793
+ "Refusal Rate (%)": st.column_config.NumberColumn("Refusal Rate (%)", format="%.2f%%"),
794
+ "Likes": st.column_config.NumberColumn("Likes"),
795
+ "Downloads": st.column_config.NumberColumn("Downloads"),
796
+ "Created": st.column_config.TextColumn("Created"),
797
+ },
798
+ hide_index=True,
799
+ width='stretch'
800
+ )
801
+
802
+ # 2. Compare β€” up to 4, transposed, NO winner trophies
803
+ with tab_compare:
804
+ if filtered_df.empty:
805
+ st.info("No models to compare β€” adjust filter.")
806
+ else:
807
+ st.caption(
808
+ "Grain of salt: a 0/x refusal alone proves little. "
809
+ "A 30/30 β†’ 0/30 on a tiny sample is weaker than 95/100 β†’ 2/100, "
810
+ "and zero refusals with high KL often means the weights got wrecked β€” "
811
+ "uncensored but stupid. Read Refusal **with** KL + sample size."
812
+ )
813
+ options = filtered_df["Model"].tolist()
814
+ selected = st.multiselect(
815
+ "Select up to 4 models to compare",
816
+ options=options,
817
+ default=options[:2] if len(options) >= 2 else options,
818
+ max_selections=4,
819
+ format_func=_short,
820
+ )
821
+ if len(selected) < 2:
822
+ st.info("Select at least 2 models to compare.")
823
+ else:
824
+ comp = filtered_df[filtered_df["Model"].isin(selected)].set_index("Model").reindex(selected)
825
+ comp["KL Divergence"] = pd.to_numeric(comp["KL Divergence"], errors="coerce")
826
+ comp["Refusal Rate (%)"] = pd.to_numeric(comp["Refusal Rate (%)"], errors="coerce")
827
+ # Per-model honest summary (no best_kl / best_ref verdicts)
828
+ for m in selected:
829
+ row = comp.loc[m]
830
+ rn, rd = _parse_fraction(row.get("Refusals"))
831
+ inn, ind = _parse_fraction(row.get("Initial Refusals"))
832
+ note = _compare_note(row.get("KL Divergence"), rn, rd, inn, ind)
833
+ band = _kl_band(row.get("KL Divergence"))
834
+ st.info(f"**{_short(m)}** β€” KL {band} | {note}")
835
+ disp = comp.T.copy()
836
+ disp.columns = [_short(c) for c in disp.columns]
837
+ # Extra derived rows for honest comparison
838
+ try:
839
+ kl_vals = pd.to_numeric(comp["KL Divergence"], errors="coerce")
840
+ disp.loc["KL band"] = [_kl_band(v) for v in kl_vals.reindex(selected).tolist()]
841
+ except Exception:
842
+ pass
843
+ try:
844
+ drops = []
845
+ for m in selected:
846
+ rn, rd = _parse_fraction(comp.loc[m].get("Refusals"))
847
+ inn, ind = _parse_fraction(comp.loc[m].get("Initial Refusals"))
848
+ drops.append(str(inn - rn) if inn is not None and rn is not None else "β€”")
849
+ disp.loc["Refusal drop (base βˆ’ final)"] = drops
850
+ except Exception:
851
+ pass
852
+ try:
853
+ ns = []
854
+ for m in selected:
855
+ _, rd = _parse_fraction(comp.loc[m].get("Refusals"))
856
+ ns.append(str(rd) if rd is not None else "β€”")
857
+ disp.loc["Sample size (n prompts)"] = ns
858
+ except Exception:
859
+ pass
860
+ for metric in list(disp.index):
861
+ for col_url, col_short in zip(selected, disp.columns):
862
+ v = comp.loc[col_url, metric] if metric in comp.columns else disp.loc[metric, col_short]
863
+ if pd.isna(v):
864
+ disp.loc[metric, col_short] = "β€”"
865
+ elif metric == "KL Divergence":
866
+ try:
867
+ disp.loc[metric, col_short] = f"{float(v):.4f}"
868
+ except (TypeError, ValueError):
869
+ disp.loc[metric, col_short] = str(v)
870
+ elif metric == "Refusal Rate (%)":
871
+ try:
872
+ disp.loc[metric, col_short] = f"{float(v):.2f}%"
873
+ except (TypeError, ValueError):
874
+ disp.loc[metric, col_short] = str(v)
875
+ elif metric in ("Likes", "Downloads"):
876
+ try:
877
+ disp.loc[metric, col_short] = f"{int(float(v)):,}"
878
+ except Exception:
879
+ disp.loc[metric, col_short] = str(v)
880
+ else:
881
+ disp.loc[metric, col_short] = str(v) if v is not None else "β€”"
882
+ disp.index.name = "Metric"
883
+ st.table(disp)
884
+
885
+ # 3. Charts β€” altair, NaN/empty safe
886
+ with tab_charts:
887
+ if not _ALT_OK:
888
+ st.warning("`altair` is not installed. Add `altair` to requirements.txt and reboot.")
889
+ elif filtered_df.empty:
890
+ st.info("No data to chart β€” adjust filter.")
891
+ else:
892
+ chart_df = filtered_df.copy()
893
+ chart_df["Short"] = chart_df["Model"].astype(str).str.split("huggingface.co/").str[-1]
894
+ chart_df["KL Divergence"] = pd.to_numeric(chart_df["KL Divergence"], errors="coerce")
895
+ chart_df["Refusal Rate (%)"] = pd.to_numeric(chart_df["Refusal Rate (%)"], errors="coerce")
896
+ chart_df["Likes"] = pd.to_numeric(chart_df["Likes"], errors="coerce").fillna(0)
897
+ chart_df["Downloads"] = pd.to_numeric(chart_df["Downloads"], errors="coerce").fillna(0)
898
+ scatter_df = chart_df.dropna(subset=["KL Divergence", "Refusal Rate (%)"])
899
+ if scatter_df.empty:
900
+ st.info("No valid KL / Refusal Rate values to plot.")
901
+ else:
902
+ st.subheader("KL (x) vs Refusal Rate (y), size=Downloads")
903
+ scatter = (
904
+ alt.Chart(scatter_df)
905
+ .mark_circle(opacity=0.7)
906
+ .encode(
907
+ x=alt.X("KL Divergence:Q", scale=alt.Scale(zero=False)),
908
+ y=alt.Y("Refusal Rate (%):Q", scale=alt.Scale(zero=False)),
909
+ size=alt.Size("Downloads:Q", scale=alt.Scale(range=[20, 500])),
910
+ tooltip=["Short:N", "KL Divergence:Q", "Refusal Rate (%):Q", "Likes:Q", "Downloads:Q"],
911
+ )
912
+ .interactive()
913
+ )
914
+ st.altair_chart(scatter, use_container_width=True)
915
+ st.subheader("Refusal Rate per model (top 20 by Likes)")
916
+ top20 = chart_df.nlargest(20, "Likes").sort_values("Refusal Rate (%)", ascending=True)
917
+ top20 = top20.dropna(subset=["Refusal Rate (%)"])
918
+ if top20.empty:
919
+ st.info("No valid Refusal Rate values for bar chart.")
920
+ else:
921
+ bars = (
922
+ alt.Chart(top20)
923
+ .mark_bar()
924
+ .encode(
925
+ x=alt.X("Refusal Rate (%):Q"),
926
+ y=alt.Y("Short:N", sort="-x", title="Model"),
927
+ tooltip=["Short:N", "Refusal Rate (%):Q", "KL Divergence:Q", "Likes:Q", "Downloads:Q"],
928
+ )
929
+ )
930
+ st.altair_chart(bars, use_container_width=True)
931
+ st.subheader("Distribution of KL Divergence")
932
+ kl_df = chart_df.dropna(subset=["KL Divergence"])
933
+ if kl_df.empty:
934
+ st.info("No valid KL values for histogram.")
935
+ else:
936
+ hist = (
937
+ alt.Chart(kl_df)
938
+ .mark_bar()
939
+ .encode(
940
+ x=alt.X("KL Divergence:Q", bin=alt.Bin(maxbins=20)),
941
+ y=alt.Y("count():Q", title="Models"),
942
+ tooltip=[alt.Tooltip("count():Q", title="Models")],
943
+ )
944
+ )
945
+ st.altair_chart(hist, use_container_width=True)
data/.gitkeep ADDED
File without changes
data/popularity.json ADDED
@@ -0,0 +1 @@
 
 
1
+ {"models": {}, "updated_at": null}
docker-compose.yml ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ services:
2
+ explorer:
3
+ build: .
4
+ image: heretic-models-explorer:pr1
5
+ ports:
6
+ - "8501:8501"
7
+ environment:
8
+ - STREAMLIT_SERVER_HEADLESS=true
9
+ restart: unless-stopped
src/popularity_store.py ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Popularity store: likes/downloads separated from reasoning metrics.
2
+
3
+ Why this file exists:
4
+ - Likes/Downloads are *volatile* HF API metadata (change hourly).
5
+ - KL Divergence / Refusals are *expensive* parsed reasoning metrics
6
+ (README + reproduce.json fetch + regex, 20 workers).
7
+ - Mixing them in one cache key (`cached_get_model_data(model_id, likes, downloads)`)
8
+ churns the expensive cache every time someone likes a model.
9
+
10
+ Well-grounded approach: yes β€” keep them separate.
11
+ - Reasoning cache: keyed by model_id only, long TTL (1h+).
12
+ - Popularity: short TTL snapshot + refreshable JSON file
13
+ (`data/popularity.json`) that can be rewritten with minimal impact
14
+ (no re-parse of READMEs, just a cheap HfApi list).
15
+
16
+ File format (data/popularity.json):
17
+ {
18
+ "updated_at": "2026-09-14T12:00:00+00:00",
19
+ "models": {
20
+ "<model_id>": {"likes": 12, "downloads": 345, "url": "https://..."},
21
+ ...
22
+ }
23
+ }
24
+ Missing file -> treated as empty map (graceful, Docker-safe).
25
+ """
26
+
27
+ import json
28
+ import os
29
+ from datetime import datetime, timezone
30
+
31
+ # Resolve repo-root/data/popularity.json from either:
32
+ # /app/src/popularity_store.py -> /app/data/popularity.json (Docker)
33
+ # <root>/src/popularity_store.py -> <root>/data/popularity.json (local)
34
+ _THIS_DIR = os.path.dirname(os.path.abspath(__file__))
35
+ _CANDIDATES = [
36
+ os.path.join(os.path.dirname(_THIS_DIR), "data", "popularity.json"),
37
+ os.path.join(os.getcwd(), "data", "popularity.json"),
38
+ os.path.join("/app", "data", "popularity.json"),
39
+ ]
40
+
41
+
42
+ def popularity_path() -> str:
43
+ for p in _CANDIDATES:
44
+ d = os.path.dirname(p)
45
+ if os.path.isdir(d):
46
+ return p
47
+ return _CANDIDATES[0]
48
+
49
+
50
+ def load_popularity() -> dict:
51
+ """Return {model_id: {likes, downloads, url}}. Empty dict if missing/corrupt."""
52
+ path = popularity_path()
53
+ try:
54
+ with open(path, "r", encoding="utf-8") as f:
55
+ data = json.load(f)
56
+ models = data.get("models", {}) if isinstance(data, dict) else {}
57
+ return models if isinstance(models, dict) else {}
58
+ except (FileNotFoundError, json.JSONDecodeError, OSError):
59
+ return {}
60
+
61
+
62
+ def save_popularity(models_map: dict) -> str:
63
+ """Persist {model_id: {likes, downloads}} snapshot. Returns path written."""
64
+ path = popularity_path()
65
+ os.makedirs(os.path.dirname(path), exist_ok=True)
66
+ payload = {
67
+ "updated_at": datetime.now(timezone.utc).isoformat(),
68
+ "models": models_map,
69
+ }
70
+ tmp = path + ".tmp"
71
+ with open(tmp, "w", encoding="utf-8") as f:
72
+ json.dump(payload, f, indent=2, sort_keys=True)
73
+ os.replace(tmp, path)
74
+ return path
75
+
76
+
77
+ def snapshot_from_metadata(metadata: list) -> dict:
78
+ """Build file-ready map from cheap HfApi metadata list."""
79
+ out = {}
80
+ for d in metadata or []:
81
+ mid = d.get("Model ID")
82
+ if not mid:
83
+ continue
84
+ try:
85
+ likes = int(d.get("Likes", 0) or 0)
86
+ except (TypeError, ValueError):
87
+ likes = 0
88
+ try:
89
+ downloads = int(d.get("Downloads", 0) or 0)
90
+ except (TypeError, ValueError):
91
+ downloads = 0
92
+ out[mid] = {"likes": likes, "downloads": downloads, "url": d.get("URL")}
93
+ return out
94
+
95
+
96
+ def merge_popularity(rows: list, metadata: list, file_map: dict | None = None) -> list:
97
+ """Enrich parsed reasoning rows with likes/downloads.
98
+
99
+ Precedence per model: file snapshot > live metadata > row's own values.
100
+ This lets you refresh popularity (rewrite one JSON) without touching
101
+ the expensive KL/refusal cache.
102
+ """
103
+ if file_map is None:
104
+ file_map = load_popularity()
105
+ meta_map = {d.get("Model ID"): d for d in (metadata or [])}
106
+ for r in rows or []:
107
+ mid = r.get("Model ID")
108
+ file_hit = (file_map or {}).get(mid, {}) if mid else {}
109
+ meta_hit = meta_map.get(mid, {}) if mid else {}
110
+ if isinstance(file_hit, dict) and ("likes" in file_hit or "downloads" in file_hit):
111
+ try:
112
+ r["Likes"] = int(file_hit.get("likes", r.get("Likes", 0)) or 0)
113
+ except (TypeError, ValueError):
114
+ pass
115
+ try:
116
+ r["Downloads"] = int(file_hit.get("downloads", r.get("Downloads", 0)) or 0)
117
+ except (TypeError, ValueError):
118
+ pass
119
+ else:
120
+ if mid in meta_map:
121
+ try:
122
+ r["Likes"] = int(meta_hit.get("Likes", r.get("Likes", 0)) or 0)
123
+ except (TypeError, ValueError):
124
+ pass
125
+ try:
126
+ r["Downloads"] = int(meta_hit.get("Downloads", r.get("Downloads", 0)) or 0)
127
+ except (TypeError, ValueError):
128
+ pass
129
+ return rows
src/streamlit_app.py CHANGED
@@ -2,9 +2,12 @@ import streamlit as st
2
  import pandas as pd
3
  from huggingface_hub import HfApi
4
  import requests
 
5
  import re
6
  import concurrent.futures
7
  import logging
 
 
8
  from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type
9
 
10
  logging.basicConfig(level=logging.WARNING, format="%(asctime)s - %(levelname)s - %(message)s")
@@ -20,13 +23,73 @@ st.markdown(
20
  "allowing you to sort and compare them easily. Click on any column header to sort!"
21
  )
22
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
  @retry(
24
  retry=retry_if_exception_type((requests.exceptions.ConnectionError, requests.exceptions.Timeout, requests.exceptions.HTTPError)),
25
  stop=stop_after_attempt(3),
26
  wait=wait_exponential(multiplier=1, min=2, max=10)
27
  )
28
  def fetch_url(url):
29
- response = requests.get(url, timeout=5)
30
  # Raise HTTPError for rate limits (429) or server errors (5xx) to trigger a retry
31
  if response.status_code in (429, 500, 502, 503, 504):
32
  response.raise_for_status()
@@ -40,14 +103,14 @@ def fetch_model_info(model):
40
  refusals_str = None
41
  refusal_rate = None
42
  initial_refusals_str = None
43
-
44
  # Download the README.md via raw URL for high-speed fetching
45
  url = f"https://huggingface.co/{model_id}/raw/main/README.md"
46
  try:
47
  response = fetch_url(url)
48
  if response.status_code == 200:
49
  readme_text = response.text
50
-
51
  # Extract KL divergence using Regex
52
  # Matches formats like: "KL divergence | 0.0033"
53
  kl_match = re.search(r"(?i)KL\s*divergence[^a-zA-Z\d\n]*?([\d\.]+)", readme_text)
@@ -56,7 +119,7 @@ def fetch_model_info(model):
56
  kl_div = float(kl_match.group(1))
57
  except ValueError as e:
58
  logging.warning(f"Could not parse KL divergence for {model_id}: {e}")
59
-
60
  # Extract Refusals using Regex
61
  # Matches formats like: "Refusals | 15/100" or "15 / 100"
62
  for line in readme_text.split('\n'):
@@ -65,19 +128,19 @@ def fetch_model_info(model):
65
  fractions = re.findall(r"(\d+)\s*/\s*(\d+)", line)
66
  if not fractions:
67
  continue
68
-
69
  if 'initial' in line_lower and initial_refusals_str is None:
70
  initial_refusals_str = f"{fractions[0][0]}/{fractions[0][1]}"
71
  elif 'initial' not in line_lower and refusals_str is None:
72
  refusals_str = f"{fractions[0][0]}/{fractions[0][1]}"
73
  try:
74
- refusal_rate = (int(fractions[0][0]) / int(fractions[0][1])) * 100
75
  except (ValueError, ZeroDivisionError) as e:
76
  logging.warning(f"Could not parse refusals for {model_id}: {e}")
77
  continue
78
  if len(fractions) >= 2 and initial_refusals_str is None:
79
  initial_refusals_str = f"{fractions[1][0]}/{fractions[1][1]}"
80
-
81
  except requests.exceptions.RequestException as e:
82
  logging.warning(f"Error fetching README for {model_id}: {e}")
83
  except Exception as e:
@@ -98,7 +161,7 @@ def fetch_model_info(model):
98
  "URL": f"https://huggingface.co/{model_id}"
99
  }
100
 
101
- # Check for reproducibility record first and then fallback to parsing data from readme.md
102
  def get_model_data(model):
103
  model_id = model.id
104
  url = f"https://huggingface.co/{model_id}/raw/main/reproduce/reproduce.json"
@@ -134,58 +197,749 @@ def get_model_data(model):
134
  logging.error(f"Unexpected error processing JSON for {model_id}: {e}")
135
  return fetch_model_info(model)
136
 
137
- @st.cache_data(ttl=3600, show_spinner=False) # Cache for 1 hour
138
- def get_heretic_models():
139
- """Fetches all heretic models and their metrics concurrently."""
 
 
 
 
 
 
 
 
140
  api = HfApi()
141
-
142
  # Query all models using the Hugging Face Hub `filter` parameter
143
  models = list(api.list_models(filter="heretic"))
144
-
145
- # Fetch details concurrently for speed (10 workers to avoid API rate limiting issues)
146
- # Skip quantised models
147
- quant_patterns = re.compile(r'(?i)(gguf|mlx|awq|nvfp4|gptq|exl[23]|-quant|int8|int4|oq[1-8]|mxfp[48])')
148
- models = [m for m in models if not quant_patterns.search(m.id)]
149
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
150
  data = []
151
-
152
- with concurrent.futures.ThreadPoolExecutor(max_workers=10) as executor:
153
- results = executor.map(get_model_data, models)
 
 
154
  for res in results:
155
  if res is not None:
156
  data.append(res)
157
-
158
  return data
159
 
160
- # Main execution
161
- with st.spinner("Fetching heretic models and parsing model cards... This might take a moment on the first run."):
162
- models_data = get_heretic_models()
 
 
 
 
 
163
 
164
- if not models_data:
165
- st.warning("No models found with the 'heretic' tag.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
166
  else:
167
- df = pd.DataFrame(models_data)
168
-
169
- # Make Model ID a clickable Markdown link
170
- df["Model"] = df["URL"]
171
-
172
- # Select and order columns for display
173
- display_df = df[["Model", "KL Divergence", "Initial Refusals", "Refusals", "Refusal Rate (%)", "Likes", "Downloads"]]
174
-
175
- st.markdown(f"**Found {len(display_df)} models.**")
176
-
177
- # Display as an interactive, sortable dataframe
178
- st.dataframe(
179
- display_df,
180
- column_config={
181
- "Model": st.column_config.LinkColumn("Model", display_text=r"https://huggingface\.co/(.*)"),
182
- "KL Divergence": st.column_config.NumberColumn("KL Divergence", format="%.4f"),
183
- "Initial Refusals": st.column_config.TextColumn("Initial Refusals"),
184
- "Refusals": st.column_config.TextColumn("Refusals"),
185
- "Refusal Rate (%)": st.column_config.NumberColumn("Refusal Rate (%)", format="%.2f%%"),
186
- "Likes": st.column_config.NumberColumn("Likes"),
187
- "Downloads": st.column_config.NumberColumn("Downloads")
188
- },
189
- hide_index=True,
190
- width='stretch'
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
191
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
  import pandas as pd
3
  from huggingface_hub import HfApi
4
  import requests
5
+ from requests.adapters import HTTPAdapter
6
  import re
7
  import concurrent.futures
8
  import logging
9
+ from datetime import datetime, timezone
10
+ from types import SimpleNamespace
11
  from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type
12
 
13
  logging.basicConfig(level=logging.WARNING, format="%(asctime)s - %(levelname)s - %(message)s")
 
23
  "allowing you to sort and compare them easily. Click on any column header to sort!"
24
  )
25
 
26
+ # ---------------------------------------------------------------------------
27
+ # Filter config (single source of truth)
28
+ # ---------------------------------------------------------------------------
29
+ FILTER_DEFAULTS = {
30
+ "query": "",
31
+ "date_range": "All time",
32
+ "unknowns": "Include unknowns",
33
+ "kl_range": (0.0, 0.5),
34
+ "refusal_range": (0.0, 100.0),
35
+ "min_likes": 0,
36
+ "min_downloads": 0,
37
+ "show_quants": False,
38
+ "sort_by": "KL Divergence (low β†’ high)",
39
+ }
40
+
41
+ DATE_OPTIONS = {
42
+ "All time": None,
43
+ "Last week": 7,
44
+ "Last month": 30,
45
+ "Last 6 months": 182,
46
+ "Last year": 365,
47
+ }
48
+
49
+ UNKNOWN_OPTIONS = (
50
+ "Include unknowns",
51
+ "Hide unknowns",
52
+ "Only unknowns",
53
+ )
54
+
55
+ SORT_OPTIONS = {
56
+ "KL Divergence (low β†’ high)": ("KL Divergence", True),
57
+ "KL Divergence (high β†’ low)": ("KL Divergence", False),
58
+ "Refusal Rate (%) (low β†’ high)": ("Refusal Rate (%)", True),
59
+ "Refusal Rate (%) (high β†’ low)": ("Refusal Rate (%)", False),
60
+ "Likes (high β†’ low)": ("Likes", False),
61
+ "Downloads (high β†’ low)": ("Downloads", False),
62
+ "Model (A β†’ Z)": ("Model", True),
63
+ }
64
+
65
+ QUANT_PATTERNS = re.compile(r'(?i)(gguf|mlx|awq|nvfp4|gptq|exl[23]|-quant|int8|int4|oq[1-8]|mxfp[48])')
66
+ QUANT_RE = r"(?i)(gguf|mlx|awq|nvfp4|gptq|exl[23]|-quant|int8|int4|oq[1-8]|mxfp[48])"
67
+
68
+ # ---------------------------------------------------------------------------
69
+ # Network: reused Session + retry
70
+ # ---------------------------------------------------------------------------
71
+ _SESSION = None
72
+
73
+
74
+ def _get_session():
75
+ global _SESSION
76
+ if _SESSION is None:
77
+ s = requests.Session()
78
+ adapter = HTTPAdapter(pool_connections=20, pool_maxsize=20, max_retries=0)
79
+ s.mount("https://", adapter)
80
+ s.mount("http://", adapter)
81
+ s.headers.update({"User-Agent": "Heretic-Models-Explorer/1.0"})
82
+ _SESSION = s
83
+ return _SESSION
84
+
85
+
86
  @retry(
87
  retry=retry_if_exception_type((requests.exceptions.ConnectionError, requests.exceptions.Timeout, requests.exceptions.HTTPError)),
88
  stop=stop_after_attempt(3),
89
  wait=wait_exponential(multiplier=1, min=2, max=10)
90
  )
91
  def fetch_url(url):
92
+ response = _get_session().get(url, timeout=(3.05, 5))
93
  # Raise HTTPError for rate limits (429) or server errors (5xx) to trigger a retry
94
  if response.status_code in (429, 500, 502, 503, 504):
95
  response.raise_for_status()
 
103
  refusals_str = None
104
  refusal_rate = None
105
  initial_refusals_str = None
106
+
107
  # Download the README.md via raw URL for high-speed fetching
108
  url = f"https://huggingface.co/{model_id}/raw/main/README.md"
109
  try:
110
  response = fetch_url(url)
111
  if response.status_code == 200:
112
  readme_text = response.text
113
+
114
  # Extract KL divergence using Regex
115
  # Matches formats like: "KL divergence | 0.0033"
116
  kl_match = re.search(r"(?i)KL\s*divergence[^a-zA-Z\d\n]*?([\d\.]+)", readme_text)
 
119
  kl_div = float(kl_match.group(1))
120
  except ValueError as e:
121
  logging.warning(f"Could not parse KL divergence for {model_id}: {e}")
122
+
123
  # Extract Refusals using Regex
124
  # Matches formats like: "Refusals | 15/100" or "15 / 100"
125
  for line in readme_text.split('\n'):
 
128
  fractions = re.findall(r"(\d+)\s*/\s*(\d+)", line)
129
  if not fractions:
130
  continue
131
+
132
  if 'initial' in line_lower and initial_refusals_str is None:
133
  initial_refusals_str = f"{fractions[0][0]}/{fractions[0][1]}"
134
  elif 'initial' not in line_lower and refusals_str is None:
135
  refusals_str = f"{fractions[0][0]}/{fractions[0][1]}"
136
  try:
137
+ refusal_rate = (int(fractions[0][0]) / int(fractions[0][1])) * 100
138
  except (ValueError, ZeroDivisionError) as e:
139
  logging.warning(f"Could not parse refusals for {model_id}: {e}")
140
  continue
141
  if len(fractions) >= 2 and initial_refusals_str is None:
142
  initial_refusals_str = f"{fractions[1][0]}/{fractions[1][1]}"
143
+
144
  except requests.exceptions.RequestException as e:
145
  logging.warning(f"Error fetching README for {model_id}: {e}")
146
  except Exception as e:
 
161
  "URL": f"https://huggingface.co/{model_id}"
162
  }
163
 
164
+ # Check for reproducibility record first and then fallback to parsing data from readme.md
165
  def get_model_data(model):
166
  model_id = model.id
167
  url = f"https://huggingface.co/{model_id}/raw/main/reproduce/reproduce.json"
 
197
  logging.error(f"Unexpected error processing JSON for {model_id}: {e}")
198
  return fetch_model_info(model)
199
 
200
+ # ---------------------------------------------------------------------------
201
+ # Two-stage cached loading (faster initial load)
202
+ # ---------------------------------------------------------------------------
203
+ @st.cache_data(ttl=3600, show_spinner=False)
204
+ def list_heretic_metadata():
205
+ """Stage 1: HfApi only, no per-model fetch. Cheap and fast.
206
+
207
+ Returns ALL models (including quants) with Created/LastModified dates
208
+ so the caller can pre-filter by date/query BEFORE the expensive
209
+ per-model README/reproduce.json parsing.
210
+ """
211
  api = HfApi()
 
212
  # Query all models using the Hugging Face Hub `filter` parameter
213
  models = list(api.list_models(filter="heretic"))
214
+ out = []
215
+ for m in models:
216
+ mid = m.id
217
+ created = getattr(m, "created_at", None)
218
+ modified = getattr(m, "last_modified", None)
219
+ # Normalise to ISO strings (cache-safe, JSON-safe)
220
+ try:
221
+ created_iso = created.isoformat() if hasattr(created, "isoformat") else (str(created) if created else None)
222
+ except Exception:
223
+ created_iso = None
224
+ try:
225
+ modified_iso = modified.isoformat() if hasattr(modified, "isoformat") else (str(modified) if modified else None)
226
+ except Exception:
227
+ modified_iso = None
228
+ out.append({
229
+ "Model ID": mid,
230
+ "Likes": getattr(m, "likes", 0) or 0,
231
+ "Downloads": getattr(m, "downloads", 0) or 0,
232
+ "URL": f"https://huggingface.co/{mid}",
233
+ "Created": created_iso,
234
+ "LastModified": modified_iso,
235
+ })
236
+ return out
237
+
238
+
239
+ @st.cache_data(ttl=3600, show_spinner=False)
240
+ def cached_get_model_data(model_id: str, likes: int = 0, downloads: int = 0):
241
+ """Stage 2: per-model reasoning cache, keyed by model_id only.
242
+
243
+ likes/downloads are accepted for back-compat but IGNORED in the key path:
244
+ popularity is merged later via popularity_store so a like-spike doesn't
245
+ invalidate the expensive KL/refusal parse. Pass model_id alone going forward.
246
+ """
247
+ proxy = SimpleNamespace(id=model_id, likes=0, downloads=0)
248
+ return get_model_data(proxy)
249
+
250
+
251
+ @st.cache_data(ttl=3600, show_spinner=False) # Cache for 1 hour, back-compat shim
252
+ def get_heretic_models():
253
+ """Fetches all heretic models and their metrics concurrently."""
254
+ meta = list_heretic_metadata()
255
  data = []
256
+ with concurrent.futures.ThreadPoolExecutor(max_workers=20) as executor:
257
+ results = executor.map(
258
+ lambda d: cached_get_model_data(d["Model ID"], d["Likes"], d["Downloads"]),
259
+ meta,
260
+ )
261
  for res in results:
262
  if res is not None:
263
  data.append(res)
 
264
  return data
265
 
266
+ # ---------------------------------------------------------------------------
267
+ # Filtering (pure, testable)
268
+ # ---------------------------------------------------------------------------
269
+ def _model_id_series(df: pd.DataFrame) -> pd.Series:
270
+ col = "Model ID" if "Model ID" in df.columns else "Model"
271
+ if col in df.columns:
272
+ return df[col].astype("string")
273
+ return pd.Series([""] * len(df), index=df.index, dtype="string")
274
 
275
+
276
+ def _parse_created(v):
277
+ """Parse Created ISO string to aware datetime, or None."""
278
+ if v is None or (isinstance(v, float) and pd.isna(v)):
279
+ return None
280
+ if isinstance(v, datetime):
281
+ dt = v
282
+ else:
283
+ try:
284
+ dt = datetime.fromisoformat(str(v).replace("Z", "+00:00"))
285
+ except Exception:
286
+ return None
287
+ if dt is not None and dt.tzinfo is None:
288
+ dt = dt.replace(tzinfo=timezone.utc)
289
+ return dt
290
+
291
+
292
+ def filter_metadata_by_date(metadata: list, date_range_key: str, unknowns_key: str = "Include unknowns") -> list:
293
+ """Pre-filter raw metadata list by Created date BEFORE expensive parsing.
294
+
295
+ Unknowns handling is now an explicit user choice (not silently kept):
296
+ - "Include unknowns": keep models with missing Created date (old default).
297
+ - "Hide unknowns": drop them β€” strictest, smallest parse set.
298
+ - "Only unknowns": parse ONLY models with missing dates (audit mode).
299
+ """
300
+ days = DATE_OPTIONS.get(date_range_key, None)
301
+ if unknowns_key not in UNKNOWN_OPTIONS:
302
+ unknowns_key = "Include unknowns"
303
+ if days is None and unknowns_key == "Include unknowns":
304
+ return list(metadata)
305
+ cutoff = None
306
+ if days is not None:
307
+ cutoff = datetime.now(timezone.utc) - pd.Timedelta(days=days).to_pytimedelta()
308
+ kept = []
309
+ for d in metadata:
310
+ dt = _parse_created(d.get("Created"))
311
+ if dt is None:
312
+ if unknowns_key in ("Include unknowns", "Only unknowns"):
313
+ kept.append(d)
314
+ elif cutoff is None:
315
+ # All time + Hide unknowns -> drop unknowns (already skipped), keep dated
316
+ kept.append(d)
317
+ elif unknowns_key == "Only unknowns":
318
+ continue # dated rows excluded in audit mode
319
+ elif dt >= cutoff:
320
+ kept.append(d)
321
+ return kept
322
+
323
+
324
+ def prefilter_metadata(metadata: list, query: str, show_quants: bool, date_range_key: str, unknowns_key: str = "Include unknowns") -> list:
325
+ """Cheap pre-fetch filter: date (+unknowns handling) + quant + query. No network."""
326
+ out = filter_metadata_by_date(metadata, date_range_key, unknowns_key)
327
+ if not show_quants:
328
+ out = [d for d in out if not QUANT_PATTERNS.search(d.get("Model ID", ""))]
329
+ if query is not None:
330
+ q = str(query).strip().lower()
331
+ if q:
332
+ out = [d for d in out if q in str(d.get("Model ID", "")).lower()]
333
+ return out
334
+
335
+
336
+ def apply_filters(df, query, kl_range, refusal_range,
337
+ min_likes, min_downloads, show_quants, date_range="All time", unknowns="Include unknowns"):
338
+ """Pure filter. Never mutates input. Handles empty df, NaNs, case-insensitive query."""
339
+ if df is None or len(df) == 0:
340
+ return df.copy() if isinstance(df, pd.DataFrame) else pd.DataFrame()
341
+ mask = pd.Series(True, index=df.index)
342
+ if query is not None:
343
+ q = str(query).strip()
344
+ if q:
345
+ ids = _model_id_series(df).fillna("")
346
+ mask &= ids.str.contains(q, case=False, na=False, regex=False)
347
+ if kl_range is not None and "KL Divergence" in df.columns:
348
+ try:
349
+ lo, hi = float(kl_range[0]), float(kl_range[1])
350
+ lo, hi = (hi, lo) if lo > hi else (lo, hi)
351
+ kl = pd.to_numeric(df["KL Divergence"], errors="coerce")
352
+ mask &= kl.between(lo, hi, inclusive="both")
353
+ except (TypeError, ValueError, IndexError):
354
+ pass
355
+ if refusal_range is not None and "Refusal Rate (%)" in df.columns:
356
+ try:
357
+ lo, hi = float(refusal_range[0]), float(refusal_range[1])
358
+ lo, hi = (hi, lo) if lo > hi else (lo, hi)
359
+ rr = pd.to_numeric(df["Refusal Rate (%)"], errors="coerce")
360
+ mask &= rr.between(lo, hi, inclusive="both")
361
+ except (TypeError, ValueError, IndexError):
362
+ pass
363
+ for col, thresh in (("Likes", min_likes), ("Downloads", min_downloads)):
364
+ if col in df.columns and thresh is not None:
365
+ try:
366
+ t = float(thresh)
367
+ except (TypeError, ValueError):
368
+ continue
369
+ if pd.isna(t) or t <= 0:
370
+ continue
371
+ vals = pd.to_numeric(df[col], errors="coerce").fillna(0)
372
+ mask &= vals >= t
373
+ if not show_quants:
374
+ ids = _model_id_series(df).fillna("")
375
+ mask &= ~ids.str.contains(QUANT_RE, case=False, na=False, regex=True)
376
+ # Date filter on Created column (post-fetch safety net; main saving is pre-fetch)
377
+ days = DATE_OPTIONS.get(date_range, None)
378
+ if unknowns not in UNKNOWN_OPTIONS:
379
+ unknowns = "Include unknowns"
380
+ if "Created" in df.columns and (days is not None or unknowns != "Include unknowns"):
381
+ try:
382
+ cutoff = None
383
+ if days is not None:
384
+ cutoff = datetime.now(timezone.utc) - pd.Timedelta(days=days).to_pytimedelta()
385
+ parsed = df["Created"].apply(_parse_created)
386
+ if unknowns == "Only unknowns":
387
+ date_mask = parsed.apply(lambda dt: dt is None)
388
+ elif unknowns == "Hide unknowns":
389
+ if cutoff is None:
390
+ date_mask = parsed.apply(lambda dt: dt is not None)
391
+ else:
392
+ date_mask = parsed.apply(lambda dt: dt is not None and dt >= cutoff)
393
+ else: # Include unknowns
394
+ if cutoff is None:
395
+ date_mask = pd.Series(True, index=df.index)
396
+ else:
397
+ date_mask = parsed.apply(lambda dt: True if dt is None else dt >= cutoff)
398
+ mask &= date_mask.fillna(True).to_numpy() if hasattr(date_mask, "fillna") else date_mask
399
+ except Exception:
400
+ pass
401
+ return df.loc[mask].copy().reset_index(drop=True)
402
+
403
+
404
+ def _qp_first(key, default=""):
405
+ v = st.query_params.get(key, default)
406
+ return v[0] if isinstance(v, list) and v else v
407
+
408
+
409
+ def _to_float(v, default):
410
+ try:
411
+ f = float(v)
412
+ return f if not pd.isna(f) else default
413
+ except (TypeError, ValueError):
414
+ return default
415
+
416
+
417
+ def _to_int(v, default):
418
+ try:
419
+ return max(0, int(float(v)))
420
+ except (TypeError, ValueError):
421
+ return default
422
+
423
+
424
+ def init_filter_state_from_query_params():
425
+ if st.session_state.get("_qp_init"):
426
+ return
427
+ d = FILTER_DEFAULTS
428
+ kl_lo = _to_float(_qp_first("kl_min", d["kl_range"][0]), d["kl_range"][0])
429
+ kl_hi = _to_float(_qp_first("kl_max", d["kl_range"][1]), d["kl_range"][1])
430
+ rr_lo = _to_float(_qp_first("rr_min", d["refusal_range"][0]), d["refusal_range"][0])
431
+ rr_hi = _to_float(_qp_first("rr_max", d["refusal_range"][1]), d["refusal_range"][1])
432
+ st.session_state.setdefault("query", str(_qp_first("q", d["query"])))
433
+ _dr = str(_qp_first("date_range", d["date_range"]))
434
+ st.session_state.setdefault("date_range", _dr if _dr in DATE_OPTIONS else d["date_range"])
435
+ _unk = str(_qp_first("unknowns", d["unknowns"]))
436
+ st.session_state.setdefault("unknowns", _unk if _unk in UNKNOWN_OPTIONS else d["unknowns"])
437
+ st.session_state.setdefault("kl_range", (min(kl_lo, kl_hi), max(kl_lo, kl_hi)))
438
+ st.session_state.setdefault("refusal_range", (min(rr_lo, rr_hi), max(rr_lo, rr_hi)))
439
+ st.session_state.setdefault("min_likes", _to_int(_qp_first("min_likes", d["min_likes"]), d["min_likes"]))
440
+ st.session_state.setdefault("min_downloads", _to_int(_qp_first("min_downloads", d["min_downloads"]), d["min_downloads"]))
441
+ st.session_state.setdefault("show_quants", str(_qp_first("show_quants", "")).lower() in ("1", "true", "yes"))
442
+ sort = str(_qp_first("sort_by", d["sort_by"]))
443
+ st.session_state.setdefault("sort_by", sort if sort in SORT_OPTIONS else d["sort_by"])
444
+ st.session_state["_qp_init"] = True
445
+
446
+
447
+ def sync_filter_state_to_query_params():
448
+ st.query_params["q"] = st.session_state.get("query", "")
449
+ st.query_params["date_range"] = str(st.session_state.get("date_range", FILTER_DEFAULTS["date_range"]))
450
+ st.query_params["unknowns"] = str(st.session_state.get("unknowns", FILTER_DEFAULTS["unknowns"]))
451
+ kl = st.session_state.get("kl_range", FILTER_DEFAULTS["kl_range"])
452
+ rr = st.session_state.get("refusal_range", FILTER_DEFAULTS["refusal_range"])
453
+ st.query_params["kl_min"] = str(float(kl[0]))
454
+ st.query_params["kl_max"] = str(float(kl[1]))
455
+ st.query_params["rr_min"] = str(float(rr[0]))
456
+ st.query_params["rr_max"] = str(float(rr[1]))
457
+ st.query_params["min_likes"] = str(int(st.session_state.get("min_likes", 0) or 0))
458
+ st.query_params["min_downloads"] = str(int(st.session_state.get("min_downloads", 0) or 0))
459
+ st.query_params["show_quants"] = "1" if st.session_state.get("show_quants") else "0"
460
+ st.query_params["sort_by"] = str(st.session_state.get("sort_by", FILTER_DEFAULTS["sort_by"]))
461
+
462
+
463
+ def _short(url: str) -> str:
464
+ s = str(url)
465
+ return s.split("huggingface.co/")[-1] if "huggingface.co/" in s else s
466
+
467
+
468
+ def _parse_fraction(s) -> tuple:
469
+ """Parse '15/100' β†’ (15, 100). Returns (None, None) on failure."""
470
+ if s is None or (isinstance(s, float) and pd.isna(s)):
471
+ return (None, None)
472
+ m = re.search(r"(\d+)\s*/\s*(\d+)", str(s))
473
+ if not m:
474
+ return (None, None)
475
+ try:
476
+ return (int(m.group(1)), int(m.group(2)))
477
+ except (ValueError, ZeroDivisionError):
478
+ return (None, None)
479
+
480
+
481
+ def _kl_band(kl) -> str:
482
+ """Rough weight-impact bands. Not a verdict β€” higher KL = more brain drift."""
483
+ if kl is None or (isinstance(kl, float) and pd.isna(kl)):
484
+ return "unknown"
485
+ try:
486
+ kl = float(kl)
487
+ except (TypeError, ValueError):
488
+ return "unknown"
489
+ if kl < 0.01:
490
+ return "minimal drift"
491
+ if kl < 0.05:
492
+ return "moderate drift"
493
+ if kl < 0.15:
494
+ return "high drift"
495
+ return "very high drift"
496
+
497
+
498
+ def _compare_note(kl, ref_num, ref_den, init_num, init_den) -> str:
499
+ """One-line grain-of-salt note per model. No trophies."""
500
+ parts = []
501
+ # Sample-size caution
502
+ if ref_den is None:
503
+ parts.append("unknown sample size β€” weak evidence")
504
+ elif ref_den < 50:
505
+ parts.append(f"small sample (n={ref_den}) β€” take with grain of salt")
506
+ # Drop vs base
507
+ if init_num is not None and ref_num is not None and init_den and ref_den:
508
+ drop = init_num - ref_num
509
+ parts.append(f"drop {drop} (base {init_num}/{init_den} β†’ {ref_num}/{ref_den})")
510
+ # KL vs refusal tradeoff β€” the core complaint: 0% refusal + high KL = stupid
511
+ try:
512
+ klf = float(kl) if kl is not None else None
513
+ except (TypeError, ValueError):
514
+ klf = None
515
+ try:
516
+ rate = (ref_num / ref_den * 100) if ref_num is not None and ref_den else None
517
+ except ZeroDivisionError:
518
+ rate = None
519
+ if rate is not None and klf is not None:
520
+ if rate == 0 and klf > 0.1:
521
+ parts.append("0 refusals BUT high KL β€” possibly lobotomized, check reasoning")
522
+ elif rate == 0 and klf > 0.05:
523
+ parts.append("0 refusals with notable drift β€” verify quality")
524
+ elif rate <= 5 and klf is not None and klf < 0.02:
525
+ parts.append("good balance: low refusal + low drift")
526
+ elif rate > 20:
527
+ parts.append("still refusal-prone, regardless of KL")
528
+ elif klf is not None and klf > 0.1:
529
+ parts.append("high KL β€” expect reasoning damage")
530
+ return "; ".join(parts) if parts else "β€”"
531
+
532
+
533
+ # ---------------------------------------------------------------------------
534
+ # Main execution: metadata instantly, pre-filter by date, then parse subset
535
+ # ---------------------------------------------------------------------------
536
+ init_filter_state_from_query_params()
537
+
538
+
539
+ def _reset_filters():
540
+ """Callback for Reset button β€” runs BEFORE widgets instantiate, so no
541
+ StreamlitWidgetAlreadyInstantiatedError (the crash you saw)."""
542
+ for k, v in FILTER_DEFAULTS.items():
543
+ st.session_state[k] = v
544
+ st.query_params.clear()
545
+ st.session_state.metrics_done = False
546
+ st.session_state.metrics_rows = []
547
+ st.session_state.metrics_signature = None
548
+
549
+
550
+ if "metrics_done" not in st.session_state:
551
+ st.session_state.metrics_done = False
552
+ if "metrics_rows" not in st.session_state:
553
+ st.session_state.metrics_rows = []
554
+ if "metrics_signature" not in st.session_state:
555
+ st.session_state.metrics_signature = None
556
+
557
+ # --- EARLY pre-filters: these decide HOW MANY models get parsed ---
558
+ # Shown before any expensive README/reproduce.json fetching.
559
+ st.sidebar.header("Load scope (saves parsing time)")
560
+ early_date = st.sidebar.selectbox(
561
+ "Release date",
562
+ list(DATE_OPTIONS.keys()),
563
+ key="date_range",
564
+ help="Pre-filters by Hugging Face creation date BEFORE parsing. Pick a recent window to avoid parsing ~2000 models.",
565
+ )
566
+ early_unknowns = st.sidebar.selectbox(
567
+ "Unknown dates",
568
+ list(UNKNOWN_OPTIONS),
569
+ key="unknowns",
570
+ help="Models with missing HF creation date. Include (old default), Hide (strictest, smallest parse), or Only (audit them).",
571
+ )
572
+ early_query = st.sidebar.text_input(
573
+ "Search model ID (pre-filter)",
574
+ key="query",
575
+ placeholder="e.g. llama-3 heretic …",
576
+ help="Case-insensitive substring. Applied BEFORE parsing to skip non-matching models.",
577
+ )
578
+ early_quants = st.sidebar.checkbox(
579
+ "Show quantised models",
580
+ key="show_quants",
581
+ help="Untick to hide GGUF/MLX/AWQ/GPTQ/EXL2/EXL3/quant/INT8/INT4/OQ/MXFP4/MXFP8 before parsing.",
582
+ )
583
+ try:
584
+ from src.popularity_store import load_popularity, save_popularity, snapshot_from_metadata
585
+ except ImportError:
586
+ try:
587
+ from popularity_store import load_popularity, save_popularity, snapshot_from_metadata
588
+ except ImportError:
589
+ load_popularity = lambda: {} # noqa: E731
590
+ save_popularity = lambda m: "" # noqa: E731
591
+ snapshot_from_metadata = lambda md: {} # noqa: E731
592
+ with st.sidebar.expander("Popularity (likes/downloads)", expanded=False):
593
+ st.caption("Stored separately in data/popularity.json β€” refreshable without re-parsing READMEs.")
594
+ if st.button("πŸ”„ Refresh popularity file", use_container_width=True):
595
+ try:
596
+ _live = list_heretic_metadata()
597
+ _path = save_popularity(snapshot_from_metadata(_live))
598
+ st.success(f"Saved {len(_live)} entries to {_path or 'data/popularity.json'}")
599
+ except Exception as e:
600
+ st.error(f"Could not save popularity: {e}")
601
+ try:
602
+ _pop = load_popularity()
603
+ st.caption(f"{len(_pop)} models in popularity file.")
604
+ except Exception:
605
+ pass
606
+
607
+ col_a, col_b = st.columns([1, 4])
608
+ with col_a:
609
+ refresh = st.button("πŸ”„ Refresh metrics", use_container_width=True)
610
+ with col_b:
611
+ auto = st.checkbox("Auto-load metrics", value=True)
612
+
613
+ try:
614
+ with st.spinner("Fetching model list (fast metadata)..."):
615
+ metadata = list_heretic_metadata()
616
+ except Exception as e:
617
+ st.error(f"Could not list heretic models: {e}")
618
+ st.stop()
619
+
620
+ # Cheap pre-filter BEFORE expensive parsing β€” this is the load-time win.
621
+ target_meta = prefilter_metadata(metadata, early_query, early_quants, early_date, early_unknowns)
622
+ st.caption(
623
+ f"Found {len(metadata)} models (metadata cached 1h). "
624
+ f"Scope '{early_date}' + '{early_unknowns}' + search β†’ **{len(target_meta)} to parse**."
625
+ )
626
+ if len(target_meta) == 0:
627
+ st.info("No models in this date/search scope β€” widen the date range, set Unknowns to Include, or clear search.")
628
+ st.stop()
629
+ if len(target_meta) > 500:
630
+ st.warning(
631
+ f"Scope selects {len(target_meta)} models β€” parsing will take a while. "
632
+ "Tip: pick 'Last month' / 'Last week', Hide unknowns, or add a search term."
633
+ )
634
+
635
+ fetch_signature = (early_date, early_unknowns, early_query.strip().lower() if early_query else "", bool(early_quants))
636
+ if st.session_state.metrics_signature is not None and st.session_state.metrics_signature != fetch_signature:
637
+ # Scope changed β†’ old parsed rows no longer valid
638
+ st.session_state.metrics_done = False
639
+ st.session_state.metrics_rows = []
640
+
641
+ should_run = (auto and not st.session_state.metrics_done) or refresh
642
+ if refresh:
643
+ st.session_state.metrics_done = False
644
+ st.session_state.metrics_rows = []
645
+
646
+ if st.session_state.metrics_done and st.session_state.metrics_signature == fetch_signature:
647
+ models_data = st.session_state.metrics_rows
648
+ elif should_run:
649
+ status_slot = st.empty()
650
+ progress = st.progress(0)
651
+ rows = []
652
+ with status_slot.status("Fetching reproduce.json β†’ README fallback...", expanded=False) as status:
653
+ with concurrent.futures.ThreadPoolExecutor(max_workers=20) as ex:
654
+ fut_to_id = {
655
+ ex.submit(cached_get_model_data, d["Model ID"], d["Likes"], d["Downloads"]): d["Model ID"]
656
+ for d in target_meta
657
+ }
658
+ done = 0
659
+ for fut in concurrent.futures.as_completed(fut_to_id):
660
+ try:
661
+ res = fut.result()
662
+ if res is not None:
663
+ rows.append(res)
664
+ except Exception as e:
665
+ logging.warning(f"Worker failed for {fut_to_id[fut]}: {e}")
666
+ done += 1
667
+ progress.progress(done / max(len(target_meta), 1))
668
+ status.update(label=f"Parsed {done}/{len(target_meta)} β€” {len(rows)} with metrics...")
669
+ status.update(label=f"Done: {len(rows)}/{len(target_meta)} with metrics", state="complete")
670
+ # Merge Created dates back in for display/filtering + popularity
671
+ # (likes/downloads live in popularity_store, NOT in the reasoning cache key)
672
+ created_map = {d["Model ID"]: d.get("Created") for d in target_meta}
673
+ meta_like_map = {d["Model ID"]: d for d in target_meta}
674
+ try:
675
+ _file_map = load_popularity()
676
+ except Exception:
677
+ _file_map = {}
678
+ for r in rows:
679
+ r.setdefault("Created", created_map.get(r.get("Model ID")))
680
+ mid = r.get("Model ID")
681
+ file_hit = (_file_map or {}).get(mid, {}) if mid else {}
682
+ meta_hit = meta_like_map.get(mid, {}) if mid else {}
683
+ if isinstance(file_hit, dict) and ("likes" in file_hit or "downloads" in file_hit):
684
+ try:
685
+ r["Likes"] = int(file_hit.get("likes", r.get("Likes", 0)) or 0)
686
+ except (TypeError, ValueError):
687
+ pass
688
+ try:
689
+ r["Downloads"] = int(file_hit.get("downloads", r.get("Downloads", 0)) or 0)
690
+ except (TypeError, ValueError):
691
+ pass
692
+ elif mid in meta_like_map:
693
+ try:
694
+ r["Likes"] = int(meta_hit.get("Likes", r.get("Likes", 0)) or 0)
695
+ except (TypeError, ValueError):
696
+ pass
697
+ try:
698
+ r["Downloads"] = int(meta_hit.get("Downloads", r.get("Downloads", 0)) or 0)
699
+ except (TypeError, ValueError):
700
+ pass
701
+ st.session_state.metrics_rows = rows
702
+ st.session_state.metrics_done = True
703
+ st.session_state.metrics_signature = fetch_signature
704
+ progress.empty()
705
+ status_slot.empty()
706
+ models_data = rows
707
+ st.rerun()
708
  else:
709
+ st.info("Tick Auto-load or press Refresh to fetch KL/Refusals.")
710
+ st.stop()
711
+
712
+ if not models_data:
713
+ st.warning("No models found with the 'heretic' tag in this scope.")
714
+ st.stop()
715
+
716
+ df = pd.DataFrame(models_data)
717
+ # Make Model ID a clickable link
718
+ df["Model"] = df["URL"]
719
+ # Select and order columns for display (Created kept for filtering, shown in table too)
720
+ cols = ["Model", "KL Divergence", "Initial Refusals", "Refusals", "Refusal Rate (%)", "Likes", "Downloads"]
721
+ if "Created" in df.columns:
722
+ cols.append("Created")
723
+ display_df = df[cols]
724
+
725
+ # --- LATE metric filters (need parsed KL/Refusals) ---
726
+ # NOTE: no writes to st.session_state widget keys here β€” that was the
727
+ # StreamlitWidgetAlreadyInstantiatedError. Slider max is extended to fit the
728
+ # stored value instead of writing back.
729
+ kl_max_data = float(pd.to_numeric(display_df["KL Divergence"], errors="coerce").max() or 0.5)
730
+ _cur_kl = st.session_state.get("kl_range", FILTER_DEFAULTS["kl_range"])
731
+ try:
732
+ _cur_hi = float(_cur_kl[1])
733
+ except Exception:
734
+ _cur_hi = 0.5
735
+ kl_slider_max = max(0.5, kl_max_data, _cur_hi)
736
+
737
+ toolbar_sort, toolbar_reset = st.columns([3, 1])
738
+ with toolbar_sort:
739
+ sort_by = st.selectbox("Sort by", list(SORT_OPTIONS.keys()), key="sort_by")
740
+ with toolbar_reset:
741
+ st.write("")
742
+ st.button("Reset filters", key="reset_filters", use_container_width=True, on_click=_reset_filters)
743
+
744
+ with st.sidebar:
745
+ st.header("Metric filters")
746
+ kl_range = st.slider(
747
+ "KL Divergence",
748
+ min_value=0.0, max_value=float(kl_slider_max), step=0.001, format="%.3f",
749
+ key="kl_range",
750
  )
751
+ refusal_range = st.slider(
752
+ "Refusal Rate (%)",
753
+ min_value=0.0, max_value=100.0, step=0.5, format="%.1f",
754
+ key="refusal_range",
755
+ )
756
+ min_likes = st.number_input("Min Likes", min_value=0, step=1, key="min_likes")
757
+ min_downloads = st.number_input("Min Downloads", min_value=0, step=10, key="min_downloads")
758
+
759
+ sync_filter_state_to_query_params()
760
+
761
+ query = st.session_state.get("query", "")
762
+ show_quants = st.session_state.get("show_quants", False)
763
+ date_range = st.session_state.get("date_range", "All time")
764
+ unknowns = st.session_state.get("unknowns", "Include unknowns")
765
+ filtered_df = apply_filters(display_df, query, kl_range, refusal_range, min_likes, min_downloads, show_quants, date_range, unknowns)
766
+ sort_col, ascending = SORT_OPTIONS.get(sort_by, ("KL Divergence", True))
767
+ if sort_col in filtered_df.columns:
768
+ filtered_df = filtered_df.sort_values(sort_col, ascending=ascending, kind="mergesort").reset_index(drop=True)
769
+
770
+ st.markdown(f"**Showing {len(filtered_df)} of {len(display_df)} models.**")
771
+
772
+ try:
773
+ import altair as alt
774
+ _ALT_OK = True
775
+ except ImportError:
776
+ alt = None # type: ignore
777
+ _ALT_OK = False
778
+
779
+ tab_table, tab_compare, tab_charts = st.tabs(["Table", "Compare", "Charts"])
780
+
781
+ # 1. Table β€” original config preserved
782
+ with tab_table:
783
+ if filtered_df.empty:
784
+ st.info("No models match the current filter.")
785
+ else:
786
+ st.dataframe(
787
+ filtered_df,
788
+ column_config={
789
+ "Model": st.column_config.LinkColumn("Model", display_text=r"https://huggingface\.co/(.*)"),
790
+ "KL Divergence": st.column_config.NumberColumn("KL Divergence", format="%.4f"),
791
+ "Initial Refusals": st.column_config.TextColumn("Initial Refusals"),
792
+ "Refusals": st.column_config.TextColumn("Refusals"),
793
+ "Refusal Rate (%)": st.column_config.NumberColumn("Refusal Rate (%)", format="%.2f%%"),
794
+ "Likes": st.column_config.NumberColumn("Likes"),
795
+ "Downloads": st.column_config.NumberColumn("Downloads"),
796
+ "Created": st.column_config.TextColumn("Created"),
797
+ },
798
+ hide_index=True,
799
+ width='stretch'
800
+ )
801
+
802
+ # 2. Compare β€” up to 4, transposed, NO winner trophies
803
+ with tab_compare:
804
+ if filtered_df.empty:
805
+ st.info("No models to compare β€” adjust filter.")
806
+ else:
807
+ st.caption(
808
+ "Grain of salt: a 0/x refusal alone proves little. "
809
+ "A 30/30 β†’ 0/30 on a tiny sample is weaker than 95/100 β†’ 2/100, "
810
+ "and zero refusals with high KL often means the weights got wrecked β€” "
811
+ "uncensored but stupid. Read Refusal **with** KL + sample size."
812
+ )
813
+ options = filtered_df["Model"].tolist()
814
+ selected = st.multiselect(
815
+ "Select up to 4 models to compare",
816
+ options=options,
817
+ default=options[:2] if len(options) >= 2 else options,
818
+ max_selections=4,
819
+ format_func=_short,
820
+ )
821
+ if len(selected) < 2:
822
+ st.info("Select at least 2 models to compare.")
823
+ else:
824
+ comp = filtered_df[filtered_df["Model"].isin(selected)].set_index("Model").reindex(selected)
825
+ comp["KL Divergence"] = pd.to_numeric(comp["KL Divergence"], errors="coerce")
826
+ comp["Refusal Rate (%)"] = pd.to_numeric(comp["Refusal Rate (%)"], errors="coerce")
827
+ # Per-model honest summary (no best_kl / best_ref verdicts)
828
+ for m in selected:
829
+ row = comp.loc[m]
830
+ rn, rd = _parse_fraction(row.get("Refusals"))
831
+ inn, ind = _parse_fraction(row.get("Initial Refusals"))
832
+ note = _compare_note(row.get("KL Divergence"), rn, rd, inn, ind)
833
+ band = _kl_band(row.get("KL Divergence"))
834
+ st.info(f"**{_short(m)}** β€” KL {band} | {note}")
835
+ disp = comp.T.copy()
836
+ disp.columns = [_short(c) for c in disp.columns]
837
+ # Extra derived rows for honest comparison
838
+ try:
839
+ kl_vals = pd.to_numeric(comp["KL Divergence"], errors="coerce")
840
+ disp.loc["KL band"] = [_kl_band(v) for v in kl_vals.reindex(selected).tolist()]
841
+ except Exception:
842
+ pass
843
+ try:
844
+ drops = []
845
+ for m in selected:
846
+ rn, rd = _parse_fraction(comp.loc[m].get("Refusals"))
847
+ inn, ind = _parse_fraction(comp.loc[m].get("Initial Refusals"))
848
+ drops.append(str(inn - rn) if inn is not None and rn is not None else "β€”")
849
+ disp.loc["Refusal drop (base βˆ’ final)"] = drops
850
+ except Exception:
851
+ pass
852
+ try:
853
+ ns = []
854
+ for m in selected:
855
+ _, rd = _parse_fraction(comp.loc[m].get("Refusals"))
856
+ ns.append(str(rd) if rd is not None else "β€”")
857
+ disp.loc["Sample size (n prompts)"] = ns
858
+ except Exception:
859
+ pass
860
+ for metric in list(disp.index):
861
+ for col_url, col_short in zip(selected, disp.columns):
862
+ v = comp.loc[col_url, metric] if metric in comp.columns else disp.loc[metric, col_short]
863
+ if pd.isna(v):
864
+ disp.loc[metric, col_short] = "β€”"
865
+ elif metric == "KL Divergence":
866
+ try:
867
+ disp.loc[metric, col_short] = f"{float(v):.4f}"
868
+ except (TypeError, ValueError):
869
+ disp.loc[metric, col_short] = str(v)
870
+ elif metric == "Refusal Rate (%)":
871
+ try:
872
+ disp.loc[metric, col_short] = f"{float(v):.2f}%"
873
+ except (TypeError, ValueError):
874
+ disp.loc[metric, col_short] = str(v)
875
+ elif metric in ("Likes", "Downloads"):
876
+ try:
877
+ disp.loc[metric, col_short] = f"{int(float(v)):,}"
878
+ except Exception:
879
+ disp.loc[metric, col_short] = str(v)
880
+ else:
881
+ disp.loc[metric, col_short] = str(v) if v is not None else "β€”"
882
+ disp.index.name = "Metric"
883
+ st.table(disp)
884
+
885
+ # 3. Charts β€” altair, NaN/empty safe
886
+ with tab_charts:
887
+ if not _ALT_OK:
888
+ st.warning("`altair` is not installed. Add `altair` to requirements.txt and reboot.")
889
+ elif filtered_df.empty:
890
+ st.info("No data to chart β€” adjust filter.")
891
+ else:
892
+ chart_df = filtered_df.copy()
893
+ chart_df["Short"] = chart_df["Model"].astype(str).str.split("huggingface.co/").str[-1]
894
+ chart_df["KL Divergence"] = pd.to_numeric(chart_df["KL Divergence"], errors="coerce")
895
+ chart_df["Refusal Rate (%)"] = pd.to_numeric(chart_df["Refusal Rate (%)"], errors="coerce")
896
+ chart_df["Likes"] = pd.to_numeric(chart_df["Likes"], errors="coerce").fillna(0)
897
+ chart_df["Downloads"] = pd.to_numeric(chart_df["Downloads"], errors="coerce").fillna(0)
898
+ scatter_df = chart_df.dropna(subset=["KL Divergence", "Refusal Rate (%)"])
899
+ if scatter_df.empty:
900
+ st.info("No valid KL / Refusal Rate values to plot.")
901
+ else:
902
+ st.subheader("KL (x) vs Refusal Rate (y), size=Downloads")
903
+ scatter = (
904
+ alt.Chart(scatter_df)
905
+ .mark_circle(opacity=0.7)
906
+ .encode(
907
+ x=alt.X("KL Divergence:Q", scale=alt.Scale(zero=False)),
908
+ y=alt.Y("Refusal Rate (%):Q", scale=alt.Scale(zero=False)),
909
+ size=alt.Size("Downloads:Q", scale=alt.Scale(range=[20, 500])),
910
+ tooltip=["Short:N", "KL Divergence:Q", "Refusal Rate (%):Q", "Likes:Q", "Downloads:Q"],
911
+ )
912
+ .interactive()
913
+ )
914
+ st.altair_chart(scatter, use_container_width=True)
915
+ st.subheader("Refusal Rate per model (top 20 by Likes)")
916
+ top20 = chart_df.nlargest(20, "Likes").sort_values("Refusal Rate (%)", ascending=True)
917
+ top20 = top20.dropna(subset=["Refusal Rate (%)"])
918
+ if top20.empty:
919
+ st.info("No valid Refusal Rate values for bar chart.")
920
+ else:
921
+ bars = (
922
+ alt.Chart(top20)
923
+ .mark_bar()
924
+ .encode(
925
+ x=alt.X("Refusal Rate (%):Q"),
926
+ y=alt.Y("Short:N", sort="-x", title="Model"),
927
+ tooltip=["Short:N", "Refusal Rate (%):Q", "KL Divergence:Q", "Likes:Q", "Downloads:Q"],
928
+ )
929
+ )
930
+ st.altair_chart(bars, use_container_width=True)
931
+ st.subheader("Distribution of KL Divergence")
932
+ kl_df = chart_df.dropna(subset=["KL Divergence"])
933
+ if kl_df.empty:
934
+ st.info("No valid KL values for histogram.")
935
+ else:
936
+ hist = (
937
+ alt.Chart(kl_df)
938
+ .mark_bar()
939
+ .encode(
940
+ x=alt.X("KL Divergence:Q", bin=alt.Bin(maxbins=20)),
941
+ y=alt.Y("count():Q", title="Models"),
942
+ tooltip=[alt.Tooltip("count():Q", title="Models")],
943
+ )
944
+ )
945
+ st.altair_chart(hist, use_container_width=True)