Source code for swcstudio.core.model_paths

"""Resolve where the auto-typing models live on disk.

The auto-typing engine (:mod:`swcstudio.core.auto_typing`) is the v12
QC-label-flag path. Its required core model files are:

* ``cell_type_classifier.pkl`` — Stage 1 (whole-cell pyramidal vs interneuron)
* ``branch_classifier.pkl`` — Stage 2 (per-branch / per-subtree multiclass)
* ``gnn_apical_basal.pt`` — Stage 2b GraphSAGE GNN
* ``gnn_branch3_rescue.pt`` — conservative Branch3 rescue head
* ``qc_gate.pkl`` — runtime QC gate

Optional learned flag models add per-cell bad-label flag scoring:

* ``flag_model_pyramidal.joblib``
* ``flag_model_interneuron.joblib``
* ``flag_model_all.joblib``

Search order (first existing wins):

1. The directory passed in via the function argument or via the
   ``SWCSTUDIO_MODEL_DIR`` environment variable (highest precedence,
   used for "I trained my own models" workflows).
2. The user data directory:

   * Windows: ``%APPDATA%\\swcstudio\\models``
   * macOS: ``~/Library/Application Support/swcstudio/models``
   * Linux: ``~/.local/share/swcstudio/models``

3. The modular desktop runtime model directory supplied by the
   bootstrap.
4. The bundled directory inside the installed package
   (``swcstudio/data/models``). Source installs keep the current model
   files here; published wheels include the same production models.

Public API:

* ``user_model_dir()`` — user data dir (creates if missing)
* ``bundled_model_dir()`` — dir inside the installed package
* ``resolve_model_path(name, override=None)`` — full path or ``None``
* ``available_models(override=None)`` — ``dict[name -> Path | None]``

Calling code never hard-codes paths. It calls
``resolve_model_path("gnn_apical_basal.pt")`` and gets either a real
path or ``None``.
"""
from __future__ import annotations

import os
import sys
from pathlib import Path
from typing import Iterable

# Standard model file names. Custom-trained models keep these names so
# they are picked up automatically.
MODEL_FILES = {
    "stage1": "cell_type_classifier.pkl",
    "stage2": "branch_classifier.pkl",
    "gnn":    "gnn_apical_basal.pt",
    "branch3": "gnn_branch3_rescue.pt",
    "qc_gate": "qc_gate.pkl",
    "flag_pyramidal": "flag_model_pyramidal.joblib",
    "flag_interneuron": "flag_model_interneuron.joblib",
    "flag_all": "flag_model_all.joblib",
}

ENV_VAR = "SWCSTUDIO_MODEL_DIR"
BUNDLED_ENV_VAR = "SWCSTUDIO_BUNDLED_MODEL_DIR"


def _user_data_root() -> Path:
    """Per-platform user data directory root."""
    if sys.platform == "win32":
        base = os.environ.get("APPDATA")
        if base:
            return Path(base)
        return Path.home() / "AppData" / "Roaming"
    if sys.platform == "darwin":
        return Path.home() / "Library" / "Application Support"
    # Linux / other unix
    base = os.environ.get("XDG_DATA_HOME")
    if base:
        return Path(base)
    return Path.home() / ".local" / "share"


[docs] def user_model_dir() -> Path: """User data directory for swcstudio models. Created on first use.""" out = _user_data_root() / "swcstudio" / "models" out.mkdir(parents=True, exist_ok=True) return out
[docs] def bundled_model_dir() -> Path: """Directory shipped inside the installed swcstudio package.""" return Path(__file__).resolve().parent.parent / "data" / "models"
[docs] def search_dirs(override: str | os.PathLike | None = None) -> list[Path]: """Ordered list of directories that may hold model files.""" out: list[Path] = [] if override: out.append(Path(override)) env_override = os.environ.get(ENV_VAR) if env_override: out.append(Path(env_override)) out.append(user_model_dir()) bundled_override = os.environ.get(BUNDLED_ENV_VAR) if bundled_override: out.append(Path(bundled_override)) out.append(bundled_model_dir()) # De-duplicate while preserving order. seen: set[str] = set() deduped: list[Path] = [] for d in out: key = str(d.resolve()) if key in seen: continue seen.add(key) deduped.append(d) return deduped
[docs] def resolve_model_path( name_or_filename: str, *, override: str | os.PathLike | None = None, auto_download: bool = True, ) -> Path | None: """Locate one model file by short name or by literal filename. ``name_or_filename`` accepts both ``"stage1"`` and ``"cell_type_classifier.pkl"``. Returns the first existing path found in :func:`search_dirs`, or ``None``. If no local copy is found and ``auto_download=True`` (default), falls back to fetching the models package from GitHub Releases via :mod:`swcstudio.core.updater`. Published wheels bundle the production models, so this network path is a repair/update fallback rather than a normal first-run requirement. Set ``auto_download=False`` to skip the network fallback (used by diagnostic / testing paths that want to know "is the model physically here right now"). """ fname = MODEL_FILES.get(name_or_filename, name_or_filename) for d in search_dirs(override): candidate = d / fname if candidate.is_file(): return candidate if not auto_download: return None # Lazy import — keeps `model_paths` itself stdlib-only and avoids a # circular import at module load time (`updater` doesn't import # `model_paths`, but importing it eagerly here would still bind a # network-capable module into a path-resolution helper). try: from swcstudio.core import updater # noqa: WPS433 except ImportError: return None manifest = updater.fetch_manifest() if manifest is None or manifest.models is None: return None try: updater.apply_update("models", manifest.models) except Exception: # Network failure / disk failure / integrity mismatch — give up # silently and let the caller report "model not found". return None # Re-scan after the download. The user model directory is part of # search_dirs(), so the fresh files will be found. for d in search_dirs(override): candidate = d / fname if candidate.is_file(): return candidate return None
[docs] def available_models( override: str | os.PathLike | None = None, ) -> dict[str, Path | None]: """Return ``{short_name: Path | None}`` for every standard model.""" return {key: resolve_model_path(key, override=override) for key in MODEL_FILES}
[docs] def diagnostic_search_report( override: str | os.PathLike | None = None, ) -> str: """Human-readable description of where the resolver looked. Useful in error messages when a model cannot be found.""" lines: list[str] = ["Search order:"] for d in search_dirs(override): marker = "[EXISTS]" if d.is_dir() else "[MISSING]" lines.append(f" {marker} {d}") lines.append("Required files:") for short, fname in MODEL_FILES.items(): found = resolve_model_path(short, override=override) marker = f"[FOUND] {found}" if found else "[NOT FOUND]" lines.append(f" {short:<8s} ({fname}) {marker}") lines.append( f"Override via env var {ENV_VAR}=/path/to/dir or via the " f"`--model-dir` CLI flag / GUI selector." ) return "\n".join(lines)