NeerajCodz commited on
Commit
9cdb79f
Β·
1 Parent(s): 7523db4

fix: memory overload by adding top models and options to load singular models

Browse files
api/main.py CHANGED
@@ -53,6 +53,7 @@ from fastapi.responses import FileResponse
53
 
54
  from api.model_registry import registry, registry_v1, registry_v2, registry_v3
55
  from api.schemas import HealthResponse
 
56
  from src.utils.logger import get_logger
57
 
58
  log = get_logger(__name__)
@@ -67,10 +68,20 @@ _FRONTEND_DIST = _HERE.parent / "frontend" / "dist"
67
  # ── Lifespan ─────────────────────────────────────────────────────────────────
68
  @asynccontextmanager
69
  async def lifespan(app: FastAPI):
70
- """Load only v3 (latest) models on startup; v1/v2 loaded on-demand."""
71
  log.info("Loading model registries …")
72
- registry_v3.load_all()
73
- log.info("v3 registry ready β€” %d models loaded", registry_v3.model_count)
 
 
 
 
 
 
 
 
 
 
74
  # v1 and v2 are NOT loaded at startup β€” download + load on-demand via
75
  # POST /api/versions/{v}/load to reduce startup time and memory usage.
76
  if _version_loaded("v1"):
@@ -118,6 +129,7 @@ async def health():
118
  # ── Version management ───────────────────────────────────────────────────────
119
  _REGISTRIES = {"v1": registry_v1, "v2": registry_v2, "v3": registry_v3}
120
  _version_status: dict[str, str] = {} # "downloading" | "ready" | "error"
 
121
 
122
 
123
  def _artifacts_dir() -> Path:
@@ -125,8 +137,14 @@ def _artifacts_dir() -> Path:
125
 
126
 
127
  def _version_loaded(version: str) -> bool:
128
- base = _artifacts_dir() / version / "models" / "classical"
129
- return any(base.glob("*.joblib")) if base.exists() else False
 
 
 
 
 
 
130
 
131
 
132
  @app.get("/api/versions", tags=["meta"])
@@ -157,7 +175,7 @@ async def list_versions():
157
 
158
 
159
  async def _bg_load_version(version: str) -> None:
160
- import subprocess, sys as _sys
161
  try:
162
  proc = await asyncio.create_subprocess_exec(
163
  _sys.executable, "scripts/download_models.py", "--version", version,
@@ -177,6 +195,33 @@ async def _bg_load_version(version: str) -> None:
177
  log.error("Failed to load version %s: %s", version, exc)
178
 
179
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
180
  @app.post("/api/versions/{version}/load", tags=["meta"])
181
  async def load_version(version: str, background_tasks: BackgroundTasks):
182
  """Download + activate a model version from HF Hub (runs in background)."""
@@ -210,6 +255,65 @@ async def get_version_models_meta(version: str):
210
  return _json.loads(meta_path.read_text(encoding="utf-8"))
211
 
212
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
213
  # ── Include routers ──────────────────────────────────────────────────────────
214
  from api.routers.predict import router as predict_router, v1_router
215
  from api.routers.predict_v2 import router as predict_v2_router
 
53
 
54
  from api.model_registry import registry, registry_v1, registry_v2, registry_v3
55
  from api.schemas import HealthResponse
56
+ from scripts.download_models import select_top_models, DEFAULT_STARTUP_TOP_MODELS
57
  from src.utils.logger import get_logger
58
 
59
  log = get_logger(__name__)
 
68
  # ── Lifespan ─────────────────────────────────────────────────────────────────
69
  @asynccontextmanager
70
  async def lifespan(app: FastAPI):
71
+ """Load only top v3 models on startup; v1/v2 loaded on-demand."""
72
  log.info("Loading model registries …")
73
+ startup_models = select_top_models("v3")
74
+ if startup_models:
75
+ registry_v3.load_all(only_models=set(startup_models))
76
+ log.info(
77
+ "v3 registry ready β€” %d models loaded (startup top-%d set: %s)",
78
+ registry_v3.model_count,
79
+ DEFAULT_STARTUP_TOP_MODELS,
80
+ ", ".join(startup_models),
81
+ )
82
+ else:
83
+ registry_v3.load_all()
84
+ log.warning("Top-5 selection unavailable; loaded full v3 registry")
85
  # v1 and v2 are NOT loaded at startup β€” download + load on-demand via
86
  # POST /api/versions/{v}/load to reduce startup time and memory usage.
87
  if _version_loaded("v1"):
 
129
  # ── Version management ───────────────────────────────────────────────────────
130
  _REGISTRIES = {"v1": registry_v1, "v2": registry_v2, "v3": registry_v3}
131
  _version_status: dict[str, str] = {} # "downloading" | "ready" | "error"
132
+ _model_status: dict[str, str] = {} # "vX:model" -> status
133
 
134
 
135
  def _artifacts_dir() -> Path:
 
137
 
138
 
139
  def _version_loaded(version: str) -> bool:
140
+ base = _artifacts_dir() / version / "models"
141
+ if not base.exists():
142
+ return False
143
+ return any(base.rglob("*.joblib")) or any(base.rglob("*.pt")) or any(base.rglob("*.keras"))
144
+
145
+
146
+ def _model_status_key(version: str, model_name: str) -> str:
147
+ return f"{version}:{model_name}"
148
 
149
 
150
  @app.get("/api/versions", tags=["meta"])
 
175
 
176
 
177
  async def _bg_load_version(version: str) -> None:
178
+ import sys as _sys
179
  try:
180
  proc = await asyncio.create_subprocess_exec(
181
  _sys.executable, "scripts/download_models.py", "--version", version,
 
195
  log.error("Failed to load version %s: %s", version, exc)
196
 
197
 
198
+ async def _bg_load_model(version: str, model_name: str) -> None:
199
+ import sys as _sys
200
+
201
+ key = _model_status_key(version, model_name)
202
+ try:
203
+ proc = await asyncio.create_subprocess_exec(
204
+ _sys.executable,
205
+ "scripts/download_models.py",
206
+ "--version",
207
+ version,
208
+ "--model",
209
+ model_name,
210
+ stdout=asyncio.subprocess.PIPE,
211
+ stderr=asyncio.subprocess.STDOUT,
212
+ )
213
+ await proc.wait()
214
+ if proc.returncode == 0 and _REGISTRIES[version].load_model(model_name):
215
+ _model_status[key] = "ready"
216
+ log.info("Model %s/%s loaded on demand", version, model_name)
217
+ else:
218
+ _model_status[key] = "error"
219
+ log.error("Failed to download/load model %s/%s", version, model_name)
220
+ except Exception as exc:
221
+ _model_status[key] = "error"
222
+ log.error("Failed to load model %s/%s: %s", version, model_name, exc)
223
+
224
+
225
  @app.post("/api/versions/{version}/load", tags=["meta"])
226
  async def load_version(version: str, background_tasks: BackgroundTasks):
227
  """Download + activate a model version from HF Hub (runs in background)."""
 
255
  return _json.loads(meta_path.read_text(encoding="utf-8"))
256
 
257
 
258
+ @app.get("/api/versions/{version}/models", tags=["meta"])
259
+ async def list_version_models(version: str):
260
+ """List all models in models.json with disk/memory/load status."""
261
+ if version not in _REGISTRIES:
262
+ raise HTTPException(status_code=400, detail=f"Unknown version '{version}'")
263
+
264
+ reg = _REGISTRIES[version]
265
+ rows = []
266
+ for model_name, info in reg._catalog.items():
267
+ key = _model_status_key(version, model_name)
268
+ status = _model_status.get(key)
269
+ loaded = model_name in reg.models
270
+ if status is None:
271
+ status = "ready" if loaded else ("on_disk" if reg.model_on_disk(model_name) else "not_downloaded")
272
+ rows.append({
273
+ "name": model_name,
274
+ "display_name": info.get("display_name", model_name),
275
+ "family": info.get("family", "unknown"),
276
+ "r2": info.get("r2"),
277
+ "has_file": bool(info.get("file")),
278
+ "on_disk": reg.model_on_disk(model_name),
279
+ "loaded": loaded,
280
+ "status": status,
281
+ })
282
+ return rows
283
+
284
+
285
+ @app.post("/api/versions/{version}/models/{model_name}/load", tags=["meta"])
286
+ async def load_single_model(version: str, model_name: str, background_tasks: BackgroundTasks):
287
+ """Download + activate one model without loading an entire version."""
288
+ if version not in _REGISTRIES:
289
+ raise HTTPException(status_code=400, detail=f"Unknown version '{version}'")
290
+
291
+ reg = _REGISTRIES[version]
292
+ if model_name not in reg._catalog:
293
+ raise HTTPException(status_code=404, detail=f"Unknown model '{model_name}' in {version}")
294
+
295
+ # Virtual models have no direct downloadable artifact.
296
+ if not reg._catalog.get(model_name, {}).get("file"):
297
+ if reg.load_model(model_name):
298
+ return {"status": "ready", "version": version, "model": model_name}
299
+ raise HTTPException(status_code=400, detail=f"Model '{model_name}' cannot be loaded directly")
300
+
301
+ key = _model_status_key(version, model_name)
302
+ if _model_status.get(key) == "downloading":
303
+ return {"status": "downloading", "version": version, "model": model_name}
304
+
305
+ if reg.model_on_disk(model_name):
306
+ if reg.load_model(model_name):
307
+ _model_status[key] = "ready"
308
+ return {"status": "ready", "version": version, "model": model_name}
309
+ _model_status[key] = "error"
310
+ raise HTTPException(status_code=500, detail=f"Model '{model_name}' exists on disk but failed to load")
311
+
312
+ _model_status[key] = "downloading"
313
+ background_tasks.add_task(_bg_load_model, version, model_name)
314
+ return {"status": "downloading", "version": version, "model": model_name}
315
+
316
+
317
  # ── Include routers ──────────────────────────────────────────────────────────
318
  from api.routers.predict import router as predict_router, v1_router
319
  from api.routers.predict_v2 import router as predict_v2_router
api/model_registry.py CHANGED
@@ -185,22 +185,49 @@ class ModelRegistry:
185
  cinfo = self._catalog.get(cname, {})
186
  self._ensemble_weights[cname] = cinfo.get("r2", 1.0)
187
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
188
  # ── Loading ──────────────────────────────────────────────────────────
189
- def load_all(self) -> None:
190
  """Scan artifacts/models and load all available model artefacts.
191
 
192
  Safe to call multiple times β€” subsequent calls are no-ops when the
193
  registry is already populated.
194
  """
195
- if self.models:
196
  log.debug("Registry already populated β€” skipping load_all()")
197
  return
198
  self._detect_device()
199
  self._load_scaler()
200
  self.feature_cols = self._load_feature_cols()
201
- self._load_classical()
202
- self._load_deep_pytorch()
203
- self._load_deep_keras()
204
  self._register_ensemble()
205
  self._choose_default()
206
  log.info(
@@ -217,7 +244,7 @@ class ModelRegistry:
217
  except ImportError:
218
  log.info("torch not installed β€” deep PyTorch models unavailable")
219
 
220
- def _load_classical(self) -> None:
221
  """Eagerly load all sklearn/XGBoost/LightGBM joblib artefacts."""
222
  cdir = self._models_dir / "classical"
223
  if not cdir.exists():
@@ -225,6 +252,10 @@ class ModelRegistry:
225
  return
226
  for p in sorted(cdir.glob("*.joblib")):
227
  name = p.stem
 
 
 
 
228
  # Skip non-model dumps (param search results, classifiers)
229
  if (
230
  "best_params" in name
@@ -308,7 +339,7 @@ class ModelRegistry:
308
  log.warning("Cannot build PyTorch model '%s': %s", name, exc)
309
  return None
310
 
311
- def _load_deep_pytorch(self) -> None:
312
  """Load PyTorch .pt state-dict files into reconstructed model instances."""
313
  ddir = self._models_dir / "deep"
314
  if not ddir.exists():
@@ -320,6 +351,10 @@ class ModelRegistry:
320
  return
321
  for p in sorted(ddir.glob("*.pt")):
322
  name = p.stem
 
 
 
 
323
  n_feat = self._detect_n_feat(p)
324
  model = self._build_pytorch_model(name, n_feat=n_feat)
325
  if model is None:
@@ -349,11 +384,20 @@ class ModelRegistry:
349
  "path": str(p), "load_error": str(exc),
350
  }
351
 
352
- def _load_deep_keras(self) -> None:
353
  """Load TensorFlow/Keras .keras model files."""
354
  ddir = self._models_dir / "deep"
355
  if not ddir.exists():
356
  return
 
 
 
 
 
 
 
 
 
357
  try:
358
  import tensorflow as tf
359
  except ImportError:
@@ -382,6 +426,10 @@ class ModelRegistry:
382
  _custom_objects = {}
383
  for p in sorted(ddir.glob("*.keras")):
384
  name = p.stem
 
 
 
 
385
  try:
386
  model = tf.keras.models.load_model(str(p), custom_objects=_custom_objects, safe_mode=False)
387
  self.models[name] = model
 
185
  cinfo = self._catalog.get(cname, {})
186
  self._ensemble_weights[cname] = cinfo.get("r2", 1.0)
187
 
188
+ def model_on_disk(self, model_name: str) -> bool:
189
+ """Return True when the model artifact file exists on disk.
190
+
191
+ Virtual models (e.g., best_ensemble) have no direct file and therefore
192
+ return False.
193
+ """
194
+ info = self._catalog.get(model_name, {})
195
+ rel = info.get("file")
196
+ if not rel:
197
+ return False
198
+ return (self._artifacts / rel).exists()
199
+
200
+ def load_model(self, model_name: str) -> bool:
201
+ """Load one model (and ensemble dependencies when applicable).
202
+
203
+ Returns True if the target model is available in memory after loading.
204
+ """
205
+ if model_name in self.models:
206
+ return True
207
+
208
+ targets: set[str] = {model_name}
209
+ if model_name == "best_ensemble":
210
+ targets.update(self._ensemble_components)
211
+
212
+ self.load_all(only_models=targets)
213
+ return model_name in self.models
214
+
215
  # ── Loading ──────────────────────────────────────────────────────────
216
+ def load_all(self, only_models: set[str] | None = None) -> None:
217
  """Scan artifacts/models and load all available model artefacts.
218
 
219
  Safe to call multiple times β€” subsequent calls are no-ops when the
220
  registry is already populated.
221
  """
222
+ if only_models is None and self.models:
223
  log.debug("Registry already populated β€” skipping load_all()")
224
  return
225
  self._detect_device()
226
  self._load_scaler()
227
  self.feature_cols = self._load_feature_cols()
228
+ self._load_classical(only_models=only_models)
229
+ self._load_deep_pytorch(only_models=only_models)
230
+ self._load_deep_keras(only_models=only_models)
231
  self._register_ensemble()
232
  self._choose_default()
233
  log.info(
 
244
  except ImportError:
245
  log.info("torch not installed β€” deep PyTorch models unavailable")
246
 
247
+ def _load_classical(self, only_models: set[str] | None = None) -> None:
248
  """Eagerly load all sklearn/XGBoost/LightGBM joblib artefacts."""
249
  cdir = self._models_dir / "classical"
250
  if not cdir.exists():
 
252
  return
253
  for p in sorted(cdir.glob("*.joblib")):
254
  name = p.stem
255
+ if only_models is not None and name not in only_models:
256
+ continue
257
+ if name in self.models:
258
+ continue
259
  # Skip non-model dumps (param search results, classifiers)
260
  if (
261
  "best_params" in name
 
339
  log.warning("Cannot build PyTorch model '%s': %s", name, exc)
340
  return None
341
 
342
+ def _load_deep_pytorch(self, only_models: set[str] | None = None) -> None:
343
  """Load PyTorch .pt state-dict files into reconstructed model instances."""
344
  ddir = self._models_dir / "deep"
345
  if not ddir.exists():
 
351
  return
352
  for p in sorted(ddir.glob("*.pt")):
353
  name = p.stem
354
+ if only_models is not None and name not in only_models:
355
+ continue
356
+ if name in self.models:
357
+ continue
358
  n_feat = self._detect_n_feat(p)
359
  model = self._build_pytorch_model(name, n_feat=n_feat)
360
  if model is None:
 
384
  "path": str(p), "load_error": str(exc),
385
  }
386
 
387
+ def _load_deep_keras(self, only_models: set[str] | None = None) -> None:
388
  """Load TensorFlow/Keras .keras model files."""
389
  ddir = self._models_dir / "deep"
390
  if not ddir.exists():
391
  return
392
+
393
+ if only_models is not None:
394
+ requested_keras = any(
395
+ self._catalog.get(m, {}).get("family") == "deep_keras"
396
+ for m in only_models
397
+ )
398
+ if not requested_keras:
399
+ return
400
+
401
  try:
402
  import tensorflow as tf
403
  except ImportError:
 
426
  _custom_objects = {}
427
  for p in sorted(ddir.glob("*.keras")):
428
  name = p.stem
429
+ if only_models is not None and name not in only_models:
430
+ continue
431
+ if name in self.models:
432
+ continue
433
  try:
434
  model = tf.keras.models.load_model(str(p), custom_objects=_custom_objects, safe_mode=False)
435
  self.models[name] = model
frontend/src/api.ts CHANGED
@@ -86,6 +86,25 @@ export const fetchVersions = () =>
86
  export const loadVersion = (version: string) =>
87
  baseApi.post<{ status: string; version: string }>(`/versions/${version}/load`).then((r) => r.data);
88
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89
  /** Models metadata JSON for a specific version. */
90
  export const fetchVersionModelsMeta = (version: string) =>
91
  baseApi.get<any>(`/versions/${version}/models-meta`).then((r) => r.data);
 
86
  export const loadVersion = (version: string) =>
87
  baseApi.post<{ status: string; version: string }>(`/versions/${version}/load`).then((r) => r.data);
88
 
89
+ export interface VersionModelInfo {
90
+ name: string;
91
+ display_name: string;
92
+ family: string;
93
+ r2: number | null;
94
+ has_file: boolean;
95
+ on_disk: boolean;
96
+ loaded: boolean;
97
+ status: "ready" | "on_disk" | "not_downloaded" | "downloading" | "error";
98
+ }
99
+
100
+ export const fetchVersionModels = (version: string) =>
101
+ baseApi.get<VersionModelInfo[]>(`/versions/${version}/models`).then((r) => r.data);
102
+
103
+ export const loadVersionModel = (version: string, model: string) =>
104
+ baseApi
105
+ .post<{ status: string; version: string; model: string }>(`/versions/${version}/models/${model}/load`)
106
+ .then((r) => r.data);
107
+
108
  /** Models metadata JSON for a specific version. */
109
  export const fetchVersionModelsMeta = (version: string) =>
110
  baseApi.get<any>(`/versions/${version}/models-meta`).then((r) => r.data);
frontend/src/components/VersionSelector.tsx CHANGED
@@ -13,7 +13,14 @@ import { useCallback, useEffect, useRef, useState } from "react";
13
  import {
14
  ChevronRight, Download, Check, RefreshCw, AlertCircle, Layers, HardDrive,
15
  } from "lucide-react";
16
- import { fetchVersions, loadVersion, VersionInfo } from "../api";
 
 
 
 
 
 
 
17
 
18
  interface Props {
19
  activeVersion: "v1" | "v2" | "v3";
@@ -24,6 +31,9 @@ export default function VersionSelector({ activeVersion, onSwitch }: Props) {
24
  const [open, setOpen] = useState(false);
25
  const [versions, setVersions] = useState<VersionInfo[]>([]);
26
  const [busy, setBusy] = useState<string | null>(null);
 
 
 
27
  const menuRef = useRef<HTMLDivElement>(null);
28
  const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
29
 
@@ -40,18 +50,33 @@ export default function VersionSelector({ activeVersion, onSwitch }: Props) {
40
  // Poll while a download is in progress
41
  useEffect(() => {
42
  const hasDownloading = versions.some((v) => v.status === "downloading");
 
 
43
  if (hasDownloading && !pollRef.current) {
44
  pollRef.current = setInterval(refresh, 2500);
45
  }
46
- if (!hasDownloading && pollRef.current) {
 
 
 
47
  clearInterval(pollRef.current);
48
  pollRef.current = null;
49
  setBusy(null);
 
50
  }
51
  return () => {
52
  if (pollRef.current) clearInterval(pollRef.current);
53
  };
54
- }, [versions, refresh]);
 
 
 
 
 
 
 
 
 
55
 
56
  // Close on outside click
57
  useEffect(() => {
@@ -86,6 +111,33 @@ export default function VersionSelector({ activeVersion, onSwitch }: Props) {
86
  setOpen(false);
87
  };
88
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89
  // Auto-switch when download completes
90
  useEffect(() => {
91
  if (busy) {
@@ -156,98 +208,153 @@ export default function VersionSelector({ activeVersion, onSwitch }: Props) {
156
  const isLoaded = v.loaded && v.model_count > 0;
157
  const isOnDisk = v.status === "on_disk" || (v.on_disk && !isLoaded && !isDownloading && !isError);
158
  const isNotDownloaded = v.status === "not_downloaded" && !v.on_disk;
 
 
159
 
160
  return (
161
- <div
162
- key={v.id}
163
- className="flex items-center justify-between px-3 py-2.5
164
- hover:bg-gray-800/60 transition-colors"
165
- >
166
- <div className="flex-1 min-w-0">
167
- <span className="text-sm font-medium text-gray-200">{v.display}</span>
168
- {v.features && (
169
- <span className="ml-2 text-xs text-gray-500">
170
- {v.features}f
171
- </span>
172
- )}
173
- {isLoaded && (
174
- <span className="ml-2 text-xs text-gray-500">
175
- {v.model_count} models
176
- </span>
177
- )}
178
- {isOnDisk && (
179
- <span className="ml-2 text-xs text-yellow-400">on disk</span>
180
- )}
181
- {isError && (
182
- <span className="ml-2 text-xs text-red-400">error</span>
183
- )}
184
- {isDownloading && (
185
- <span className="ml-2 text-xs text-yellow-400 animate-pulse">
186
- downloading...
187
- </span>
188
- )}
189
- {isNotDownloaded && (
190
- <span className="ml-2 text-xs text-gray-600">not downloaded</span>
191
- )}
192
- </div>
193
 
194
- <div className="flex items-center gap-1 shrink-0 ml-2">
195
- {/* Switch button β€” loaded and ready */}
196
- {isLoaded && (
197
- <button
198
- onClick={() => handleSwitch(v.id)}
199
- className="px-2.5 py-1 rounded text-xs font-medium
200
- bg-green-700 hover:bg-green-600 text-white transition-colors"
201
- title={`Switch to ${v.display}`}
202
- >
203
- Switch
204
- </button>
205
- )}
206
 
207
- {/* Load button β€” on disk but not loaded in memory */}
208
- {isOnDisk && !isDownloading && (
209
- <button
210
- onClick={() => handleDownloadOrLoad(v.id)}
211
- className="flex items-center gap-1 px-2.5 py-1 rounded text-xs font-medium
212
- bg-blue-700 hover:bg-blue-600 text-white transition-colors"
213
- title={`Load ${v.display} into memory`}
214
- >
215
- <HardDrive className="w-3 h-3" />
216
- Load
217
- </button>
218
- )}
219
 
220
- {/* Download button β€” not on disk */}
221
- {isNotDownloaded && !isDownloading && !isError && (
222
- <button
223
- onClick={() => handleDownloadOrLoad(v.id)}
224
- className="flex items-center gap-1 px-2.5 py-1 rounded text-xs font-medium
225
- bg-gray-700 hover:bg-gray-600 text-gray-300 hover:text-white transition-colors"
226
- title={`Download ${v.display} from HF Hub`}
227
- >
228
- <Download className="w-3 h-3" />
229
- Download
230
- </button>
231
- )}
 
 
 
232
 
233
- {/* Spinner while downloading */}
234
- {isDownloading && (
235
- <RefreshCw className="w-3.5 h-3.5 text-yellow-400 animate-spin" />
236
- )}
 
 
 
 
 
 
 
237
 
238
- {/* Error retry */}
239
- {isError && !isDownloading && (
240
  <button
241
- onClick={() => handleDownloadOrLoad(v.id)}
242
- className="flex items-center gap-1 px-2.5 py-1 rounded text-xs font-medium
243
- bg-gray-700 hover:bg-red-700 text-red-400 hover:text-white transition-colors"
244
- title="Retry download"
245
  >
246
- <AlertCircle className="w-3 h-3" />
247
- Retry
248
  </button>
249
- )}
250
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
251
  </div>
252
  );
253
  })}
 
13
  import {
14
  ChevronRight, Download, Check, RefreshCw, AlertCircle, Layers, HardDrive,
15
  } from "lucide-react";
16
+ import {
17
+ fetchVersions,
18
+ loadVersion,
19
+ VersionInfo,
20
+ fetchVersionModels,
21
+ loadVersionModel,
22
+ VersionModelInfo,
23
+ } from "../api";
24
 
25
  interface Props {
26
  activeVersion: "v1" | "v2" | "v3";
 
31
  const [open, setOpen] = useState(false);
32
  const [versions, setVersions] = useState<VersionInfo[]>([]);
33
  const [busy, setBusy] = useState<string | null>(null);
34
+ const [modelBusy, setModelBusy] = useState<string | null>(null);
35
+ const [expandedVersion, setExpandedVersion] = useState<string | null>(null);
36
+ const [versionModels, setVersionModels] = useState<Record<string, VersionModelInfo[]>>({});
37
  const menuRef = useRef<HTMLDivElement>(null);
38
  const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
39
 
 
50
  // Poll while a download is in progress
51
  useEffect(() => {
52
  const hasDownloading = versions.some((v) => v.status === "downloading");
53
+ const hasModelDownloading = Object.values(versionModels)
54
+ .some((rows) => rows.some((m) => m.status === "downloading"));
55
  if (hasDownloading && !pollRef.current) {
56
  pollRef.current = setInterval(refresh, 2500);
57
  }
58
+ if (hasModelDownloading && !pollRef.current) {
59
+ pollRef.current = setInterval(refresh, 2500);
60
+ }
61
+ if (!hasDownloading && !hasModelDownloading && pollRef.current) {
62
  clearInterval(pollRef.current);
63
  pollRef.current = null;
64
  setBusy(null);
65
+ setModelBusy(null);
66
  }
67
  return () => {
68
  if (pollRef.current) clearInterval(pollRef.current);
69
  };
70
+ }, [versions, versionModels, refresh]);
71
+
72
+ useEffect(() => {
73
+ if (!expandedVersion) return;
74
+ fetchVersionModels(expandedVersion)
75
+ .then((rows) => {
76
+ setVersionModels((prev) => ({ ...prev, [expandedVersion]: rows }));
77
+ })
78
+ .catch(() => {});
79
+ }, [expandedVersion, versions]);
80
 
81
  // Close on outside click
82
  useEffect(() => {
 
111
  setOpen(false);
112
  };
113
 
114
+ const handleExpandVersion = async (version: string) => {
115
+ if (expandedVersion === version) {
116
+ setExpandedVersion(null);
117
+ return;
118
+ }
119
+ setExpandedVersion(version);
120
+ try {
121
+ const rows = await fetchVersionModels(version);
122
+ setVersionModels((prev) => ({ ...prev, [version]: rows }));
123
+ } catch {
124
+ // ignore
125
+ }
126
+ };
127
+
128
+ const handleLoadModel = async (version: string, model: string) => {
129
+ const key = `${version}:${model}`;
130
+ setModelBusy(key);
131
+ try {
132
+ await loadVersionModel(version, model);
133
+ const rows = await fetchVersionModels(version);
134
+ setVersionModels((prev) => ({ ...prev, [version]: rows }));
135
+ refresh();
136
+ } catch {
137
+ // ignore
138
+ }
139
+ };
140
+
141
  // Auto-switch when download completes
142
  useEffect(() => {
143
  if (busy) {
 
208
  const isLoaded = v.loaded && v.model_count > 0;
209
  const isOnDisk = v.status === "on_disk" || (v.on_disk && !isLoaded && !isDownloading && !isError);
210
  const isNotDownloaded = v.status === "not_downloaded" && !v.on_disk;
211
+ const isExpanded = expandedVersion === v.id;
212
+ const models = versionModels[v.id] ?? [];
213
 
214
  return (
215
+ <div key={v.id} className="border-t border-gray-800/80 first:border-t-0">
216
+ <div
217
+ className="flex items-center justify-between px-3 py-2.5
218
+ hover:bg-gray-800/60 transition-colors"
219
+ >
220
+ <div className="flex-1 min-w-0">
221
+ <span className="text-sm font-medium text-gray-200">{v.display}</span>
222
+ {v.features && (
223
+ <span className="ml-2 text-xs text-gray-500">
224
+ {v.features}f
225
+ </span>
226
+ )}
227
+ {isLoaded && (
228
+ <span className="ml-2 text-xs text-gray-500">
229
+ {v.model_count} models
230
+ </span>
231
+ )}
232
+ {isOnDisk && (
233
+ <span className="ml-2 text-xs text-yellow-400">on disk</span>
234
+ )}
235
+ {isError && (
236
+ <span className="ml-2 text-xs text-red-400">error</span>
237
+ )}
238
+ {isDownloading && (
239
+ <span className="ml-2 text-xs text-yellow-400 animate-pulse">
240
+ downloading...
241
+ </span>
242
+ )}
243
+ {isNotDownloaded && (
244
+ <span className="ml-2 text-xs text-gray-600">not downloaded</span>
245
+ )}
246
+ </div>
247
 
248
+ <div className="flex items-center gap-1 shrink-0 ml-2">
249
+ {isLoaded && (
250
+ <button
251
+ onClick={() => handleSwitch(v.id)}
252
+ className="px-2.5 py-1 rounded text-xs font-medium
253
+ bg-green-700 hover:bg-green-600 text-white transition-colors"
254
+ title={`Switch to ${v.display}`}
255
+ >
256
+ Switch
257
+ </button>
258
+ )}
 
259
 
260
+ {isOnDisk && !isDownloading && (
261
+ <button
262
+ onClick={() => handleDownloadOrLoad(v.id)}
263
+ className="flex items-center gap-1 px-2.5 py-1 rounded text-xs font-medium
264
+ bg-blue-700 hover:bg-blue-600 text-white transition-colors"
265
+ title={`Load ${v.display} into memory`}
266
+ >
267
+ <HardDrive className="w-3 h-3" />
268
+ Load
269
+ </button>
270
+ )}
 
271
 
272
+ {isNotDownloaded && !isDownloading && !isError && (
273
+ <button
274
+ onClick={() => handleDownloadOrLoad(v.id)}
275
+ className="flex items-center gap-1 px-2.5 py-1 rounded text-xs font-medium
276
+ bg-gray-700 hover:bg-gray-600 text-gray-300 hover:text-white transition-colors"
277
+ title={`Download ${v.display} from HF Hub`}
278
+ >
279
+ <Download className="w-3 h-3" />
280
+ Download
281
+ </button>
282
+ )}
283
+
284
+ {isDownloading && (
285
+ <RefreshCw className="w-3.5 h-3.5 text-yellow-400 animate-spin" />
286
+ )}
287
 
288
+ {isError && !isDownloading && (
289
+ <button
290
+ onClick={() => handleDownloadOrLoad(v.id)}
291
+ className="flex items-center gap-1 px-2.5 py-1 rounded text-xs font-medium
292
+ bg-gray-700 hover:bg-red-700 text-red-400 hover:text-white transition-colors"
293
+ title="Retry download"
294
+ >
295
+ <AlertCircle className="w-3 h-3" />
296
+ Retry
297
+ </button>
298
+ )}
299
 
 
 
300
  <button
301
+ onClick={() => handleExpandVersion(v.id)}
302
+ className="px-2 py-1 rounded text-xs font-medium bg-gray-800 hover:bg-gray-700 text-gray-300 transition-colors"
303
+ title="Show model-level controls"
 
304
  >
305
+ {isExpanded ? "Hide Models" : "Models"}
 
306
  </button>
307
+ </div>
308
  </div>
309
+
310
+ {isExpanded && (
311
+ <div className="px-3 pb-3">
312
+ <div className="rounded-lg border border-gray-800 bg-gray-950/50 overflow-hidden">
313
+ <div className="px-2 py-1.5 text-[11px] text-gray-500 border-b border-gray-800">
314
+ Download / load models individually
315
+ </div>
316
+ <div className="max-h-44 overflow-auto">
317
+ {models.length === 0 && (
318
+ <div className="px-2 py-2 text-xs text-gray-600">No model metadata available</div>
319
+ )}
320
+ {[...models]
321
+ .sort((a, b) => (b.r2 ?? -1) - (a.r2 ?? -1))
322
+ .map((m) => {
323
+ const key = `${v.id}:${m.name}`;
324
+ const downloading = m.status === "downloading" || modelBusy === key;
325
+ const loaded = m.loaded;
326
+ const onDisk = m.on_disk;
327
+ const canDownload = m.has_file;
328
+ return (
329
+ <div key={m.name} className="flex items-center justify-between px-2 py-1.5 border-b border-gray-900 last:border-b-0">
330
+ <div className="min-w-0 pr-2">
331
+ <div className="text-xs text-gray-200 truncate">{m.display_name}</div>
332
+ <div className="text-[11px] text-gray-500 truncate">
333
+ {m.family}
334
+ {m.r2 != null ? ` β€’ R2 ${m.r2.toFixed(3)}` : ""}
335
+ {loaded ? " β€’ loaded" : onDisk ? " β€’ on disk" : ""}
336
+ </div>
337
+ </div>
338
+ <div className="flex items-center gap-1">
339
+ {loaded && <span className="text-[11px] text-green-400">ready</span>}
340
+ {!loaded && canDownload && !downloading && (
341
+ <button
342
+ onClick={() => handleLoadModel(v.id, m.name)}
343
+ className="px-2 py-0.5 rounded text-[11px] font-medium bg-blue-700 hover:bg-blue-600 text-white"
344
+ >
345
+ {onDisk ? "Load" : "Download"}
346
+ </button>
347
+ )}
348
+ {downloading && <RefreshCw className="w-3 h-3 text-yellow-400 animate-spin" />}
349
+ {!canDownload && <span className="text-[11px] text-gray-500">virtual</span>}
350
+ </div>
351
+ </div>
352
+ );
353
+ })}
354
+ </div>
355
+ </div>
356
+ </div>
357
+ )}
358
  </div>
359
  );
360
  })}
scripts/download_models.py CHANGED
@@ -20,6 +20,7 @@ Local layout after download (local_dir = ARTIFACTS_DIR):
20
 
21
  import os
22
  import sys
 
23
  from pathlib import Path
24
 
25
  # ──────────────────────────────────────────────────────────────────────────────
@@ -34,6 +35,7 @@ HF_TOKEN = os.getenv("HF_TOKEN", "")
34
  # HF repo stores v1/ and v2/ at root β†’ local_dir=ARTIFACTS_DIR maps them to
35
  # artifacts/v1/... and artifacts/v2/...
36
  ARTIFACTS_DIR = Path(__file__).resolve().parent.parent / "artifacts"
 
37
 
38
  # Sentinel file β€” written after a successful full download
39
  SENTINEL = ARTIFACTS_DIR / ".hf_downloaded"
@@ -101,6 +103,110 @@ def download_version(version: str) -> None:
101
  print(f"[download_models] {version}/ ready")
102
 
103
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
104
  def download_models_meta_only(versions: list[str]) -> None:
105
  """Download only models.json for listed versions."""
106
  _ensure_hub()
@@ -131,8 +237,15 @@ def main() -> None:
131
  parser = argparse.ArgumentParser()
132
  parser.add_argument("--version", default=None,
133
  help="Download only this version, e.g. v1 or v2")
 
 
134
  args = parser.parse_args()
135
 
 
 
 
 
 
136
  if args.version:
137
  if version_loaded(args.version):
138
  print(f"[download_models] {args.version} already present β€” skipping")
@@ -144,12 +257,18 @@ def main() -> None:
144
  # latest (v3) heavy model artifacts at startup.
145
  download_models_meta_only(["v1", "v2", "v3"])
146
 
147
- # Then fetch v3 artifacts if key models are missing.
148
- if already_downloaded("v3"):
149
- print("[download_models] v3 artifacts already present β€” skipping download")
 
 
 
 
 
150
  return
151
 
152
- download_version("v3")
 
153
 
154
 
155
  if __name__ == "__main__":
 
20
 
21
  import os
22
  import sys
23
+ import json
24
  from pathlib import Path
25
 
26
  # ──────────────────────────────────────────────────────────────────────────────
 
35
  # HF repo stores v1/ and v2/ at root β†’ local_dir=ARTIFACTS_DIR maps them to
36
  # artifacts/v1/... and artifacts/v2/...
37
  ARTIFACTS_DIR = Path(__file__).resolve().parent.parent / "artifacts"
38
+ DEFAULT_STARTUP_TOP_MODELS = 3
39
 
40
  # Sentinel file β€” written after a successful full download
41
  SENTINEL = ARTIFACTS_DIR / ".hf_downloaded"
 
103
  print(f"[download_models] {version}/ ready")
104
 
105
 
106
+ def _read_models_meta(version: str) -> dict:
107
+ meta_path = ARTIFACTS_DIR / version / "models.json"
108
+ if not meta_path.exists():
109
+ return {}
110
+ try:
111
+ return json.loads(meta_path.read_text(encoding="utf-8"))
112
+ except Exception:
113
+ return {}
114
+
115
+
116
+ def _model_score(info: dict) -> float:
117
+ """Return a comparable score for a model metadata record.
118
+
119
+ Priority:
120
+ 1) R2 (higher is better)
121
+ 2) f1_weighted / f1_macro (higher is better)
122
+ 3) within_5pct (higher is better)
123
+ 4) mae (lower is better; converted to negative contribution)
124
+ """
125
+ if not isinstance(info, dict):
126
+ return float("-inf")
127
+
128
+ r2 = info.get("r2")
129
+ if isinstance(r2, (int, float)):
130
+ return float(r2)
131
+
132
+ # Fallback when r2 is unavailable.
133
+ f1w = info.get("f1_weighted")
134
+ if isinstance(f1w, (int, float)):
135
+ return float(f1w)
136
+
137
+ f1m = info.get("f1_macro")
138
+ if isinstance(f1m, (int, float)):
139
+ return float(f1m)
140
+
141
+ w5 = info.get("within_5pct")
142
+ if isinstance(w5, (int, float)):
143
+ return float(w5) / 100.0
144
+
145
+ mae = info.get("mae")
146
+ if isinstance(mae, (int, float)):
147
+ return -float(mae)
148
+
149
+ return float("-inf")
150
+
151
+
152
+ def select_top_models(version: str, top_k: int = DEFAULT_STARTUP_TOP_MODELS) -> list[str]:
153
+ """Pick top-k concrete models from models.json by best available metric score.
154
+
155
+ Excludes virtual entries without a model file.
156
+ """
157
+ meta = _read_models_meta(version)
158
+ models = meta.get("models", {}) if isinstance(meta, dict) else {}
159
+ scored: list[tuple[float, str]] = []
160
+ for name, info in models.items():
161
+ if not isinstance(info, dict):
162
+ continue
163
+ if not info.get("file"):
164
+ continue
165
+ score = _model_score(info)
166
+ if score != float("-inf"):
167
+ scored.append((score, name))
168
+ scored.sort(key=lambda x: x[0], reverse=True)
169
+ return [name for _, name in scored[:top_k]]
170
+
171
+
172
+ def _collect_model_patterns(version: str, model_names: list[str]) -> list[str]:
173
+ """Build allow_patterns for model files + required shared artifacts."""
174
+ meta = _read_models_meta(version)
175
+ models = meta.get("models", {}) if isinstance(meta, dict) else {}
176
+ patterns = {
177
+ f"{version}/models.json",
178
+ f"{version}/features/train_split.csv",
179
+ f"{version}/scalers/features_standard.joblib",
180
+ f"{version}/scalers/sequence_scaler.joblib",
181
+ f"{version}/scalers/features_minmax.joblib",
182
+ }
183
+ for model_name in model_names:
184
+ info = models.get(model_name, {})
185
+ rel_file = info.get("file") if isinstance(info, dict) else None
186
+ if rel_file:
187
+ patterns.add(f"{version}/{rel_file}")
188
+ return sorted(patterns)
189
+
190
+
191
+ def download_models(version: str, model_names: list[str]) -> None:
192
+ """Download selected model artifacts for one version."""
193
+ _ensure_hub()
194
+ from huggingface_hub import snapshot_download
195
+ ARTIFACTS_DIR.mkdir(parents=True, exist_ok=True)
196
+ allow = _collect_model_patterns(version, model_names)
197
+ print(f"[download_models] Downloading selected models for {version}: {model_names}")
198
+ snapshot_download(**_hf_kwargs(
199
+ allow_patterns=allow,
200
+ ignore_patterns=["*.log"],
201
+ ))
202
+ print(f"[download_models] Selected model artifacts ready for {version}")
203
+
204
+
205
+ def download_model(version: str, model_name: str) -> None:
206
+ """Download one model artifact (plus required metadata/scalers)."""
207
+ download_models(version, [model_name])
208
+
209
+
210
  def download_models_meta_only(versions: list[str]) -> None:
211
  """Download only models.json for listed versions."""
212
  _ensure_hub()
 
237
  parser = argparse.ArgumentParser()
238
  parser.add_argument("--version", default=None,
239
  help="Download only this version, e.g. v1 or v2")
240
+ parser.add_argument("--model", default=None,
241
+ help="Download only this model key inside --version")
242
  args = parser.parse_args()
243
 
244
+ if args.version and args.model:
245
+ download_models_meta_only([args.version])
246
+ download_model(args.version, args.model)
247
+ return
248
+
249
  if args.version:
250
  if version_loaded(args.version):
251
  print(f"[download_models] {args.version} already present β€” skipping")
 
257
  # latest (v3) heavy model artifacts at startup.
258
  download_models_meta_only(["v1", "v2", "v3"])
259
 
260
+ # Then fetch only top-N (default=3) v3 model artifacts by best score.
261
+ top_models = select_top_models("v3")
262
+ if not top_models:
263
+ print("[download_models] Could not resolve top models from models.json, falling back to full v3 download")
264
+ if already_downloaded("v3"):
265
+ print("[download_models] v3 artifacts already present β€” skipping download")
266
+ return
267
+ download_version("v3")
268
  return
269
 
270
+ print(f"[download_models] Default startup model set (v3 top-{DEFAULT_STARTUP_TOP_MODELS}): {top_models}")
271
+ download_models("v3", top_models)
272
 
273
 
274
  if __name__ == "__main__":