corems.molecular_networking

corems.molecular_networking

Molecular networking for comparing experimental and library MS2 spectra using FlashEntropy similarity (and optional cosine) in open, identity, or neutral_loss modes.

Quick start
from corems.molecular_networking import MolecularNetwork

# Optional: build FE library via MSPInterface / other CoreMS DB helpers
mn = MolecularNetwork(
    fe_lib=fe_lib,           # or None for query-only
    search_type="open",      # default; try "identity" / "neutral_loss"
    similarity_thresholds={"entropy_similarity": 0.3, "cosine": 0.3},
)

# From an LCMSBase object (mass features with best_ms2):
spectra, ids, pmzs = MolecularNetwork.prepare_query_spectra_from_lcms_object(lcms)

# All-in-one (stages 1–2; optional stage 3 with hydrate_library_similarities=True)
mn.query_vs_library(spectra, ids, query_precursor_mzs=pmzs)

edges = mn.get_network_edges(metric="entropy_similarity")
mn.save_edge_list("edges.csv")
# Plotting/clustering require: pip install "corems[networking]"  (networkx + ipysigma)
mn.plot_network(path="network.png", return_fig=True)  # static
# mn.plot_interactive_network(out_path="network.html")  # interactive HTML
Stages
  1. Query–query — temporary FE index over experimental spectra.
  2. Query–library — search against a reference FlashEntropy library.
  3. Library–library (optional) — all-vs-all among library hits above a threshold.

Internal library node IDs use the lib:<index> form so they never collide with numeric mass-feature IDs. CSV export maps those nodes to FlashEntropy spectra_id (or id) when available.

Spectrum protocol

Query spectra are duck-typed: objects must expose .mz_exp and .abundance array-like attributes (e.g. CoreMS MS2 spectra).

Classes

MolecularNetwork Main interface for building and querying molecular networks. SimilarityMatrix Sparse symmetric matrix for one similarity metric. SimilarityEngine Computes pairwise similarities (FlashEntropy + optional cosine). NetworkVisualizeMixin Static and interactive plotting (mixed into MolecularNetwork).

See also

examples/notebooks/LCMS_Tutorial.ipynb End-to-end single-sample networking section. examples/notebooks/LCMS_Collection_Tutorial.ipynb Multi-sample / consensus representatives. tests/test_molecular_networking.py Unit tests and usage fixtures.

 1"""
 2corems.molecular_networking
 3===========================
 4
 5Molecular networking for comparing experimental and library MS2 spectra
 6using FlashEntropy similarity (and optional cosine) in ``open``,
 7``identity``, or ``neutral_loss`` modes.
 8
 9Quick start
10-----------
11
12.. code-block:: python
13
14    from corems.molecular_networking import MolecularNetwork
15
16    # Optional: build FE library via MSPInterface / other CoreMS DB helpers
17    mn = MolecularNetwork(
18        fe_lib=fe_lib,           # or None for query-only
19        search_type="open",      # default; try "identity" / "neutral_loss"
20        similarity_thresholds={"entropy_similarity": 0.3, "cosine": 0.3},
21    )
22
23    # From an LCMSBase object (mass features with best_ms2):
24    spectra, ids, pmzs = MolecularNetwork.prepare_query_spectra_from_lcms_object(lcms)
25
26    # All-in-one (stages 1–2; optional stage 3 with hydrate_library_similarities=True)
27    mn.query_vs_library(spectra, ids, query_precursor_mzs=pmzs)
28
29    edges = mn.get_network_edges(metric="entropy_similarity")
30    mn.save_edge_list("edges.csv")
31    # Plotting/clustering require: pip install "corems[networking]"  (networkx + ipysigma)
32    mn.plot_network(path="network.png", return_fig=True)  # static
33    # mn.plot_interactive_network(out_path="network.html")  # interactive HTML
34
35Stages
36------
371. **Query–query** — temporary FE index over experimental spectra.
382. **Query–library** — search against a reference FlashEntropy library.
393. **Library–library** (optional) — all-vs-all among library hits above a threshold.
40
41Internal library node IDs use the ``lib:<index>`` form so they never collide with
42numeric mass-feature IDs.  CSV export maps those nodes to FlashEntropy
43``spectra_id`` (or ``id``) when available.
44
45Spectrum protocol
46-----------------
47Query spectra are duck-typed: objects must expose ``.mz_exp`` and ``.abundance``
48array-like attributes (e.g. CoreMS MS2 spectra).
49
50Classes
51-------
52MolecularNetwork
53    Main interface for building and querying molecular networks.
54SimilarityMatrix
55    Sparse symmetric matrix for one similarity metric.
56SimilarityEngine
57    Computes pairwise similarities (FlashEntropy + optional cosine).
58NetworkVisualizeMixin
59    Static and interactive plotting (mixed into MolecularNetwork).
60
61See also
62--------
63examples/notebooks/LCMS_Tutorial.ipynb
64    End-to-end single-sample networking section.
65examples/notebooks/LCMS_Collection_Tutorial.ipynb
66    Multi-sample / consensus representatives.
67tests/test_molecular_networking.py
68    Unit tests and usage fixtures.
69"""
70
71from corems.molecular_networking.similarity_matrix import SimilarityMatrix
72from corems.molecular_networking.similarity_engine import SimilarityEngine
73from corems.molecular_networking.network_builder import MolecularNetwork
74from corems.molecular_networking.network_visualize import NetworkVisualizeMixin
75
76__all__ = [
77    "MolecularNetwork",
78    "SimilarityMatrix",
79    "SimilarityEngine",
80    "NetworkVisualizeMixin",
81]
class MolecularNetwork(corems.molecular_networking.NetworkVisualizeMixin):
  51class MolecularNetwork(NetworkVisualizeMixin):
  52    """Build a molecular network around a reference library.
  53
  54    Initialization is **lazy** — no similarity computation happens until you
  55    call one of the computation methods.
  56
  57    Parameters
  58    ----------
  59    fe_lib : ms_entropy.FlashEntropySearch or None, optional
  60        Pre-built FlashEntropy search instance (built from the reference
  61        library).  Required for query-vs-library and library-vs-library
  62        stages.  Pass ``None`` (default) to build a query-only network
  63        (stage 1 only).  When provided, fragment-tolerance parameters are
  64        automatically extracted from this library unless overridden by
  65        *ms2_tolerance_da*.
  66    search_type : str
  67        FlashEntropy search mode: ``"identity"``, ``"open"``, or
  68        ``"neutral_loss"``.  Default ``"open"`` (typical DDA networking;
  69        use ``"identity"`` when precursor filtering is required).
  70    additional_similarities : list of str, optional
  71        Extra similarity metrics to compute alongside entropy similarity.
  72        Currently supported: ``["cosine"]``.  Default ``["cosine"]``.
  73        Cosine is gated by *entropy_threshold_low* (best-effort secondary score).
  74    similarity_thresholds : dict, optional
  75        Mapping of metric name → edge threshold.  Edges are created in each
  76        metric's network when score >= threshold.  Metrics not listed use
  77        a default of 0.5.
  78        Example: ``{"entropy_similarity": 0.5, "cosine": 0.6}``
  79    ms1_tolerance_da : float, optional
  80        Precursor m/z tolerance (Da) used for ``"identity"`` search to filter
  81        library candidates by precursor m/z.  Ignored for ``"open"`` and
  82        ``"neutral_loss"`` (precursor filter is disabled for those modes).
  83        If None (default), uses 0.01 Da.
  84    ms2_tolerance_da : float, optional
  85        Fragment m/z tolerance (Da) used for spectrum cleaning and similarity
  86        scoring.  Priority: explicit kwarg > extracted from *fe_lib* >
  87        0.01 Da fallback.  Override this when *fe_lib* is ``None`` or when
  88        you need a tolerance different from the library's built-in value.
  89    entropy_threshold_low : float or None, optional
  90        Minimum entropy similarity score required to trigger additional metric
  91        computation (e.g. cosine).  If None (default), set to half the lowest
  92        non-entropy similarity threshold, or 0.25 when no thresholds are given.
  93    settings : SpectralSimilaritySearchSettings, optional
  94        Encapsulated spectral-similarity / networking defaults (for example
  95        ``lcms.parameters.mass_spectrum["ms2"].spectral_similarity_search`` or
  96        :func:`~corems.encapsulation.factory.parameters.settings_from_lcms`).
  97        When provided, fills any constructor fields left as ``None`` from this
  98        object.  Explicit keyword arguments always override *settings*.  Stage-1
  99        temporary FE index build uses ``settings.as_fe_kwargs()`` when
 100        *fe_kwargs* is not passed to :meth:`query_vs_library` /
 101        :meth:`run_query_vs_query_only`.  Stage-3 defaults
 102        (``hydrate_library_similarities``, ``library_similarity_threshold``)
 103        are also taken from *settings* when those method arguments are omitted.
 104
 105    Attributes
 106    ----------
 107    similarity_matrices : dict of str → SimilarityMatrix
 108        One :class:`~corems.molecular_networking.similarity_matrix.SimilarityMatrix`
 109        per metric (``"entropy_similarity"``, ``"cosine"``, …).
 110    stage_query_query_done : bool
 111        True after query-vs-query similarities have been computed (stage 1).
 112    stage_query_library_done : bool
 113        True after query-vs-library similarities have been computed (stage 2).
 114    stage_library_library_done : bool
 115        True after matched library-vs-library similarities have been computed
 116        (stage 3).
 117
 118    Notes
 119    -----
 120    **Tolerance resolution order** (highest priority first):
 121
 122    1. Explicit *ms2_tolerance_da* kwarg passed to this constructor.
 123    2. *settings.ms2_tolerance_da* when *settings* is provided and the kwarg is
 124       omitted.
 125    3. Value extracted from ``fe_lib.entropy_search.max_ms2_tolerance_in_da``.
 126    4. Hard-coded fallback of 0.01 Da (used when *fe_lib* is ``None`` and no
 127       explicit value is given).
 128
 129    Prefer building *fe_lib* with the same
 130    :class:`~corems.encapsulation.factory.processingSetting.SpectralSimilaritySearchSettings`
 131    instance used here (via library ``settings=`` / ``settings_from_lcms``) so
 132    FlashEntropy index tolerances match networking.
 133
 134    **Precursor filter behaviour by search type:**
 135
 136    - ``"identity"`` : library candidates are filtered by precursor m/z within
 137      *ms1_tolerance_da* before scoring.
 138    - ``"open"`` : precursor filter is disabled; all library entries are scored.
 139    - ``"neutral_loss"`` : precursor filter is disabled; matching occurs in
 140      neutral-loss mass space (``precursor_mz − fragment_mz``), not on the
 141      precursor itself.
 142
 143    Cosine scoring (when requested) is single-threaded: unique spectra are
 144    pre-sorted once, then pairs are scored sequentially.
 145    """
 146
 147    def __init__(
 148        self,
 149        fe_lib=None,
 150        search_type: str | None = None,
 151        additional_similarities: list[str] | None = None,
 152        similarity_thresholds: dict[str, float] | None = None,
 153        ms1_tolerance_da: float | None = None,
 154        ms2_tolerance_da: float | None = None,
 155        entropy_threshold_low: float | None = None,
 156        settings=None,
 157    ):
 158        if settings is not None:
 159            if search_type is None:
 160                search_type = settings.search_type
 161            if additional_similarities is None:
 162                additional_similarities = list(settings.additional_similarities)
 163            if similarity_thresholds is None:
 164                similarity_thresholds = dict(settings.similarity_thresholds)
 165            if ms1_tolerance_da is None:
 166                ms1_tolerance_da = settings.ms1_tolerance_da
 167            if ms2_tolerance_da is None:
 168                ms2_tolerance_da = settings.ms2_tolerance_da
 169            if entropy_threshold_low is None:
 170                entropy_threshold_low = settings.entropy_threshold_low
 171            self._settings = settings
 172        else:
 173            self._settings = None
 174
 175        if search_type is None:
 176            search_type = "open"
 177        if additional_similarities is None:
 178            additional_similarities = ["cosine"]
 179        if similarity_thresholds is None:
 180            similarity_thresholds = {}
 181
 182        if entropy_threshold_low is None:
 183            non_entropy_thresholds = [
 184                v for k, v in similarity_thresholds.items() if k != "entropy_similarity"
 185            ]
 186            if non_entropy_thresholds:
 187                entropy_threshold_low = min(non_entropy_thresholds) / 2
 188            else:
 189                entropy_threshold_low = 0.25  # Placeholder that won't be used if no additional similarities are computed
 190
 191        self.fe_lib = fe_lib
 192        self.search_type = search_type
 193        self.additional_similarities = list(additional_similarities)
 194        self.similarity_thresholds = similarity_thresholds
 195
 196        # Build the engine (wraps fe_lib + search parameters)
 197        # When fe_lib is provided, tolerance parameters are extracted from it.
 198        # When fe_lib is None (queries-only use), SimilarityEngine will fall back
 199        # to its internal defaults and only query-vs-query stage is allowed.
 200        self._engine = SimilarityEngine(
 201            fe_lib=fe_lib,
 202            search_type=search_type,
 203            additional_similarities=additional_similarities,
 204            ms1_tolerance_da=ms1_tolerance_da,
 205            ms2_tolerance_da=ms2_tolerance_da,
 206            entropy_threshold_low=entropy_threshold_low,
 207            settings=settings,
 208        )
 209
 210        # One SimilarityMatrix per metric – empty until query_vs_library() is called
 211        all_metrics = ["entropy_similarity"] + list(additional_similarities)
 212        self.similarity_matrices: dict[str, SimilarityMatrix] = {
 213            m: SimilarityMatrix(metric_name=m) for m in all_metrics
 214        }
 215
 216        # Accumulate query spectra (kept separate from the library FE index)
 217        self._all_query_spectra: list = []
 218        self._all_query_ids: list[str] = []
 219        self._all_query_precursor_mzs: list[float | None] = []
 220
 221        # Stage flags – enforce order:
 222        #   1) query-vs-query
 223        #   2) query-vs-library
 224        #   3) matched library-vs-library
 225        self.stage_query_query_done: bool = False
 226        self.stage_query_library_done: bool = False
 227        self.stage_library_library_done: bool = False
 228
 229        # Whether query_vs_library has been run (disabled repeated runs until dropped)
 230        self._has_queries_run: bool = False
 231
 232        # Cache for stage 2 entropy pairs (used by stage 3)
 233        self._stage2_entropy_pairs: dict[tuple[str, str], float] | None = None
 234
 235        # Cache of computed network clustering artifacts keyed by metric.
 236        self._network_clusters: dict[str, dict] = {}
 237
 238    @staticmethod
 239    def prepare_query_spectra_from_lcms_object(
 240        lcms_obj,
 241        mf_ids: set | None = None,
 242    ) -> tuple[list, list[str], list[float]]:
 243        """Build query lists from an ``LCMSBase`` object's mass features.
 244
 245        Iterates ``lcms_obj.mass_features`` and collects each feature's
 246        ``best_ms2`` spectrum, skipping features with no MS2 or an empty
 247        ``mz_exp``.  If *mf_ids* is provided, only those mass features are
 248        included.
 249
 250        Parameters
 251        ----------
 252        lcms_obj : LCMSBase
 253            LCMS object whose mass features have been populated with MS2
 254            spectra (e.g. via :meth:`add_associated_ms2_dda`).
 255        mf_ids : set, optional
 256            If provided, only mass features whose id is in this set are
 257            included.
 258
 259        Returns
 260        -------
 261        (query_spectra, query_ids, query_precursor_mzs)
 262            Three parallel lists ready to pass to :meth:`query_vs_library`
 263            or :meth:`run_query_vs_query_only`.
 264        """
 265        query_spectra: list = []
 266        query_ids: list[str] = []
 267        query_precursor_mzs: list[float] = []
 268        for mf_id, mf in lcms_obj.mass_features.items():
 269            if mf_ids is not None and mf_id not in mf_ids:
 270                continue
 271            ms2 = mf.best_ms2
 272            if ms2 is None:
 273                continue
 274            if not hasattr(ms2, "mz_exp") or len(ms2.mz_exp) == 0:
 275                continue
 276            query_spectra.append(ms2)
 277            query_ids.append(str(mf_id))
 278            query_precursor_mzs.append(float(mf.mz))
 279        return query_spectra, query_ids, query_precursor_mzs
 280
 281    @staticmethod
 282    def prepare_query_spectra_from_lcms_collection(
 283        lcms_collection,
 284        cluster_ids: set | None = None,
 285        representative_metric: str | None = None,
 286    ) -> tuple[list, list[str], list[float]]:
 287        """Build query lists from representative mass features in an ``LCMSCollection``.
 288
 289        Uses :meth:`~corems.mass_spectra.calc.lc_calc.LCMSCollectionCalculations.get_representative_mass_features_for_all_clusters`
 290        to select one mass feature per consensus cluster, then collects each
 291        feature's ``best_ms2`` from the corresponding sample object.
 292
 293        Representative mass features must already be loaded with MS2 spectra
 294        (e.g. via ``process_consensus_features(load_representatives=True,
 295        add_ms2=True, ...)`` or ``reload_representative_mass_features(add_ms2=True)``).
 296        Clusters whose representative has no usable MS2 are skipped.
 297
 298        Parameters
 299        ----------
 300        lcms_collection : LCMSCollection
 301            Collection with consensus clusters and loaded representative
 302            mass features (with MS2 where available).
 303        cluster_ids : set, optional
 304            If provided, only representatives for these cluster IDs are
 305            included.
 306        representative_metric : str, optional
 307            Metric passed to
 308            :meth:`~corems.mass_spectra.calc.lc_calc.LCMSCollectionCalculations.get_representative_mass_features_for_all_clusters`.
 309            If ``None``, uses the collection parameter
 310            ``consensus_representative_metric``.
 311
 312        Returns
 313        -------
 314        (query_spectra, query_ids, query_precursor_mzs)
 315            Three parallel lists ready to pass to :meth:`query_vs_library`
 316            or :meth:`run_query_vs_query_only`.  Query IDs are collection-level
 317            ``coll_mf_id`` strings (``"{sample_id}_{mf_id}"``).
 318
 319        Raises
 320        ------
 321        AttributeError
 322            If *lcms_collection* does not expose representative lookup or
 323            sample indexing (not an ``LCMSCollection``-like object).
 324        """
 325        if not hasattr(
 326            lcms_collection, "get_representative_mass_features_for_all_clusters"
 327        ):
 328            raise AttributeError(
 329                "lcms_collection must provide get_representative_mass_features_for_all_clusters "
 330                "(expected an LCMSCollection)."
 331            )
 332
 333        representatives = (
 334            lcms_collection.get_representative_mass_features_for_all_clusters(
 335                representative_metric=representative_metric
 336            )
 337        )
 338        if cluster_ids is not None:
 339            representatives = representatives[
 340                representatives["cluster"].isin(cluster_ids)
 341            ]
 342
 343        query_spectra: list = []
 344        query_ids: list[str] = []
 345        query_precursor_mzs: list[float] = []
 346
 347        for _, row in representatives.iterrows():
 348            sample_id = int(row["sample_id"])
 349            mf_id = row["mf_id"]
 350            coll_mf_id = str(row["coll_mf_id"])
 351
 352            try:
 353                sample = lcms_collection[sample_id]
 354            except (IndexError, KeyError, TypeError):
 355                continue
 356
 357            mass_features = getattr(sample, "mass_features", None) or {}
 358            mf = mass_features.get(mf_id)
 359            if mf is None:
 360                # mf_id from dataframes may not match dict key type (int/str/np)
 361                try:
 362                    mf = mass_features.get(int(mf_id))
 363                except (TypeError, ValueError):
 364                    mf = None
 365            if mf is None:
 366                try:
 367                    mf = mass_features.get(str(mf_id))
 368                except (TypeError, ValueError):
 369                    mf = None
 370            if mf is None:
 371                continue
 372
 373            ms2 = getattr(mf, "best_ms2", None)
 374            if ms2 is None:
 375                continue
 376            if not hasattr(ms2, "mz_exp") or len(ms2.mz_exp) == 0:
 377                continue
 378
 379            query_spectra.append(ms2)
 380            query_ids.append(coll_mf_id)
 381            query_precursor_mzs.append(float(mf.mz))
 382
 383        return query_spectra, query_ids, query_precursor_mzs
 384
 385    # ── Internal helpers ──────────────────────────────────────────────────────
 386
 387    def _threshold_for(self, metric: str) -> float:
 388        """Return the edge threshold for *metric*."""
 389        return self.similarity_thresholds.get(metric, 0.5)
 390
 391    @staticmethod
 392    def library_node_id(lib_idx: int) -> str:
 393        """Return the internal network node ID for a library spectrum index."""
 394        return f"{_LIB_NODE_PREFIX}{int(lib_idx)}"
 395
 396    @staticmethod
 397    def library_index_from_node_id(node_id: str) -> int | None:
 398        """Parse a library matrix index from an internal node ID, if any.
 399
 400        Accepts the canonical ``lib:<idx>`` form. Bare integer strings are
 401        also accepted for backward compatibility with older matrices.
 402        """
 403        if node_id is None:
 404            return None
 405        s = str(node_id)
 406        if s.startswith(_LIB_NODE_PREFIX):
 407            try:
 408                return int(s[len(_LIB_NODE_PREFIX) :])
 409            except ValueError:
 410                return None
 411        try:
 412            return int(s)
 413        except (TypeError, ValueError):
 414            return None
 415
 416    def _update_matrices(self, new_scores: dict[str, dict[tuple[str, str], float]]):
 417        """Write new similarity scores into the appropriate SimilarityMatrix."""
 418        for metric, pairs in new_scores.items():
 419            mat = self.similarity_matrices.get(metric)
 420            if mat is None:
 421                continue
 422            for (id1, id2), score in pairs.items():
 423                mat.set_similarity(id1, id2, score)
 424        # Finalise all matrices for efficient querying
 425        for mat in self.similarity_matrices.values():
 426            mat.finalise()
 427
 428    def _prepare_queries(
 429        self,
 430        query_spectra: list,
 431        query_ids: list[str],
 432        query_precursor_mzs: list[float | None] | None,
 433    ) -> None:
 434        """Validate and store query spectra, IDs, and precursor m/z values.
 435
 436        Resets all stage flags and the stage-2 entropy-pair cache so that a
 437        fresh computation can proceed.  Called internally by
 438        :meth:`run_query_vs_query_only` and :meth:`query_vs_library`.
 439
 440        Parameters
 441        ----------
 442        query_spectra : list
 443            Spectrum objects with ``.mz_exp`` and ``.abundance`` attributes.
 444        query_ids : list of str
 445            Unique string ID for each spectrum.  Must be the same length as
 446            *query_spectra*.
 447        query_precursor_mzs : list of float or None, or None
 448            Precursor m/z for each spectrum.  Required (non-None list) for
 449            ``"identity"`` and ``"neutral_loss"`` search types.  Pass ``None``
 450            to auto-fill with ``None`` values for ``"open"`` search.
 451
 452        Raises
 453        ------
 454        ValueError
 455            If *query_spectra* is empty, lengths are mismatched, or precursor
 456            m/z values are missing/None for a precursor-required search type.
 457        """
 458        n_query = len(query_spectra)
 459        if n_query == 0:
 460            raise ValueError("No query spectra provided.")
 461
 462        if len(query_ids) != n_query:
 463            raise ValueError(
 464                f"query_ids length ({len(query_ids)}) must match query_spectra length ({n_query})."
 465            )
 466
 467        # Validate precursor_mzs requirement
 468        if self.search_type in _PRECURSOR_REQUIRED:
 469            if query_precursor_mzs is None:
 470                raise ValueError(
 471                    f"query_precursor_mzs is required for "
 472                    f"search_type='{self.search_type}'. "
 473                    "Provide a list of precursor m/z values, one per query spectrum."
 474                )
 475            if len(query_precursor_mzs) != n_query:
 476                raise ValueError(
 477                    f"query_precursor_mzs length ({len(query_precursor_mzs)}) "
 478                    f"must match query_spectra length ({n_query})."
 479                )
 480            if any(pmz is None for pmz in query_precursor_mzs):
 481                raise ValueError(
 482                    f"query_precursor_mzs contains None values for search_type='{self.search_type}'. "
 483                    "Provide a valid precursor m/z for every query spectrum."
 484                )
 485        else:
 486            if query_precursor_mzs is None:
 487                query_precursor_mzs = [None] * n_query
 488            elif len(query_precursor_mzs) != n_query:
 489                raise ValueError(
 490                    f"query_precursor_mzs length ({len(query_precursor_mzs)}) "
 491                    f"must match query_spectra length ({n_query})."
 492                )
 493
 494        # Store provided queries
 495        self._all_query_spectra = list(query_spectra)
 496        self._all_query_ids = list(query_ids)
 497        self._all_query_precursor_mzs = list(query_precursor_mzs)
 498
 499        # Register query IDs now
 500        for mat in self.similarity_matrices.values():
 501            mat.register_spectra(self._all_query_ids)
 502
 503        # Reset stage flags and caches
 504        self.stage_query_query_done = False
 505        self.stage_query_library_done = False
 506        self.stage_library_library_done = False
 507        self._stage2_entropy_pairs = None
 508
 509    def _run_query_vs_query(
 510        self,
 511        fe_kwargs: dict | None = None,
 512    ) -> None:
 513        """Stage 1 – compute query-vs-query similarities.
 514
 515        Builds a temporary FlashEntropy index from the stored query spectra
 516        and computes all-vs-all pairwise similarities within that set.
 517        Results are written into :attr:`similarity_matrices` and
 518        :attr:`stage_query_query_done` is set to ``True``.
 519
 520        Parameters
 521        ----------
 522        fe_kwargs : dict, optional
 523            Extra keyword arguments forwarded to
 524            :class:`ms_entropy.FlashEntropySearch` when building the
 525            temporary query index.
 526
 527        Raises
 528        ------
 529        RuntimeError
 530            If no queries have been prepared (i.e. :meth:`_prepare_queries`
 531            has not been called).
 532        """
 533        if not self._all_query_spectra:
 534            raise RuntimeError(
 535                "No queries prepared. Call run_query_vs_query_only() or query_vs_library() first."
 536            )
 537
 538        if fe_kwargs is None and self._settings is not None:
 539            fe_kwargs = self._settings.as_fe_kwargs()
 540
 541        query_fe_lib = self._engine.build_fe_index_from_spectra(
 542            spectra=self._all_query_spectra,
 543            precursor_mzs=self._all_query_precursor_mzs,
 544            fe_kwargs=fe_kwargs,
 545        )
 546        qq_scores = self._engine.compute_all_vs_all_with_lib(
 547            spectra=self._all_query_spectra,
 548            spectrum_ids=self._all_query_ids,
 549            precursor_mzs=self._all_query_precursor_mzs,
 550            fe_lib_override=query_fe_lib,
 551        )
 552        self._update_matrices(qq_scores)
 553        self.stage_query_query_done = True
 554
 555    def _run_query_vs_library(
 556        self,
 557    ) -> tuple[dict[tuple[str, str], float], dict[tuple[str, str], float]]:
 558        """Stage 2 – compute query-vs-library similarities.
 559
 560        Searches each stored query spectrum against the reference FE library
 561        (``self.fe_lib``).  Entropy similarity scores are stored for all
 562        library entries with score > 0; cosine scores are computed for pairs
 563        above :attr:`~SimilarityEngine.entropy_threshold_low`.  Results are
 564        written into :attr:`similarity_matrices`,
 565        :attr:`stage_query_library_done` is set to ``True``, and the entropy
 566        pairs are cached in :attr:`_stage2_entropy_pairs` for use by stage 3.
 567
 568        Returns
 569        -------
 570        entropy_pairs : dict mapping (query_id, lib_idx_str) → float
 571            All query-vs-library entropy similarity scores > 0.
 572        cosine_pairs : dict mapping (query_id, lib_idx_str) → float
 573            Cosine scores for pairs above the entropy threshold.
 574
 575        Raises
 576        ------
 577        RuntimeError
 578            If stage 1 has not been completed or if ``self.fe_lib`` is ``None``.
 579        """
 580        if not self.stage_query_query_done:
 581            raise RuntimeError(
 582                "query-vs-query stage must be run before query-vs-library stage."
 583            )
 584        if self.fe_lib is None:
 585            raise RuntimeError(
 586                "query-vs-library stage requires a reference FE library (fe_lib). "
 587                "Construct MolecularNetwork with fe_lib or use run_query_vs_query_only for query-only networks."
 588            )
 589
 590        combined, lib_size = self._engine.search_queries_against_library(
 591            query_spectra=self._all_query_spectra,
 592            query_ids=self._all_query_ids,
 593            query_precursor_mzs=self._all_query_precursor_mzs,
 594            format_library_id=self.library_node_id,
 595        )
 596
 597        lib_ids = [self.library_node_id(i) for i in range(lib_size)]
 598        for mat in self.similarity_matrices.values():
 599            mat.register_spectra(lib_ids)
 600
 601        self._update_matrices(combined)
 602
 603        entropy_pairs = combined.get("entropy_similarity", {})
 604        cosine_pairs = combined.get("cosine", {})
 605
 606        self.stage_query_library_done = True
 607        self._stage2_entropy_pairs = entropy_pairs
 608
 609        return entropy_pairs, cosine_pairs
 610
 611    def _run_library_vs_library(
 612        self,
 613        entropy_pairs: dict[tuple[str, str], float],
 614        library_similarity_threshold: float,
 615    ) -> None:
 616        """Stage 3 – compute library-vs-library similarities for matched entries.
 617
 618        Identifies the subset of library spectra that scored ≥
 619        *library_similarity_threshold* against any query in stage 2, then
 620        computes all-vs-all pairwise similarities within that subset.
 621        Results are written into :attr:`similarity_matrices` and
 622        :attr:`stage_library_library_done` is set to ``True``.
 623
 624        Parameters
 625        ----------
 626        entropy_pairs : dict mapping (query_id, lib_idx_str) → float
 627            The entropy similarity pairs returned by :meth:`_run_query_vs_library`.
 628            Used to identify which library indices exceeded the threshold.
 629        library_similarity_threshold : float
 630            Minimum entropy similarity score a library entry must have against
 631            any query to be included in this stage.
 632
 633        Raises
 634        ------
 635        RuntimeError
 636            If stage 2 has not been completed.
 637        """
 638        if not self.stage_query_library_done:
 639            raise RuntimeError(
 640                "query-vs-library stage must be run before library-vs-library stage."
 641            )
 642
 643        matched_lib_indices: list[int] = sorted(
 644            {
 645                lib_idx
 646                for (q_id, lib_id), score in entropy_pairs.items()
 647                if q_id in set(self._all_query_ids)
 648                and score >= library_similarity_threshold
 649                and (lib_idx := self.library_index_from_node_id(lib_id)) is not None
 650            }
 651        )
 652
 653        n_matched = len(matched_lib_indices)
 654
 655        if n_matched > 1:
 656            matched_lib_ids = [self.library_node_id(i) for i in matched_lib_indices]
 657            ll_scores = self._engine.compute_library_vs_library_filtered(
 658                library_indices=matched_lib_indices,
 659                spectrum_ids=matched_lib_ids,
 660            )
 661            self._update_matrices(ll_scores)
 662
 663        self.stage_library_library_done = True
 664
 665    def _export_node_id(self, node_id: str, query_id_set: set[str]) -> str:
 666        """Return node ID for file export.
 667
 668        Query IDs are preserved as-is. Library nodes stored internally as
 669        ``lib:<idx>`` (or bare index strings) are mapped to FlashEntropy entry
 670        ``spectra_id`` when available (fallback to ``id``, then the internal
 671        node ID).
 672        """
 673        if node_id in query_id_set:
 674            return node_id
 675
 676        lib_idx = self.library_index_from_node_id(node_id)
 677        if lib_idx is None or lib_idx < 0:
 678            return node_id
 679
 680        if self.fe_lib is None:
 681            return node_id
 682
 683        try:
 684            lib_entry = self.fe_lib[lib_idx]
 685        except (IndexError, KeyError, TypeError):
 686            return node_id
 687
 688        if not isinstance(lib_entry, dict):
 689            return node_id
 690
 691        lib_id = lib_entry.get("spectra_id")
 692        if lib_id is None:
 693            lib_id = lib_entry.get("id")
 694        if lib_id is None:
 695            return node_id
 696
 697        lib_id_str = str(lib_id)
 698        return lib_id_str if lib_id_str else node_id
 699
 700    # ── Public API ────────────────────────────────────────────────────────────
 701
 702    def run_query_vs_query_only(
 703        self,
 704        query_spectra: list,
 705        query_ids: list[str],
 706        query_precursor_mzs: list[float | None] | None = None,
 707        fe_kwargs: dict | None = None,
 708    ) -> None:
 709        """Compute query-vs-query similarities (stage 1 only).
 710
 711        Builds a temporary FlashEntropy index from *query_spectra* and
 712        computes all-vs-all pairwise similarities within that set.  Results
 713        are stored in :attr:`similarity_matrices`.
 714
 715        After this call, :attr:`stage_query_query_done` is ``True``.  If a
 716        reference library was provided at construction time you may continue
 717        with :meth:`run_query_vs_library_stage`.
 718
 719        Parameters
 720        ----------
 721        query_spectra : list
 722            Spectrum objects with ``.mz_exp`` and ``.abundance`` attributes.
 723        query_ids : list of str
 724            Unique string ID for each spectrum.
 725        query_precursor_mzs : list of float or None, optional
 726            Precursor m/z for each spectrum.  Required (non-None list) for
 727            ``"identity"`` and ``"neutral_loss"`` search types.
 728        fe_kwargs : dict, optional
 729            Extra keyword arguments forwarded to
 730            :class:`ms_entropy.FlashEntropySearch` when building the
 731            temporary query index.
 732
 733        Raises
 734        ------
 735        RuntimeError
 736            If queries have already been run (call :meth:`drop_queries` first).
 737        ValueError
 738            If *query_spectra* is empty, lengths are mismatched, or precursor
 739            m/z values are missing for a precursor-required search type.
 740        """
 741        if self._has_queries_run:
 742            raise RuntimeError(
 743                "query_vs_library has already been run — call drop_queries() to clear queries and results before running again."
 744            )
 745
 746        self._prepare_queries(
 747            query_spectra=query_spectra,
 748            query_ids=query_ids,
 749            query_precursor_mzs=query_precursor_mzs,
 750        )
 751        self._run_query_vs_query(fe_kwargs=fe_kwargs)
 752        self._has_queries_run = True
 753
 754    def run_query_vs_library_stage(self) -> None:
 755        """Continue from stage 1 and compute query-vs-library similarities (stage 2).
 756
 757        Searches each stored query spectrum against the reference FE library
 758        and stores entropy + cosine scores in :attr:`similarity_matrices`.
 759        After this call, :attr:`stage_query_library_done` is ``True``.
 760
 761        Stage-3 library subset selection uses
 762        *library_similarity_threshold* on :meth:`run_library_vs_library_stage`
 763        (or the same keyword on :meth:`query_vs_library`), not this method.
 764
 765        Raises
 766        ------
 767        RuntimeError
 768            If stage 1 has not been completed (call
 769            :meth:`run_query_vs_query_only` first) or if ``fe_lib`` is
 770            ``None``.
 771        """
 772        if not self._has_queries_run:
 773            raise RuntimeError(
 774                "No queries have been run yet — call run_query_vs_query_only() or query_vs_library() first."
 775            )
 776
 777        entropy_pairs, _ = self._run_query_vs_library()
 778        self._stage2_entropy_pairs = entropy_pairs
 779
 780    def run_library_vs_library_stage(
 781        self,
 782        library_similarity_threshold: float = 0.3,
 783    ) -> None:
 784        """Continue from stage 2 and compute library-vs-library similarities (stage 3).
 785
 786        Identifies library spectra that scored ≥ *library_similarity_threshold*
 787        against any query in stage 2, then computes all-vs-all pairwise
 788        similarities within that subset.  Results are stored in
 789        :attr:`similarity_matrices` and :attr:`stage_library_library_done`
 790        is set to ``True``.
 791
 792        Parameters
 793        ----------
 794        library_similarity_threshold : float, optional
 795            Minimum entropy similarity score (from stage 2) required for a
 796            library spectrum to be included in this stage.  Default 0.3.
 797
 798        Raises
 799        ------
 800        RuntimeError
 801            If stage 2 has not been completed (call
 802            :meth:`run_query_vs_library_stage` first).
 803        """
 804        if self._stage2_entropy_pairs is None:
 805            raise RuntimeError(
 806                "query-vs-library stage must be run before library-vs-library stage."
 807            )
 808
 809        self._run_library_vs_library(
 810            entropy_pairs=self._stage2_entropy_pairs,
 811            library_similarity_threshold=library_similarity_threshold,
 812        )
 813
 814    def query_vs_library(
 815        self,
 816        query_spectra: list,
 817        query_ids: list[str],
 818        query_precursor_mzs: list[float | None] | None = None,
 819        fe_kwargs: dict | None = None,
 820        *,
 821        hydrate_library_similarities: bool | None = None,
 822        library_similarity_threshold: float | None = None,
 823    ) -> None:
 824        """Run all three stages in one call (convenience wrapper).
 825
 826        Executes stages 1–3 sequentially:
 827
 828        1. **Query-vs-query** – builds a temporary FlashEntropy index from
 829           *query_spectra* and computes all-vs-all pairwise similarities.
 830        2. **Query-vs-library** – searches each query against the reference
 831           FE library (``self.fe_lib``).
 832        3. **Library-vs-library** *(optional)* – when
 833           *hydrate_library_similarities* is ``True``, computes pairwise
 834           similarities for the subset of library spectra that matched any
 835           query above *library_similarity_threshold*.
 836
 837        All results are stored in :attr:`similarity_matrices`.
 838
 839        Parameters
 840        ----------
 841        query_spectra : list
 842            Spectrum objects with ``.mz_exp`` and ``.abundance`` attributes.
 843        query_ids : list of str
 844            Unique string ID for each spectrum.
 845        query_precursor_mzs : list of float or None, optional
 846            Precursor m/z for each spectrum.  Required (non-None list) for
 847            ``"identity"`` and ``"neutral_loss"`` search types.
 848        fe_kwargs : dict, optional
 849            Extra keyword arguments forwarded to
 850            :class:`ms_entropy.FlashEntropySearch` when building the
 851            temporary query index (stage 1). When None and *settings* was
 852            provided at construction, uses ``settings.as_fe_kwargs()``.
 853        hydrate_library_similarities : bool, optional
 854            When ``True``, run stage 3 (library-vs-library). Default from
 855            *settings* or ``False``.
 856        library_similarity_threshold : float, optional
 857            Minimum entropy similarity score (stage 2) required for a library
 858            spectrum to be included in stage 3. Default from *settings* or 0.3.
 859
 860        Raises
 861        ------
 862        RuntimeError
 863            If queries have already been run (call :meth:`drop_queries` first)
 864            or if ``fe_lib`` is ``None`` (required for stages 2 and 3).
 865        ValueError
 866            If *query_spectra* is empty, lengths are mismatched, or precursor
 867            m/z values are missing for a precursor-required search type.
 868        """
 869        if self._has_queries_run:
 870            raise RuntimeError(
 871                "query_vs_library has already been run — call drop_queries() to clear queries and results before running again."
 872            )
 873
 874        if hydrate_library_similarities is None:
 875            hydrate_library_similarities = (
 876                self._settings.hydrate_library_similarities
 877                if self._settings is not None
 878                else False
 879            )
 880        if library_similarity_threshold is None:
 881            library_similarity_threshold = (
 882                self._settings.library_similarity_threshold
 883                if self._settings is not None
 884                else 0.3
 885            )
 886
 887        self._prepare_queries(
 888            query_spectra=query_spectra,
 889            query_ids=query_ids,
 890            query_precursor_mzs=query_precursor_mzs,
 891        )
 892        self._run_query_vs_query(fe_kwargs=fe_kwargs)
 893        entropy_pairs, _ = self._run_query_vs_library()
 894
 895        if hydrate_library_similarities:
 896            self._run_library_vs_library(
 897                entropy_pairs=entropy_pairs,
 898                library_similarity_threshold=library_similarity_threshold,
 899            )
 900
 901        self._has_queries_run = True
 902
 903    def get_network_edges(
 904        self, metric: str = "entropy_similarity"
 905    ) -> list[tuple[str, str, float]]:
 906        """Return all edges above the similarity threshold for *metric*.
 907
 908        Parameters
 909        ----------
 910        metric : str
 911            Similarity metric to query.  Default ``"entropy_similarity"``.
 912
 913        Returns
 914        -------
 915        list of (id1, id2, score)
 916            Upper-triangle pairs with score >= threshold.
 917        """
 918        mat = self._get_matrix(metric)
 919        threshold = self._threshold_for(metric)
 920        return mat.get_pairs_above_threshold(threshold)
 921
 922    def get_spectrum_neighbors(
 923        self,
 924        spectrum_id: str,
 925        metric: str = "entropy_similarity",
 926    ) -> list[tuple[str, float]]:
 927        """Return all neighbors of *spectrum_id* above the threshold.
 928
 929        Parameters
 930        ----------
 931        spectrum_id : str
 932            Query spectrum ID.
 933        metric : str
 934            Similarity metric to query.  Default ``"entropy_similarity"``.
 935
 936        Returns
 937        -------
 938        list of (neighbor_id, score)
 939            Sorted by score descending.
 940        """
 941        mat = self._get_matrix(metric)
 942        threshold = self._threshold_for(metric)
 943
 944        if spectrum_id not in mat._id_to_idx:
 945            return []
 946
 947        idx = mat._id_to_idx[spectrum_id]
 948        if mat._matrix is None:
 949            return []
 950
 951        mat.finalise()
 952        row = mat._matrix.getrow(idx)
 953        cx = row.tocoo()
 954        neighbors = []
 955        for j, v in zip(cx.col, cx.data):
 956            if v >= threshold and j != idx:
 957                neighbors.append((mat._idx_to_id[j], float(v)))
 958        neighbors.sort(key=lambda x: x[1], reverse=True)
 959        return neighbors
 960
 961    def get_network_stats(self, metric: str = "entropy_similarity") -> dict[str, Any]:
 962        """Return basic network statistics for *metric*.
 963
 964        Parameters
 965        ----------
 966        metric : str
 967            Similarity metric to query.  Default ``"entropy_similarity"``.
 968
 969        Returns
 970        -------
 971        dict with keys:
 972            - ``n_nodes`` : number of registered spectra
 973            - ``n_edges`` : number of edges above threshold
 974            - ``avg_degree`` : average node degree
 975            - ``density`` : fraction of possible edges that exist
 976            - ``threshold`` : the threshold used
 977            - ``metric`` : the metric name
 978        """
 979        mat = self._get_matrix(metric)
 980        threshold = self._threshold_for(metric)
 981        edges = mat.get_pairs_above_threshold(threshold)
 982        n_nodes = mat.n_spectra
 983        n_edges = len(edges)
 984        max_edges = n_nodes * (n_nodes - 1) / 2 if n_nodes > 1 else 1
 985        degrees = {sid: 0 for sid in mat.spectrum_ids}
 986        for id1, id2, _ in edges:
 987            degrees[id1] += 1
 988            degrees[id2] += 1
 989        avg_degree = np.mean(list(degrees.values())) if degrees else 0.0
 990        return {
 991            "metric": metric,
 992            "threshold": threshold,
 993            "n_nodes": n_nodes,
 994            "n_edges": n_edges,
 995            "avg_degree": float(avg_degree),
 996            "density": n_edges / max_edges if max_edges > 0 else 0.0,
 997        }
 998
 999    # ── File output ───────────────────────────────────────────────────────────
1000
1001    def save_edge_list(
1002        self,
1003        path: str,
1004        metric: str = "entropy_similarity",
1005    ):
1006        """Save the edge list as a CSV file.
1007
1008        Parameters
1009        ----------
1010        path : str
1011            Output file path.
1012        metric : str
1013            Similarity metric.  Default ``"entropy_similarity"``.
1014        """
1015        edges = self.get_network_edges(metric=metric)
1016        query_id_set = set(self._all_query_ids)
1017        export_edges = [
1018            (
1019                self._export_node_id(id1, query_id_set),
1020                self._export_node_id(id2, query_id_set),
1021                score,
1022            )
1023            for id1, id2, score in edges
1024        ]
1025        df = pd.DataFrame(export_edges, columns=["id1", "id2", "score"])
1026        df.to_csv(path, index=False)
1027
1028    def save_similarity_matrix(
1029        self,
1030        path: str,
1031        metric: str = "entropy_similarity",
1032        threshold: float = 0.0,
1033    ):
1034        """Save the similarity matrix as a CSV file.
1035
1036        Parameters
1037        ----------
1038        path : str
1039            Output file path.
1040        metric : str
1041            Similarity metric.  Default ``"entropy_similarity"``.
1042        threshold : float
1043            Minimum score to include.  Default 0.0 (all stored pairs).
1044        """
1045        mat = self._get_matrix(metric)
1046        df = mat.to_dataframe(threshold=threshold)
1047        df.to_csv(path, index=False)
1048
1049    # ── Helpers ───────────────────────────────────────────────────────────────
1050
1051    def _get_matrix(self, metric: str) -> SimilarityMatrix:
1052        if metric not in self.similarity_matrices:
1053            raise KeyError(
1054                f"Unknown metric '{metric}'. "
1055                f"Available: {list(self.similarity_matrices.keys())}"
1056            )
1057        return self.similarity_matrices[metric]
1058
1059    def drop_queries(self):
1060        """Clear all stored queries and computed similarity results.
1061
1062        Resets internal query storage, stage flags, the stage-2 entropy-pair
1063        cache, clustering artifacts, and reinitialises all
1064        :attr:`similarity_matrices` to empty so that
1065        :meth:`run_query_vs_query_only` or :meth:`query_vs_library` can be
1066        called again with a consistent cleared state.
1067        """
1068        # Clear stored queries
1069        self._all_query_spectra = []
1070        self._all_query_ids = []
1071        self._all_query_precursor_mzs = []
1072        self._has_queries_run = False
1073        self._stage2_entropy_pairs = None
1074
1075        # Stage flags must match empty matrices (not "stale done")
1076        self.stage_query_query_done = False
1077        self.stage_query_library_done = False
1078        self.stage_library_library_done = False
1079
1080        # Recreate empty similarity matrices for each metric
1081        all_metrics = list(self.similarity_matrices.keys())
1082        self.similarity_matrices = {
1083            m: SimilarityMatrix(metric_name=m) for m in all_metrics
1084        }
1085
1086        # Drop any cached clustering artifacts tied to previous query results.
1087        self._network_clusters = {}
1088
1089    def __repr__(self) -> str:
1090        n_nodes = sum(
1091            mat.n_spectra for mat in self.similarity_matrices.values()
1092        ) // max(1, len(self.similarity_matrices))
1093        metrics = list(self.similarity_matrices.keys())
1094        return (
1095            f"MolecularNetwork(search_type='{self.search_type}', "
1096            f"n_spectra={n_nodes}, metrics={metrics})"
1097        )

Build a molecular network around a reference library.

Initialization is lazy — no similarity computation happens until you call one of the computation methods.

Parameters
  • fe_lib (ms_entropy.FlashEntropySearch or None, optional): Pre-built FlashEntropy search instance (built from the reference library). Required for query-vs-library and library-vs-library stages. Pass None (default) to build a query-only network (stage 1 only). When provided, fragment-tolerance parameters are automatically extracted from this library unless overridden by ms2_tolerance_da.
  • search_type (str): FlashEntropy search mode: "identity", "open", or "neutral_loss". Default "open" (typical DDA networking; use "identity" when precursor filtering is required).
  • additional_similarities (list of str, optional): Extra similarity metrics to compute alongside entropy similarity. Currently supported: ["cosine"]. Default ["cosine"]. Cosine is gated by entropy_threshold_low (best-effort secondary score).
  • similarity_thresholds (dict, optional): Mapping of metric name → edge threshold. Edges are created in each metric's network when score >= threshold. Metrics not listed use a default of 0.5. Example: {"entropy_similarity": 0.5, "cosine": 0.6}
  • ms1_tolerance_da (float, optional): Precursor m/z tolerance (Da) used for "identity" search to filter library candidates by precursor m/z. Ignored for "open" and "neutral_loss" (precursor filter is disabled for those modes). If None (default), uses 0.01 Da.
  • ms2_tolerance_da (float, optional): Fragment m/z tolerance (Da) used for spectrum cleaning and similarity scoring. Priority: explicit kwarg > extracted from fe_lib > 0.01 Da fallback. Override this when fe_lib is None or when you need a tolerance different from the library's built-in value.
  • entropy_threshold_low (float or None, optional): Minimum entropy similarity score required to trigger additional metric computation (e.g. cosine). If None (default), set to half the lowest non-entropy similarity threshold, or 0.25 when no thresholds are given.
  • settings (SpectralSimilaritySearchSettings, optional): Encapsulated spectral-similarity / networking defaults (for example lcms.parameters.mass_spectrum["ms2"].spectral_similarity_search or ~corems.encapsulation.factory.parameters.settings_from_lcms()). When provided, fills any constructor fields left as None from this object. Explicit keyword arguments always override settings. Stage-1 temporary FE index build uses settings.as_fe_kwargs() when fe_kwargs is not passed to query_vs_library() / run_query_vs_query_only(). Stage-3 defaults (hydrate_library_similarities, library_similarity_threshold) are also taken from settings when those method arguments are omitted.
Attributes
  • similarity_matrices (dict of str → SimilarityMatrix): One ~corems.molecular_networking.similarity_matrix.SimilarityMatrix per metric ("entropy_similarity", "cosine", …).
  • stage_query_query_done (bool): True after query-vs-query similarities have been computed (stage 1).
  • stage_query_library_done (bool): True after query-vs-library similarities have been computed (stage 2).
  • stage_library_library_done (bool): True after matched library-vs-library similarities have been computed (stage 3).
Notes

Tolerance resolution order (highest priority first):

  1. Explicit ms2_tolerance_da kwarg passed to this constructor.
  2. settings.ms2_tolerance_da when settings is provided and the kwarg is omitted.
  3. Value extracted from fe_lib.entropy_search.max_ms2_tolerance_in_da.
  4. Hard-coded fallback of 0.01 Da (used when fe_lib is None and no explicit value is given).

Prefer building fe_lib with the same ~corems.encapsulation.factory.processingSetting.SpectralSimilaritySearchSettings instance used here (via library settings= / settings_from_lcms) so FlashEntropy index tolerances match networking.

Precursor filter behaviour by search type:

  • "identity" : library candidates are filtered by precursor m/z within ms1_tolerance_da before scoring.
  • "open" : precursor filter is disabled; all library entries are scored.
  • "neutral_loss" : precursor filter is disabled; matching occurs in neutral-loss mass space (precursor_mz − fragment_mz), not on the precursor itself.

Cosine scoring (when requested) is single-threaded: unique spectra are pre-sorted once, then pairs are scored sequentially.

MolecularNetwork( fe_lib=None, search_type: str | None = None, additional_similarities: list[str] | None = None, similarity_thresholds: dict[str, float] | None = None, ms1_tolerance_da: float | None = None, ms2_tolerance_da: float | None = None, entropy_threshold_low: float | None = None, settings=None)
147    def __init__(
148        self,
149        fe_lib=None,
150        search_type: str | None = None,
151        additional_similarities: list[str] | None = None,
152        similarity_thresholds: dict[str, float] | None = None,
153        ms1_tolerance_da: float | None = None,
154        ms2_tolerance_da: float | None = None,
155        entropy_threshold_low: float | None = None,
156        settings=None,
157    ):
158        if settings is not None:
159            if search_type is None:
160                search_type = settings.search_type
161            if additional_similarities is None:
162                additional_similarities = list(settings.additional_similarities)
163            if similarity_thresholds is None:
164                similarity_thresholds = dict(settings.similarity_thresholds)
165            if ms1_tolerance_da is None:
166                ms1_tolerance_da = settings.ms1_tolerance_da
167            if ms2_tolerance_da is None:
168                ms2_tolerance_da = settings.ms2_tolerance_da
169            if entropy_threshold_low is None:
170                entropy_threshold_low = settings.entropy_threshold_low
171            self._settings = settings
172        else:
173            self._settings = None
174
175        if search_type is None:
176            search_type = "open"
177        if additional_similarities is None:
178            additional_similarities = ["cosine"]
179        if similarity_thresholds is None:
180            similarity_thresholds = {}
181
182        if entropy_threshold_low is None:
183            non_entropy_thresholds = [
184                v for k, v in similarity_thresholds.items() if k != "entropy_similarity"
185            ]
186            if non_entropy_thresholds:
187                entropy_threshold_low = min(non_entropy_thresholds) / 2
188            else:
189                entropy_threshold_low = 0.25  # Placeholder that won't be used if no additional similarities are computed
190
191        self.fe_lib = fe_lib
192        self.search_type = search_type
193        self.additional_similarities = list(additional_similarities)
194        self.similarity_thresholds = similarity_thresholds
195
196        # Build the engine (wraps fe_lib + search parameters)
197        # When fe_lib is provided, tolerance parameters are extracted from it.
198        # When fe_lib is None (queries-only use), SimilarityEngine will fall back
199        # to its internal defaults and only query-vs-query stage is allowed.
200        self._engine = SimilarityEngine(
201            fe_lib=fe_lib,
202            search_type=search_type,
203            additional_similarities=additional_similarities,
204            ms1_tolerance_da=ms1_tolerance_da,
205            ms2_tolerance_da=ms2_tolerance_da,
206            entropy_threshold_low=entropy_threshold_low,
207            settings=settings,
208        )
209
210        # One SimilarityMatrix per metric – empty until query_vs_library() is called
211        all_metrics = ["entropy_similarity"] + list(additional_similarities)
212        self.similarity_matrices: dict[str, SimilarityMatrix] = {
213            m: SimilarityMatrix(metric_name=m) for m in all_metrics
214        }
215
216        # Accumulate query spectra (kept separate from the library FE index)
217        self._all_query_spectra: list = []
218        self._all_query_ids: list[str] = []
219        self._all_query_precursor_mzs: list[float | None] = []
220
221        # Stage flags – enforce order:
222        #   1) query-vs-query
223        #   2) query-vs-library
224        #   3) matched library-vs-library
225        self.stage_query_query_done: bool = False
226        self.stage_query_library_done: bool = False
227        self.stage_library_library_done: bool = False
228
229        # Whether query_vs_library has been run (disabled repeated runs until dropped)
230        self._has_queries_run: bool = False
231
232        # Cache for stage 2 entropy pairs (used by stage 3)
233        self._stage2_entropy_pairs: dict[tuple[str, str], float] | None = None
234
235        # Cache of computed network clustering artifacts keyed by metric.
236        self._network_clusters: dict[str, dict] = {}
fe_lib
search_type
additional_similarities
similarity_thresholds
similarity_matrices: dict[str, SimilarityMatrix]
stage_query_query_done: bool
stage_query_library_done: bool
stage_library_library_done: bool
@staticmethod
def prepare_query_spectra_from_lcms_object( lcms_obj, mf_ids: set | None = None) -> tuple[list, list[str], list[float]]:
238    @staticmethod
239    def prepare_query_spectra_from_lcms_object(
240        lcms_obj,
241        mf_ids: set | None = None,
242    ) -> tuple[list, list[str], list[float]]:
243        """Build query lists from an ``LCMSBase`` object's mass features.
244
245        Iterates ``lcms_obj.mass_features`` and collects each feature's
246        ``best_ms2`` spectrum, skipping features with no MS2 or an empty
247        ``mz_exp``.  If *mf_ids* is provided, only those mass features are
248        included.
249
250        Parameters
251        ----------
252        lcms_obj : LCMSBase
253            LCMS object whose mass features have been populated with MS2
254            spectra (e.g. via :meth:`add_associated_ms2_dda`).
255        mf_ids : set, optional
256            If provided, only mass features whose id is in this set are
257            included.
258
259        Returns
260        -------
261        (query_spectra, query_ids, query_precursor_mzs)
262            Three parallel lists ready to pass to :meth:`query_vs_library`
263            or :meth:`run_query_vs_query_only`.
264        """
265        query_spectra: list = []
266        query_ids: list[str] = []
267        query_precursor_mzs: list[float] = []
268        for mf_id, mf in lcms_obj.mass_features.items():
269            if mf_ids is not None and mf_id not in mf_ids:
270                continue
271            ms2 = mf.best_ms2
272            if ms2 is None:
273                continue
274            if not hasattr(ms2, "mz_exp") or len(ms2.mz_exp) == 0:
275                continue
276            query_spectra.append(ms2)
277            query_ids.append(str(mf_id))
278            query_precursor_mzs.append(float(mf.mz))
279        return query_spectra, query_ids, query_precursor_mzs

Build query lists from an LCMSBase object's mass features.

Iterates lcms_obj.mass_features and collects each feature's best_ms2 spectrum, skipping features with no MS2 or an empty mz_exp. If mf_ids is provided, only those mass features are included.

Parameters
  • lcms_obj (LCMSBase): LCMS object whose mass features have been populated with MS2 spectra (e.g. via add_associated_ms2_dda()).
  • mf_ids (set, optional): If provided, only mass features whose id is in this set are included.
Returns
@staticmethod
def prepare_query_spectra_from_lcms_collection( lcms_collection, cluster_ids: set | None = None, representative_metric: str | None = None) -> tuple[list, list[str], list[float]]:
281    @staticmethod
282    def prepare_query_spectra_from_lcms_collection(
283        lcms_collection,
284        cluster_ids: set | None = None,
285        representative_metric: str | None = None,
286    ) -> tuple[list, list[str], list[float]]:
287        """Build query lists from representative mass features in an ``LCMSCollection``.
288
289        Uses :meth:`~corems.mass_spectra.calc.lc_calc.LCMSCollectionCalculations.get_representative_mass_features_for_all_clusters`
290        to select one mass feature per consensus cluster, then collects each
291        feature's ``best_ms2`` from the corresponding sample object.
292
293        Representative mass features must already be loaded with MS2 spectra
294        (e.g. via ``process_consensus_features(load_representatives=True,
295        add_ms2=True, ...)`` or ``reload_representative_mass_features(add_ms2=True)``).
296        Clusters whose representative has no usable MS2 are skipped.
297
298        Parameters
299        ----------
300        lcms_collection : LCMSCollection
301            Collection with consensus clusters and loaded representative
302            mass features (with MS2 where available).
303        cluster_ids : set, optional
304            If provided, only representatives for these cluster IDs are
305            included.
306        representative_metric : str, optional
307            Metric passed to
308            :meth:`~corems.mass_spectra.calc.lc_calc.LCMSCollectionCalculations.get_representative_mass_features_for_all_clusters`.
309            If ``None``, uses the collection parameter
310            ``consensus_representative_metric``.
311
312        Returns
313        -------
314        (query_spectra, query_ids, query_precursor_mzs)
315            Three parallel lists ready to pass to :meth:`query_vs_library`
316            or :meth:`run_query_vs_query_only`.  Query IDs are collection-level
317            ``coll_mf_id`` strings (``"{sample_id}_{mf_id}"``).
318
319        Raises
320        ------
321        AttributeError
322            If *lcms_collection* does not expose representative lookup or
323            sample indexing (not an ``LCMSCollection``-like object).
324        """
325        if not hasattr(
326            lcms_collection, "get_representative_mass_features_for_all_clusters"
327        ):
328            raise AttributeError(
329                "lcms_collection must provide get_representative_mass_features_for_all_clusters "
330                "(expected an LCMSCollection)."
331            )
332
333        representatives = (
334            lcms_collection.get_representative_mass_features_for_all_clusters(
335                representative_metric=representative_metric
336            )
337        )
338        if cluster_ids is not None:
339            representatives = representatives[
340                representatives["cluster"].isin(cluster_ids)
341            ]
342
343        query_spectra: list = []
344        query_ids: list[str] = []
345        query_precursor_mzs: list[float] = []
346
347        for _, row in representatives.iterrows():
348            sample_id = int(row["sample_id"])
349            mf_id = row["mf_id"]
350            coll_mf_id = str(row["coll_mf_id"])
351
352            try:
353                sample = lcms_collection[sample_id]
354            except (IndexError, KeyError, TypeError):
355                continue
356
357            mass_features = getattr(sample, "mass_features", None) or {}
358            mf = mass_features.get(mf_id)
359            if mf is None:
360                # mf_id from dataframes may not match dict key type (int/str/np)
361                try:
362                    mf = mass_features.get(int(mf_id))
363                except (TypeError, ValueError):
364                    mf = None
365            if mf is None:
366                try:
367                    mf = mass_features.get(str(mf_id))
368                except (TypeError, ValueError):
369                    mf = None
370            if mf is None:
371                continue
372
373            ms2 = getattr(mf, "best_ms2", None)
374            if ms2 is None:
375                continue
376            if not hasattr(ms2, "mz_exp") or len(ms2.mz_exp) == 0:
377                continue
378
379            query_spectra.append(ms2)
380            query_ids.append(coll_mf_id)
381            query_precursor_mzs.append(float(mf.mz))
382
383        return query_spectra, query_ids, query_precursor_mzs

Build query lists from representative mass features in an LCMSCollection.

Uses ~corems.mass_spectra.calc.lc_calc.LCMSCollectionCalculations.get_representative_mass_features_for_all_clusters() to select one mass feature per consensus cluster, then collects each feature's best_ms2 from the corresponding sample object.

Representative mass features must already be loaded with MS2 spectra (e.g. via process_consensus_features(load_representatives=True, add_ms2=True, ...) or reload_representative_mass_features(add_ms2=True)). Clusters whose representative has no usable MS2 are skipped.

Parameters
Returns
Raises
  • AttributeError: If lcms_collection does not expose representative lookup or sample indexing (not an LCMSCollection-like object).
@staticmethod
def library_node_id(lib_idx: int) -> str:
391    @staticmethod
392    def library_node_id(lib_idx: int) -> str:
393        """Return the internal network node ID for a library spectrum index."""
394        return f"{_LIB_NODE_PREFIX}{int(lib_idx)}"

Return the internal network node ID for a library spectrum index.

@staticmethod
def library_index_from_node_id(node_id: str) -> int | None:
396    @staticmethod
397    def library_index_from_node_id(node_id: str) -> int | None:
398        """Parse a library matrix index from an internal node ID, if any.
399
400        Accepts the canonical ``lib:<idx>`` form. Bare integer strings are
401        also accepted for backward compatibility with older matrices.
402        """
403        if node_id is None:
404            return None
405        s = str(node_id)
406        if s.startswith(_LIB_NODE_PREFIX):
407            try:
408                return int(s[len(_LIB_NODE_PREFIX) :])
409            except ValueError:
410                return None
411        try:
412            return int(s)
413        except (TypeError, ValueError):
414            return None

Parse a library matrix index from an internal node ID, if any.

Accepts the canonical lib:<idx> form. Bare integer strings are also accepted for backward compatibility with older matrices.

def run_query_vs_query_only( self, query_spectra: list, query_ids: list[str], query_precursor_mzs: list[float | None] | None = None, fe_kwargs: dict | None = None) -> None:
702    def run_query_vs_query_only(
703        self,
704        query_spectra: list,
705        query_ids: list[str],
706        query_precursor_mzs: list[float | None] | None = None,
707        fe_kwargs: dict | None = None,
708    ) -> None:
709        """Compute query-vs-query similarities (stage 1 only).
710
711        Builds a temporary FlashEntropy index from *query_spectra* and
712        computes all-vs-all pairwise similarities within that set.  Results
713        are stored in :attr:`similarity_matrices`.
714
715        After this call, :attr:`stage_query_query_done` is ``True``.  If a
716        reference library was provided at construction time you may continue
717        with :meth:`run_query_vs_library_stage`.
718
719        Parameters
720        ----------
721        query_spectra : list
722            Spectrum objects with ``.mz_exp`` and ``.abundance`` attributes.
723        query_ids : list of str
724            Unique string ID for each spectrum.
725        query_precursor_mzs : list of float or None, optional
726            Precursor m/z for each spectrum.  Required (non-None list) for
727            ``"identity"`` and ``"neutral_loss"`` search types.
728        fe_kwargs : dict, optional
729            Extra keyword arguments forwarded to
730            :class:`ms_entropy.FlashEntropySearch` when building the
731            temporary query index.
732
733        Raises
734        ------
735        RuntimeError
736            If queries have already been run (call :meth:`drop_queries` first).
737        ValueError
738            If *query_spectra* is empty, lengths are mismatched, or precursor
739            m/z values are missing for a precursor-required search type.
740        """
741        if self._has_queries_run:
742            raise RuntimeError(
743                "query_vs_library has already been run — call drop_queries() to clear queries and results before running again."
744            )
745
746        self._prepare_queries(
747            query_spectra=query_spectra,
748            query_ids=query_ids,
749            query_precursor_mzs=query_precursor_mzs,
750        )
751        self._run_query_vs_query(fe_kwargs=fe_kwargs)
752        self._has_queries_run = True

Compute query-vs-query similarities (stage 1 only).

Builds a temporary FlashEntropy index from query_spectra and computes all-vs-all pairwise similarities within that set. Results are stored in similarity_matrices.

After this call, stage_query_query_done is True. If a reference library was provided at construction time you may continue with run_query_vs_library_stage().

Parameters
  • query_spectra (list): Spectrum objects with .mz_exp and .abundance attributes.
  • query_ids (list of str): Unique string ID for each spectrum.
  • query_precursor_mzs (list of float or None, optional): Precursor m/z for each spectrum. Required (non-None list) for "identity" and "neutral_loss" search types.
  • fe_kwargs (dict, optional): Extra keyword arguments forwarded to ms_entropy.FlashEntropySearch when building the temporary query index.
Raises
  • RuntimeError: If queries have already been run (call drop_queries() first).
  • ValueError: If query_spectra is empty, lengths are mismatched, or precursor m/z values are missing for a precursor-required search type.
def run_query_vs_library_stage(self) -> None:
754    def run_query_vs_library_stage(self) -> None:
755        """Continue from stage 1 and compute query-vs-library similarities (stage 2).
756
757        Searches each stored query spectrum against the reference FE library
758        and stores entropy + cosine scores in :attr:`similarity_matrices`.
759        After this call, :attr:`stage_query_library_done` is ``True``.
760
761        Stage-3 library subset selection uses
762        *library_similarity_threshold* on :meth:`run_library_vs_library_stage`
763        (or the same keyword on :meth:`query_vs_library`), not this method.
764
765        Raises
766        ------
767        RuntimeError
768            If stage 1 has not been completed (call
769            :meth:`run_query_vs_query_only` first) or if ``fe_lib`` is
770            ``None``.
771        """
772        if not self._has_queries_run:
773            raise RuntimeError(
774                "No queries have been run yet — call run_query_vs_query_only() or query_vs_library() first."
775            )
776
777        entropy_pairs, _ = self._run_query_vs_library()
778        self._stage2_entropy_pairs = entropy_pairs

Continue from stage 1 and compute query-vs-library similarities (stage 2).

Searches each stored query spectrum against the reference FE library and stores entropy + cosine scores in similarity_matrices. After this call, stage_query_library_done is True.

Stage-3 library subset selection uses library_similarity_threshold on run_library_vs_library_stage() (or the same keyword on query_vs_library()), not this method.

Raises
def run_library_vs_library_stage(self, library_similarity_threshold: float = 0.3) -> None:
780    def run_library_vs_library_stage(
781        self,
782        library_similarity_threshold: float = 0.3,
783    ) -> None:
784        """Continue from stage 2 and compute library-vs-library similarities (stage 3).
785
786        Identifies library spectra that scored ≥ *library_similarity_threshold*
787        against any query in stage 2, then computes all-vs-all pairwise
788        similarities within that subset.  Results are stored in
789        :attr:`similarity_matrices` and :attr:`stage_library_library_done`
790        is set to ``True``.
791
792        Parameters
793        ----------
794        library_similarity_threshold : float, optional
795            Minimum entropy similarity score (from stage 2) required for a
796            library spectrum to be included in this stage.  Default 0.3.
797
798        Raises
799        ------
800        RuntimeError
801            If stage 2 has not been completed (call
802            :meth:`run_query_vs_library_stage` first).
803        """
804        if self._stage2_entropy_pairs is None:
805            raise RuntimeError(
806                "query-vs-library stage must be run before library-vs-library stage."
807            )
808
809        self._run_library_vs_library(
810            entropy_pairs=self._stage2_entropy_pairs,
811            library_similarity_threshold=library_similarity_threshold,
812        )

Continue from stage 2 and compute library-vs-library similarities (stage 3).

Identifies library spectra that scored ≥ library_similarity_threshold against any query in stage 2, then computes all-vs-all pairwise similarities within that subset. Results are stored in similarity_matrices and stage_library_library_done is set to True.

Parameters
  • library_similarity_threshold (float, optional): Minimum entropy similarity score (from stage 2) required for a library spectrum to be included in this stage. Default 0.3.
Raises
def query_vs_library( self, query_spectra: list, query_ids: list[str], query_precursor_mzs: list[float | None] | None = None, fe_kwargs: dict | None = None, *, hydrate_library_similarities: bool | None = None, library_similarity_threshold: float | None = None) -> None:
814    def query_vs_library(
815        self,
816        query_spectra: list,
817        query_ids: list[str],
818        query_precursor_mzs: list[float | None] | None = None,
819        fe_kwargs: dict | None = None,
820        *,
821        hydrate_library_similarities: bool | None = None,
822        library_similarity_threshold: float | None = None,
823    ) -> None:
824        """Run all three stages in one call (convenience wrapper).
825
826        Executes stages 1–3 sequentially:
827
828        1. **Query-vs-query** – builds a temporary FlashEntropy index from
829           *query_spectra* and computes all-vs-all pairwise similarities.
830        2. **Query-vs-library** – searches each query against the reference
831           FE library (``self.fe_lib``).
832        3. **Library-vs-library** *(optional)* – when
833           *hydrate_library_similarities* is ``True``, computes pairwise
834           similarities for the subset of library spectra that matched any
835           query above *library_similarity_threshold*.
836
837        All results are stored in :attr:`similarity_matrices`.
838
839        Parameters
840        ----------
841        query_spectra : list
842            Spectrum objects with ``.mz_exp`` and ``.abundance`` attributes.
843        query_ids : list of str
844            Unique string ID for each spectrum.
845        query_precursor_mzs : list of float or None, optional
846            Precursor m/z for each spectrum.  Required (non-None list) for
847            ``"identity"`` and ``"neutral_loss"`` search types.
848        fe_kwargs : dict, optional
849            Extra keyword arguments forwarded to
850            :class:`ms_entropy.FlashEntropySearch` when building the
851            temporary query index (stage 1). When None and *settings* was
852            provided at construction, uses ``settings.as_fe_kwargs()``.
853        hydrate_library_similarities : bool, optional
854            When ``True``, run stage 3 (library-vs-library). Default from
855            *settings* or ``False``.
856        library_similarity_threshold : float, optional
857            Minimum entropy similarity score (stage 2) required for a library
858            spectrum to be included in stage 3. Default from *settings* or 0.3.
859
860        Raises
861        ------
862        RuntimeError
863            If queries have already been run (call :meth:`drop_queries` first)
864            or if ``fe_lib`` is ``None`` (required for stages 2 and 3).
865        ValueError
866            If *query_spectra* is empty, lengths are mismatched, or precursor
867            m/z values are missing for a precursor-required search type.
868        """
869        if self._has_queries_run:
870            raise RuntimeError(
871                "query_vs_library has already been run — call drop_queries() to clear queries and results before running again."
872            )
873
874        if hydrate_library_similarities is None:
875            hydrate_library_similarities = (
876                self._settings.hydrate_library_similarities
877                if self._settings is not None
878                else False
879            )
880        if library_similarity_threshold is None:
881            library_similarity_threshold = (
882                self._settings.library_similarity_threshold
883                if self._settings is not None
884                else 0.3
885            )
886
887        self._prepare_queries(
888            query_spectra=query_spectra,
889            query_ids=query_ids,
890            query_precursor_mzs=query_precursor_mzs,
891        )
892        self._run_query_vs_query(fe_kwargs=fe_kwargs)
893        entropy_pairs, _ = self._run_query_vs_library()
894
895        if hydrate_library_similarities:
896            self._run_library_vs_library(
897                entropy_pairs=entropy_pairs,
898                library_similarity_threshold=library_similarity_threshold,
899            )
900
901        self._has_queries_run = True

Run all three stages in one call (convenience wrapper).

Executes stages 1–3 sequentially:

  1. Query-vs-query – builds a temporary FlashEntropy index from query_spectra and computes all-vs-all pairwise similarities.
  2. Query-vs-library – searches each query against the reference FE library (self.fe_lib).
  3. Library-vs-library (optional) – when hydrate_library_similarities is True, computes pairwise similarities for the subset of library spectra that matched any query above library_similarity_threshold.

All results are stored in similarity_matrices.

Parameters
  • query_spectra (list): Spectrum objects with .mz_exp and .abundance attributes.
  • query_ids (list of str): Unique string ID for each spectrum.
  • query_precursor_mzs (list of float or None, optional): Precursor m/z for each spectrum. Required (non-None list) for "identity" and "neutral_loss" search types.
  • fe_kwargs (dict, optional): Extra keyword arguments forwarded to ms_entropy.FlashEntropySearch when building the temporary query index (stage 1). When None and settings was provided at construction, uses settings.as_fe_kwargs().
  • hydrate_library_similarities (bool, optional): When True, run stage 3 (library-vs-library). Default from settings or False.
  • library_similarity_threshold (float, optional): Minimum entropy similarity score (stage 2) required for a library spectrum to be included in stage 3. Default from settings or 0.3.
Raises
  • RuntimeError: If queries have already been run (call drop_queries() first) or if fe_lib is None (required for stages 2 and 3).
  • ValueError: If query_spectra is empty, lengths are mismatched, or precursor m/z values are missing for a precursor-required search type.
def get_network_edges(self, metric: str = 'entropy_similarity') -> list[tuple[str, str, float]]:
903    def get_network_edges(
904        self, metric: str = "entropy_similarity"
905    ) -> list[tuple[str, str, float]]:
906        """Return all edges above the similarity threshold for *metric*.
907
908        Parameters
909        ----------
910        metric : str
911            Similarity metric to query.  Default ``"entropy_similarity"``.
912
913        Returns
914        -------
915        list of (id1, id2, score)
916            Upper-triangle pairs with score >= threshold.
917        """
918        mat = self._get_matrix(metric)
919        threshold = self._threshold_for(metric)
920        return mat.get_pairs_above_threshold(threshold)

Return all edges above the similarity threshold for metric.

Parameters
  • metric (str): Similarity metric to query. Default "entropy_similarity".
Returns
  • list of (id1, id2, score): Upper-triangle pairs with score >= threshold.
def get_spectrum_neighbors( self, spectrum_id: str, metric: str = 'entropy_similarity') -> list[tuple[str, float]]:
922    def get_spectrum_neighbors(
923        self,
924        spectrum_id: str,
925        metric: str = "entropy_similarity",
926    ) -> list[tuple[str, float]]:
927        """Return all neighbors of *spectrum_id* above the threshold.
928
929        Parameters
930        ----------
931        spectrum_id : str
932            Query spectrum ID.
933        metric : str
934            Similarity metric to query.  Default ``"entropy_similarity"``.
935
936        Returns
937        -------
938        list of (neighbor_id, score)
939            Sorted by score descending.
940        """
941        mat = self._get_matrix(metric)
942        threshold = self._threshold_for(metric)
943
944        if spectrum_id not in mat._id_to_idx:
945            return []
946
947        idx = mat._id_to_idx[spectrum_id]
948        if mat._matrix is None:
949            return []
950
951        mat.finalise()
952        row = mat._matrix.getrow(idx)
953        cx = row.tocoo()
954        neighbors = []
955        for j, v in zip(cx.col, cx.data):
956            if v >= threshold and j != idx:
957                neighbors.append((mat._idx_to_id[j], float(v)))
958        neighbors.sort(key=lambda x: x[1], reverse=True)
959        return neighbors

Return all neighbors of spectrum_id above the threshold.

Parameters
  • spectrum_id (str): Query spectrum ID.
  • metric (str): Similarity metric to query. Default "entropy_similarity".
Returns
  • list of (neighbor_id, score): Sorted by score descending.
def get_network_stats(self, metric: str = 'entropy_similarity') -> dict[str, typing.Any]:
961    def get_network_stats(self, metric: str = "entropy_similarity") -> dict[str, Any]:
962        """Return basic network statistics for *metric*.
963
964        Parameters
965        ----------
966        metric : str
967            Similarity metric to query.  Default ``"entropy_similarity"``.
968
969        Returns
970        -------
971        dict with keys:
972            - ``n_nodes`` : number of registered spectra
973            - ``n_edges`` : number of edges above threshold
974            - ``avg_degree`` : average node degree
975            - ``density`` : fraction of possible edges that exist
976            - ``threshold`` : the threshold used
977            - ``metric`` : the metric name
978        """
979        mat = self._get_matrix(metric)
980        threshold = self._threshold_for(metric)
981        edges = mat.get_pairs_above_threshold(threshold)
982        n_nodes = mat.n_spectra
983        n_edges = len(edges)
984        max_edges = n_nodes * (n_nodes - 1) / 2 if n_nodes > 1 else 1
985        degrees = {sid: 0 for sid in mat.spectrum_ids}
986        for id1, id2, _ in edges:
987            degrees[id1] += 1
988            degrees[id2] += 1
989        avg_degree = np.mean(list(degrees.values())) if degrees else 0.0
990        return {
991            "metric": metric,
992            "threshold": threshold,
993            "n_nodes": n_nodes,
994            "n_edges": n_edges,
995            "avg_degree": float(avg_degree),
996            "density": n_edges / max_edges if max_edges > 0 else 0.0,
997        }

Return basic network statistics for metric.

Parameters
  • metric (str): Similarity metric to query. Default "entropy_similarity".
Returns
  • dict with keys:: - n_nodes : number of registered spectra
    • n_edges : number of edges above threshold
    • avg_degree : average node degree
    • density : fraction of possible edges that exist
    • threshold : the threshold used
    • metric : the metric name
def save_edge_list(self, path: str, metric: str = 'entropy_similarity'):
1001    def save_edge_list(
1002        self,
1003        path: str,
1004        metric: str = "entropy_similarity",
1005    ):
1006        """Save the edge list as a CSV file.
1007
1008        Parameters
1009        ----------
1010        path : str
1011            Output file path.
1012        metric : str
1013            Similarity metric.  Default ``"entropy_similarity"``.
1014        """
1015        edges = self.get_network_edges(metric=metric)
1016        query_id_set = set(self._all_query_ids)
1017        export_edges = [
1018            (
1019                self._export_node_id(id1, query_id_set),
1020                self._export_node_id(id2, query_id_set),
1021                score,
1022            )
1023            for id1, id2, score in edges
1024        ]
1025        df = pd.DataFrame(export_edges, columns=["id1", "id2", "score"])
1026        df.to_csv(path, index=False)

Save the edge list as a CSV file.

Parameters
  • path (str): Output file path.
  • metric (str): Similarity metric. Default "entropy_similarity".
def save_similarity_matrix( self, path: str, metric: str = 'entropy_similarity', threshold: float = 0.0):
1028    def save_similarity_matrix(
1029        self,
1030        path: str,
1031        metric: str = "entropy_similarity",
1032        threshold: float = 0.0,
1033    ):
1034        """Save the similarity matrix as a CSV file.
1035
1036        Parameters
1037        ----------
1038        path : str
1039            Output file path.
1040        metric : str
1041            Similarity metric.  Default ``"entropy_similarity"``.
1042        threshold : float
1043            Minimum score to include.  Default 0.0 (all stored pairs).
1044        """
1045        mat = self._get_matrix(metric)
1046        df = mat.to_dataframe(threshold=threshold)
1047        df.to_csv(path, index=False)

Save the similarity matrix as a CSV file.

Parameters
  • path (str): Output file path.
  • metric (str): Similarity metric. Default "entropy_similarity".
  • threshold (float): Minimum score to include. Default 0.0 (all stored pairs).
def drop_queries(self):
1059    def drop_queries(self):
1060        """Clear all stored queries and computed similarity results.
1061
1062        Resets internal query storage, stage flags, the stage-2 entropy-pair
1063        cache, clustering artifacts, and reinitialises all
1064        :attr:`similarity_matrices` to empty so that
1065        :meth:`run_query_vs_query_only` or :meth:`query_vs_library` can be
1066        called again with a consistent cleared state.
1067        """
1068        # Clear stored queries
1069        self._all_query_spectra = []
1070        self._all_query_ids = []
1071        self._all_query_precursor_mzs = []
1072        self._has_queries_run = False
1073        self._stage2_entropy_pairs = None
1074
1075        # Stage flags must match empty matrices (not "stale done")
1076        self.stage_query_query_done = False
1077        self.stage_query_library_done = False
1078        self.stage_library_library_done = False
1079
1080        # Recreate empty similarity matrices for each metric
1081        all_metrics = list(self.similarity_matrices.keys())
1082        self.similarity_matrices = {
1083            m: SimilarityMatrix(metric_name=m) for m in all_metrics
1084        }
1085
1086        # Drop any cached clustering artifacts tied to previous query results.
1087        self._network_clusters = {}

Clear all stored queries and computed similarity results.

Resets internal query storage, stage flags, the stage-2 entropy-pair cache, clustering artifacts, and reinitialises all similarity_matrices to empty so that run_query_vs_query_only() or query_vs_library() can be called again with a consistent cleared state.

class SimilarityMatrix:
 21class SimilarityMatrix:
 22    """Sparse, symmetric similarity matrix for one spectral similarity metric.
 23
 24    Parameters
 25    ----------
 26    metric_name : str
 27        Name of the similarity metric stored in this matrix
 28        (e.g., ``"entropy_similarity"``, ``"cosine"``).
 29
 30    Attributes
 31    ----------
 32    metric_name : str
 33        Name of the similarity metric.
 34    _id_to_idx : dict
 35        Mapping from spectrum ID (str) to integer matrix index.
 36    _idx_to_id : list
 37        Mapping from integer matrix index to spectrum ID.
 38    _matrix : scipy.sparse.lil_matrix or None
 39        Underlying sparse matrix (lil for construction, csr after finalise).
 40    _is_csr : bool
 41        True when the matrix has been converted to CSR format.
 42    """
 43
 44    def __init__(self, metric_name: str = "entropy_similarity"):
 45        self.metric_name = metric_name
 46        self._id_to_idx: dict[str, int] = {}
 47        self._idx_to_id: list[str] = []
 48        self._matrix: lil_matrix | csr_matrix | None = None
 49        self._is_csr: bool = False
 50
 51    # ── Internal helpers ──────────────────────────────────────────────────────
 52
 53    def _ensure_lil(self):
 54        """Convert to LIL format if currently CSR (needed before adding entries)."""
 55        if self._is_csr and self._matrix is not None:
 56            self._matrix = self._matrix.tolil()
 57            self._is_csr = False
 58
 59    def _grow_matrix(self, new_size: int):
 60        """Grow the sparse matrix to accommodate *new_size* spectra.
 61
 62        Uses 1.5x growth factor to reduce reallocation frequency.
 63        """
 64        if self._matrix is None:
 65            # Pre-allocate with growth factor on first allocation
 66            alloc_size = max(new_size, int(new_size * 1.5))
 67            self._matrix = lil_matrix((alloc_size, alloc_size), dtype=np.float32)
 68            self._is_csr = False
 69        else:
 70            self._ensure_lil()
 71            old_size = self._matrix.shape[0]
 72            if new_size > old_size:
 73                # Grow with 1.5x factor to reduce future reallocations
 74                alloc_size = max(new_size, int(old_size * 1.5))
 75                new_mat = lil_matrix((alloc_size, alloc_size), dtype=np.float32)
 76                cx = self._matrix.tocsr()
 77                new_mat[:old_size, :old_size] = cx
 78                self._matrix = new_mat
 79
 80    # ── Public API ────────────────────────────────────────────────────────────
 81
 82    def register_spectra(self, spectrum_ids: list[str]):
 83        """Register new spectrum IDs, growing the matrix as needed.
 84
 85        Parameters
 86        ----------
 87        spectrum_ids : list of str
 88            IDs to register.  Already-registered IDs are silently skipped.
 89        """
 90        new_ids = [sid for sid in spectrum_ids if sid not in self._id_to_idx]
 91        if not new_ids:
 92            return
 93        for sid in new_ids:
 94            self._id_to_idx[sid] = len(self._idx_to_id)
 95            self._idx_to_id.append(sid)
 96        self._grow_matrix(len(self._idx_to_id))
 97
 98    def set_similarity(self, id1: str, id2: str, score: float):
 99        """Store a similarity score for a pair of spectra.
100
101        The matrix is symmetric: both (i, j) and (j, i) are set.
102
103        Parameters
104        ----------
105        id1, id2 : str
106            Spectrum IDs (must already be registered).
107        score : float
108            Similarity score to store.
109
110        Raises
111        ------
112        KeyError
113            If either ID has not been registered.
114        """
115        i = self._id_to_idx[id1]
116        j = self._id_to_idx[id2]
117        self._ensure_lil()
118        self._matrix[i, j] = score
119        self._matrix[j, i] = score
120
121    def get_similarity(self, id1: str, id2: str) -> float:
122        """Retrieve the similarity score for a pair of spectra.
123
124        Parameters
125        ----------
126        id1, id2 : str
127            Spectrum IDs.
128
129        Returns
130        -------
131        float
132            Similarity score, or 0.0 if the pair was never stored.
133        """
134        if id1 not in self._id_to_idx or id2 not in self._id_to_idx:
135            return 0.0
136        i = self._id_to_idx[id1]
137        j = self._id_to_idx[id2]
138        if self._matrix is None:
139            return 0.0
140        return float(self._matrix[i, j])
141
142    def finalise(self):
143        """Convert internal LIL matrix to CSR for efficient querying.
144
145        Call this after all ``set_similarity`` calls for a batch are done.
146        Trims matrix to logical size (removes over-allocated space).
147        """
148        if self._matrix is not None and not self._is_csr:
149            logical_size = len(self._idx_to_id)
150            # Trim to logical size if over-allocated
151            if self._matrix.shape[0] > logical_size:
152                self._matrix = self._matrix[:logical_size, :logical_size]
153            self._matrix = self._matrix.tocsr()
154            self._is_csr = True
155
156    def get_pairs_above_threshold(
157        self, threshold: float
158    ) -> list[tuple[str, str, float]]:
159        """Return all stored pairs with score >= *threshold*.
160
161        Parameters
162        ----------
163        threshold : float
164            Minimum score to include.
165
166        Returns
167        -------
168        list of (id1, id2, score)
169            Only the upper-triangle pairs are returned (no duplicates).
170        """
171        if self._matrix is None:
172            return []
173        mat = self._matrix.tocsr() if not self._is_csr else self._matrix
174        cx = mat.tocoo()
175        results = []
176        for i, j, v in zip(cx.row, cx.col, cx.data):
177            if i < j and v >= threshold:  # upper triangle only
178                results.append((self._idx_to_id[i], self._idx_to_id[j], float(v)))
179        return results
180
181    def to_dataframe(self, threshold: float = 0.0) -> pd.DataFrame:
182        """Return pairs above *threshold* as a pandas DataFrame.
183
184        Parameters
185        ----------
186        threshold : float
187            Minimum score to include.  Default 0.0 (all stored pairs).
188
189        Returns
190        -------
191        pandas.DataFrame
192            Columns: ``["id1", "id2", "score"]``.
193        """
194        pairs = self.get_pairs_above_threshold(threshold)
195        return pd.DataFrame(pairs, columns=["id1", "id2", "score"])
196
197    @property
198    def spectrum_ids(self) -> list[str]:
199        """List of registered spectrum IDs in index order."""
200        return list(self._idx_to_id)
201
202    @property
203    def n_spectra(self) -> int:
204        """Number of registered spectra."""
205        return len(self._idx_to_id)
206
207    # ── Persistence ───────────────────────────────────────────────────────────
208
209    def save(self, path: str):
210        """Save the matrix and ID mapping to a ``.npz`` file.
211
212        Parameters
213        ----------
214        path : str
215            Output file path (will be given ``.npz`` extension if absent).
216        """
217        self.finalise()
218        mat = self._matrix if self._matrix is not None else csr_matrix((0, 0))
219        save_npz(path, mat)
220        # Save ID list alongside
221        ids_path = str(path).replace(".npz", "") + "_ids.npy"
222        np.save(ids_path, np.array(self._idx_to_id, dtype=object))
223
224    @classmethod
225    def load(cls, path: str) -> "SimilarityMatrix":
226        """Load a SimilarityMatrix from a ``.npz`` file.
227
228        Parameters
229        ----------
230        path : str
231            Path to the ``.npz`` file saved by :meth:`save`.
232
233        Returns
234        -------
235        SimilarityMatrix
236        """
237        mat = load_npz(path)
238        ids_path = str(path).replace(".npz", "") + "_ids.npy"
239        ids = list(np.load(ids_path, allow_pickle=True))
240        obj = cls()
241        obj._matrix = mat.tocsr()
242        obj._is_csr = True
243        obj._idx_to_id = ids
244        obj._id_to_idx = {sid: i for i, sid in enumerate(ids)}
245        return obj
246
247    def __repr__(self) -> str:
248        n = self.n_spectra
249        nnz = self._matrix.nnz // 2 if self._matrix is not None else 0
250        return (
251            f"SimilarityMatrix(metric='{self.metric_name}', "
252            f"n_spectra={n}, n_pairs_stored={nnz})"
253        )

Sparse, symmetric similarity matrix for one spectral similarity metric.

Parameters
  • metric_name (str): Name of the similarity metric stored in this matrix (e.g., "entropy_similarity", "cosine").
Attributes
  • metric_name (str): Name of the similarity metric.
  • _id_to_idx (dict): Mapping from spectrum ID (str) to integer matrix index.
  • _idx_to_id (list): Mapping from integer matrix index to spectrum ID.
  • _matrix (scipy.sparse.lil_matrix or None): Underlying sparse matrix (lil for construction, csr after finalise).
  • _is_csr (bool): True when the matrix has been converted to CSR format.
SimilarityMatrix(metric_name: str = 'entropy_similarity')
44    def __init__(self, metric_name: str = "entropy_similarity"):
45        self.metric_name = metric_name
46        self._id_to_idx: dict[str, int] = {}
47        self._idx_to_id: list[str] = []
48        self._matrix: lil_matrix | csr_matrix | None = None
49        self._is_csr: bool = False
metric_name
def register_spectra(self, spectrum_ids: list[str]):
82    def register_spectra(self, spectrum_ids: list[str]):
83        """Register new spectrum IDs, growing the matrix as needed.
84
85        Parameters
86        ----------
87        spectrum_ids : list of str
88            IDs to register.  Already-registered IDs are silently skipped.
89        """
90        new_ids = [sid for sid in spectrum_ids if sid not in self._id_to_idx]
91        if not new_ids:
92            return
93        for sid in new_ids:
94            self._id_to_idx[sid] = len(self._idx_to_id)
95            self._idx_to_id.append(sid)
96        self._grow_matrix(len(self._idx_to_id))

Register new spectrum IDs, growing the matrix as needed.

Parameters
  • spectrum_ids (list of str): IDs to register. Already-registered IDs are silently skipped.
def set_similarity(self, id1: str, id2: str, score: float):
 98    def set_similarity(self, id1: str, id2: str, score: float):
 99        """Store a similarity score for a pair of spectra.
100
101        The matrix is symmetric: both (i, j) and (j, i) are set.
102
103        Parameters
104        ----------
105        id1, id2 : str
106            Spectrum IDs (must already be registered).
107        score : float
108            Similarity score to store.
109
110        Raises
111        ------
112        KeyError
113            If either ID has not been registered.
114        """
115        i = self._id_to_idx[id1]
116        j = self._id_to_idx[id2]
117        self._ensure_lil()
118        self._matrix[i, j] = score
119        self._matrix[j, i] = score

Store a similarity score for a pair of spectra.

The matrix is symmetric: both (i, j) and (j, i) are set.

Parameters
  • id1, id2 (str): Spectrum IDs (must already be registered).
  • score (float): Similarity score to store.
Raises
  • KeyError: If either ID has not been registered.
def get_similarity(self, id1: str, id2: str) -> float:
121    def get_similarity(self, id1: str, id2: str) -> float:
122        """Retrieve the similarity score for a pair of spectra.
123
124        Parameters
125        ----------
126        id1, id2 : str
127            Spectrum IDs.
128
129        Returns
130        -------
131        float
132            Similarity score, or 0.0 if the pair was never stored.
133        """
134        if id1 not in self._id_to_idx or id2 not in self._id_to_idx:
135            return 0.0
136        i = self._id_to_idx[id1]
137        j = self._id_to_idx[id2]
138        if self._matrix is None:
139            return 0.0
140        return float(self._matrix[i, j])

Retrieve the similarity score for a pair of spectra.

Parameters
  • id1, id2 (str): Spectrum IDs.
Returns
  • float: Similarity score, or 0.0 if the pair was never stored.
def finalise(self):
142    def finalise(self):
143        """Convert internal LIL matrix to CSR for efficient querying.
144
145        Call this after all ``set_similarity`` calls for a batch are done.
146        Trims matrix to logical size (removes over-allocated space).
147        """
148        if self._matrix is not None and not self._is_csr:
149            logical_size = len(self._idx_to_id)
150            # Trim to logical size if over-allocated
151            if self._matrix.shape[0] > logical_size:
152                self._matrix = self._matrix[:logical_size, :logical_size]
153            self._matrix = self._matrix.tocsr()
154            self._is_csr = True

Convert internal LIL matrix to CSR for efficient querying.

Call this after all set_similarity calls for a batch are done. Trims matrix to logical size (removes over-allocated space).

def get_pairs_above_threshold(self, threshold: float) -> list[tuple[str, str, float]]:
156    def get_pairs_above_threshold(
157        self, threshold: float
158    ) -> list[tuple[str, str, float]]:
159        """Return all stored pairs with score >= *threshold*.
160
161        Parameters
162        ----------
163        threshold : float
164            Minimum score to include.
165
166        Returns
167        -------
168        list of (id1, id2, score)
169            Only the upper-triangle pairs are returned (no duplicates).
170        """
171        if self._matrix is None:
172            return []
173        mat = self._matrix.tocsr() if not self._is_csr else self._matrix
174        cx = mat.tocoo()
175        results = []
176        for i, j, v in zip(cx.row, cx.col, cx.data):
177            if i < j and v >= threshold:  # upper triangle only
178                results.append((self._idx_to_id[i], self._idx_to_id[j], float(v)))
179        return results

Return all stored pairs with score >= threshold.

Parameters
  • threshold (float): Minimum score to include.
Returns
  • list of (id1, id2, score): Only the upper-triangle pairs are returned (no duplicates).
def to_dataframe(self, threshold: float = 0.0) -> pandas.core.frame.DataFrame:
181    def to_dataframe(self, threshold: float = 0.0) -> pd.DataFrame:
182        """Return pairs above *threshold* as a pandas DataFrame.
183
184        Parameters
185        ----------
186        threshold : float
187            Minimum score to include.  Default 0.0 (all stored pairs).
188
189        Returns
190        -------
191        pandas.DataFrame
192            Columns: ``["id1", "id2", "score"]``.
193        """
194        pairs = self.get_pairs_above_threshold(threshold)
195        return pd.DataFrame(pairs, columns=["id1", "id2", "score"])

Return pairs above threshold as a pandas DataFrame.

Parameters
  • threshold (float): Minimum score to include. Default 0.0 (all stored pairs).
Returns
  • pandas.DataFrame: Columns: ["id1", "id2", "score"].
spectrum_ids: list[str]
197    @property
198    def spectrum_ids(self) -> list[str]:
199        """List of registered spectrum IDs in index order."""
200        return list(self._idx_to_id)

List of registered spectrum IDs in index order.

n_spectra: int
202    @property
203    def n_spectra(self) -> int:
204        """Number of registered spectra."""
205        return len(self._idx_to_id)

Number of registered spectra.

def save(self, path: str):
209    def save(self, path: str):
210        """Save the matrix and ID mapping to a ``.npz`` file.
211
212        Parameters
213        ----------
214        path : str
215            Output file path (will be given ``.npz`` extension if absent).
216        """
217        self.finalise()
218        mat = self._matrix if self._matrix is not None else csr_matrix((0, 0))
219        save_npz(path, mat)
220        # Save ID list alongside
221        ids_path = str(path).replace(".npz", "") + "_ids.npy"
222        np.save(ids_path, np.array(self._idx_to_id, dtype=object))

Save the matrix and ID mapping to a .npz file.

Parameters
  • path (str): Output file path (will be given .npz extension if absent).
@classmethod
def load( cls, path: str) -> SimilarityMatrix:
224    @classmethod
225    def load(cls, path: str) -> "SimilarityMatrix":
226        """Load a SimilarityMatrix from a ``.npz`` file.
227
228        Parameters
229        ----------
230        path : str
231            Path to the ``.npz`` file saved by :meth:`save`.
232
233        Returns
234        -------
235        SimilarityMatrix
236        """
237        mat = load_npz(path)
238        ids_path = str(path).replace(".npz", "") + "_ids.npy"
239        ids = list(np.load(ids_path, allow_pickle=True))
240        obj = cls()
241        obj._matrix = mat.tocsr()
242        obj._is_csr = True
243        obj._idx_to_id = ids
244        obj._id_to_idx = {sid: i for i, sid in enumerate(ids)}
245        return obj

Load a SimilarityMatrix from a .npz file.

Parameters
  • path (str): Path to the .npz file saved by save().
Returns
  • SimilarityMatrix
class SimilarityEngine:
 167class SimilarityEngine:
 168    """Compute pairwise spectral similarities using FlashEntropy + optional extras.
 169
 170    Parameters
 171    ----------
 172    fe_lib : ms_entropy.FlashEntropySearch or None
 173        Pre-built FlashEntropy search instance.  Pass ``None`` for
 174        query-only mode (only :meth:`compute_all_vs_all_with_lib` with a
 175        temporary index is used; query-vs-library methods are unavailable).
 176        When provided, fragment-tolerance parameters are extracted from this
 177        library unless overridden by *ms2_tolerance_da*.
 178    search_type : str
 179        FlashEntropy search mode: ``"identity"``, ``"open"``, or
 180        ``"neutral_loss"``.  Default ``"open"`` (matches typical DDA
 181        molecular-networking use; use ``"identity"`` when precursor
 182        filtering is required).
 183    additional_similarities : list of str, optional
 184        Extra similarity metrics to compute for pairs that pass the entropy
 185        threshold.  Currently supported: ``["cosine"]``.
 186        Default ``["cosine"]``.  Cosine is a best-effort peak-aligned
 187        score gated by *entropy_threshold_low* (not a second primary engine).
 188    ms1_tolerance_da : float, optional
 189        Precursor m/z tolerance (Da) used **only** for ``"identity"`` search
 190        to filter library candidates by precursor m/z.  Ignored for
 191        ``"open"`` and ``"neutral_loss"`` (precursor filter is disabled for
 192        those modes).  If ``None`` (default), uses 0.01 Da.
 193    ms2_tolerance_da : float, optional
 194        Fragment m/z tolerance (Da) for spectrum cleaning and similarity
 195        scoring.  Resolution priority: explicit kwarg > extracted from
 196        *fe_lib* > 0.01 Da fallback.
 197    entropy_threshold_low : float or None, optional
 198        Minimum entropy similarity score required to trigger additional
 199        metric computation.  If None and *settings* does not supply a value,
 200        default is 0.1.
 201    settings : SpectralSimilaritySearchSettings, optional
 202        Encapsulated spectral-similarity defaults (for example
 203        ``lcms.parameters.mass_spectrum["ms2"].spectral_similarity_search``).
 204        When provided, fills any constructor fields left as ``None``.
 205        Explicit keyword arguments always override *settings*.
 206
 207    Attributes
 208    ----------
 209    ms2_tolerance_da : float
 210        Fragment m/z tolerance resolved at construction time.
 211    peak_sep_da : float
 212        Minimum peak separation used during spectrum cleaning
 213        (``2 * ms2_tolerance_da``).
 214    ms1_tolerance_da : float
 215        Precursor m/z tolerance for identity search.
 216    entropy_threshold_low : float
 217        Low-entropy gate for triggering additional metric computation.
 218
 219    Notes
 220    -----
 221    **Tolerance resolution order** (highest priority first):
 222
 223    1. Explicit *ms2_tolerance_da* kwarg.
 224    2. *settings.ms2_tolerance_da* when *settings* is provided and the kwarg
 225       is omitted.
 226    3. ``fe_lib.entropy_search.max_ms2_tolerance_in_da`` (when *fe_lib* is
 227       not ``None``).
 228    4. Hard-coded fallback of 0.01 Da.
 229
 230    **Precursor filter behaviour by search type:**
 231
 232    - ``"identity"`` : library candidates are filtered by precursor m/z
 233      within *ms1_tolerance_da* before scoring.
 234    - ``"open"`` : precursor filter is disabled (``ms1_tolerance_in_da``
 235      passed to FlashEntropy is set to ``1e9``).
 236    - ``"neutral_loss"`` : precursor filter is disabled for the same reason
 237      as ``"open"``; matching occurs in neutral-loss mass space
 238      (``precursor_mz − fragment_mz``), not on the precursor itself.
 239
 240    Cosine (when requested) runs **single-threaded**: unique spectra are
 241    pre-sorted once, then pairs are scored sequentially via
 242    :func:`_align_and_compute_cosine_presorted`.
 243    """
 244
 245    def __init__(
 246        self,
 247        fe_lib,
 248        search_type: str | None = None,
 249        additional_similarities: list[str] | None = None,
 250        ms1_tolerance_da: float | None = None,
 251        ms2_tolerance_da: float | None = None,
 252        entropy_threshold_low: float | None = None,
 253        settings=None,
 254    ):
 255        if settings is not None:
 256            if search_type is None:
 257                search_type = settings.search_type
 258            if additional_similarities is None:
 259                additional_similarities = list(settings.additional_similarities)
 260            if ms1_tolerance_da is None:
 261                ms1_tolerance_da = settings.ms1_tolerance_da
 262            if ms2_tolerance_da is None:
 263                ms2_tolerance_da = settings.ms2_tolerance_da
 264            if (
 265                entropy_threshold_low is None
 266                and settings.entropy_threshold_low is not None
 267            ):
 268                entropy_threshold_low = settings.entropy_threshold_low
 269            self._settings = settings
 270        else:
 271            self._settings = None
 272
 273        if search_type is None:
 274            search_type = "open"
 275        if search_type not in _FE_METHOD_MAP:
 276            raise ValueError(
 277                f"search_type must be one of {list(_FE_METHOD_MAP.keys())}, "
 278                f"got '{search_type}'."
 279            )
 280        if additional_similarities is None:
 281            additional_similarities = ["cosine"]
 282        unsupported = set(additional_similarities) - _SUPPORTED_ADDITIONAL
 283        if unsupported:
 284            raise ValueError(
 285                f"Unsupported additional_similarities: {unsupported}. "
 286                f"Supported: {_SUPPORTED_ADDITIONAL}"
 287            )
 288
 289        self.fe_lib = fe_lib
 290        self.search_type = search_type
 291        self.additional_similarities = list(additional_similarities)
 292
 293        # Tolerance priority: explicit kwarg > fe_lib extraction > 0.01 fallback.
 294        # When fe_lib is None (queries-only mode), a temporary FE index is built
 295        # from the query spectra themselves; ms2_tolerance_da controls that index.
 296        if ms2_tolerance_da is not None:
 297            self.ms2_tolerance_da = ms2_tolerance_da
 298        elif fe_lib is not None:
 299            self.ms2_tolerance_da = fe_lib.entropy_search.max_ms2_tolerance_in_da
 300        else:
 301            self.ms2_tolerance_da = 0.01
 302        self.peak_sep_da = 2 * self.ms2_tolerance_da
 303        self.ms1_tolerance_da = (
 304            ms1_tolerance_da if ms1_tolerance_da is not None else 0.01
 305        )
 306
 307        self.entropy_threshold_low = (
 308            entropy_threshold_low if entropy_threshold_low is not None else 0.1
 309        )
 310
 311    # ── Internal helpers ──────────────────────────────────────────────────────
 312
 313    def _peaks_array(self, spectrum) -> np.ndarray:
 314        """Return (N, 2) peaks array from a spectrum object."""
 315        mz = np.asarray(spectrum.mz_exp, dtype=float)
 316        ab = np.asarray(spectrum.abundance, dtype=float)
 317        return np.column_stack((mz, ab))
 318
 319    def _require_precursor_mz(
 320        self,
 321        precursor_mz: float | None,
 322        context: str,
 323    ) -> float:
 324        """Validate precursor m/z and return it as float."""
 325        if precursor_mz is None:
 326            raise ValueError(
 327                f"{context} is missing precursor_mz, which is required for "
 328                f"search_type='{self.search_type}'."
 329            )
 330
 331        precursor = float(precursor_mz)
 332        if not np.isfinite(precursor):
 333            raise ValueError(f"{context} has non-finite precursor_mz={precursor_mz!r}.")
 334        return precursor
 335
 336    def _transform_to_neutral_loss_space(
 337        self,
 338        peaks: np.ndarray,
 339        precursor_mz: float | None,
 340        context: str,
 341    ) -> np.ndarray:
 342        """Transform peaks from fragment m/z to neutral-loss mass space.
 343
 344        Neutral-loss mass is computed as:
 345            neutral_loss_mz = precursor_mz - fragment_mz
 346        """
 347        precursor = self._require_precursor_mz(precursor_mz, context)
 348        peaks = np.asarray(peaks, dtype=float)
 349
 350        if peaks.ndim != 2 or peaks.shape[1] < 2:
 351            raise ValueError(
 352                f"{context} peaks must be a 2D array with at least 2 columns; "
 353                f"got shape={peaks.shape}."
 354            )
 355
 356        if peaks.shape[0] == 0:
 357            return peaks.copy()
 358
 359        # Neutral-loss masses are only valid for fragment_mz <= precursor_mz.
 360        # Drop invalid fragment peaks to mirror the neutral-loss search domain.
 361        valid_mask = peaks[:, 0] <= precursor
 362        if not np.any(valid_mask):
 363            return np.empty((0, peaks.shape[1]), dtype=float)
 364
 365        nl_peaks = peaks[valid_mask].copy()
 366        nl_peaks[:, 0] = precursor - nl_peaks[:, 0]
 367
 368        # Keep deterministic ordering before downstream alignment.
 369        sort_idx = np.argsort(nl_peaks[:, 0])
 370        return nl_peaks[sort_idx]
 371
 372    def _clean_and_search(
 373        self,
 374        peaks: np.ndarray,
 375        precursor_mz: float | None,
 376        fe_lib_override=None,
 377    ) -> np.ndarray:
 378        """Run FlashEntropy search for one query spectrum.
 379
 380        Passes raw peaks directly to FlashEntropy's ``search()`` method,
 381        which cleans the spectrum internally.  This avoids double-cleaning
 382        and ensures the query is processed identically to library entries.
 383
 384        **Precursor filter behaviour:**
 385
 386        - ``"identity"`` : ``ms1_tolerance_in_da`` is set to
 387          :attr:`ms1_tolerance_da` so only library entries within that
 388          precursor window are scored.
 389        - ``"open"`` and ``"neutral_loss"`` : ``ms1_tolerance_in_da`` is
 390          set to ``1e9`` (effectively no filter).  For ``"neutral_loss"``,
 391          matching occurs in neutral-loss mass space internally within
 392          FlashEntropy, so filtering on precursor m/z would incorrectly
 393          exclude valid matches.
 394
 395        For ``"neutral_loss"``, fragment peaks with m/z > precursor m/z are
 396        stripped before the search call (they have no valid neutral-loss
 397        representation).
 398
 399        Parameters
 400        ----------
 401        peaks : np.ndarray of shape (N, 2)
 402            Raw ``[[mz, abundance], ...]`` array.  FlashEntropy cleans this
 403            internally (normalisation, denoising, peak separation).
 404        precursor_mz : float or None
 405            Required for ``"identity"`` and ``"neutral_loss"``; a dummy
 406            value of 0.0 is used for ``"open"`` when ``None`` is passed.
 407        fe_lib_override : ms_entropy.FlashEntropySearch, optional
 408            If provided, search against this library instead of
 409            ``self.fe_lib``.  Used for query-vs-query searches where a
 410            temporary index is built from the query spectra themselves.
 411
 412        Returns
 413        -------
 414        np.ndarray
 415            1-D array of entropy similarity scores, one per library entry.
 416            Returns a zero array if the query has no valid peaks after
 417            pre-filtering.
 418        """
 419        fe_method, fe_key = _FE_METHOD_MAP[self.search_type]
 420        fe = fe_lib_override if fe_lib_override is not None else self.fe_lib
 421
 422        if self.search_type in ("identity", "neutral_loss"):
 423            pmz = self._require_precursor_mz(precursor_mz, "Query spectrum")
 424        else:
 425            # Use a dummy precursor_mz for open search
 426            pmz = precursor_mz if precursor_mz is not None else 0.0
 427
 428        peaks_for_search = peaks
 429        if self.search_type == "neutral_loss":
 430            peaks_for_search = np.asarray(peaks, dtype=float)
 431            if peaks_for_search.ndim != 2 or peaks_for_search.shape[1] < 2:
 432                raise ValueError(
 433                    f"Query spectrum peaks must be 2D with at least 2 columns for neutral_loss search; "
 434                    f"got shape={peaks_for_search.shape}."
 435                )
 436            peaks_for_search = peaks_for_search[peaks_for_search[:, 0] <= pmz]
 437            if peaks_for_search.shape[0] == 0:
 438                try:
 439                    lib_size = len(fe)
 440                except TypeError:
 441                    lib_size = len(getattr(fe, "precursor_mz_array", []))
 442                return np.zeros(lib_size, dtype=float)
 443
 444        # Pass raw peaks to search() - it will clean them internally
 445        # This ensures consistent cleaning between library and query spectra
 446        search_kwargs: dict[str, Any] = dict(
 447            peaks=peaks_for_search,  # Raw peaks - search() will clean them
 448            ms2_tolerance_in_da=self.ms2_tolerance_da,
 449            method={fe_method},
 450            precursor_ions_removal_da=None,
 451            noise_threshold=0.0,  # TODO: get this as an attribute from FE as well
 452            min_ms2_difference_in_da=self.peak_sep_da,
 453            target="cpu",
 454        )
 455        if self.search_type == "identity":
 456            search_kwargs["precursor_mz"] = pmz
 457            search_kwargs["ms1_tolerance_in_da"] = self.ms1_tolerance_da
 458        else:
 459            # open search and neutral_loss: precursor_mz still required by ms_entropy
 460            # API but precursor filtering is disabled (1e9 = no filter).
 461            # For neutral_loss, matching occurs on neutral-loss fragment space, not
 462            # on precursor m/z, so the precursor filter must be disabled.
 463            search_kwargs["precursor_mz"] = pmz
 464            search_kwargs["ms1_tolerance_in_da"] = 1e9  # effectively no filter
 465
 466        results = fe.search(**search_kwargs)
 467        return results[fe_key]
 468
 469    def _compute_cosine_for_query_vs_library(
 470        self,
 471        query_spectrum,
 472        query_precursor_mz: float | None,
 473        library_indices: list[int],
 474    ) -> dict[int, float]:
 475        """Compute cosine similarity for one query against multiple library spectra.
 476
 477        Extracts cleaned peaks directly from the FlashEntropy library to ensure
 478        consistency with entropy similarity calculations.  For
 479        ``"neutral_loss"`` search, both query and library peaks are
 480        transformed to neutral-loss mass space before alignment.
 481
 482        Parameters
 483        ----------
 484        query_spectrum : spectrum object
 485            Query spectrum with ``.mz_exp`` and ``.abundance`` attributes.
 486        query_precursor_mz : float or None
 487            Precursor m/z for the query.  Required for ``"neutral_loss"``
 488            search; used as a dummy 0.0 for ``"open"`` when ``None``.
 489        library_indices : list of int
 490            Indices into ``self.fe_lib`` of the library spectra to score.
 491
 492        Returns
 493        -------
 494        dict mapping int → float
 495            ``{library_idx: cosine_score}`` for all pairs with score > 0.
 496        """
 497        # Get and clean query peaks (same as FlashEntropy does)
 498        query_peaks = self._peaks_array(query_spectrum)
 499        if query_peaks.shape[0] == 0:
 500            return {}
 501
 502        pmz = query_precursor_mz if query_precursor_mz is not None else 0.0
 503        cleaned_query = self.fe_lib.clean_spectrum_for_search(
 504            precursor_mz=pmz,
 505            peaks=query_peaks,
 506            precursor_ions_removal_da=None,
 507            noise_threshold=0.0,
 508            min_ms2_difference_in_da=self.peak_sep_da,
 509        )
 510        cleaned_query = np.asarray(cleaned_query, dtype=float)
 511
 512        if cleaned_query.shape[0] == 0:
 513            return {}
 514
 515        if self.search_type == "neutral_loss":
 516            cleaned_query = self._transform_to_neutral_loss_space(
 517                cleaned_query,
 518                query_precursor_mz,
 519                "Query spectrum",
 520            )
 521
 522        # Sort query once; each library spectrum is sorted once before cosine
 523        query_mz_sorted, query_abun_sorted = _sort_peaks_by_mz(
 524            cleaned_query[:, 0], cleaned_query[:, 1]
 525        )
 526
 527        cosine_scores = {}
 528        for lib_idx in library_indices:
 529            # Extract cleaned peaks from FE library (same as entropy similarity uses)
 530            try:
 531                lib_entry = self.fe_lib[lib_idx]
 532                lib_peaks = np.asarray(lib_entry["peaks"], dtype=float)
 533            except (IndexError, KeyError, TypeError):
 534                continue
 535
 536            if self.search_type == "neutral_loss":
 537                lib_peaks = self._transform_to_neutral_loss_space(
 538                    lib_peaks,
 539                    lib_entry.get("precursor_mz"),
 540                    f"Library spectrum at index {lib_idx}",
 541                )
 542
 543            if lib_peaks.ndim != 2 or lib_peaks.shape[1] < 2 or lib_peaks.shape[0] == 0:
 544                continue
 545
 546            lib_mz_sorted, lib_abun_sorted = _sort_peaks_by_mz(
 547                lib_peaks[:, 0], lib_peaks[:, 1]
 548            )
 549
 550            cosine = _align_and_compute_cosine_presorted(
 551                query_mz_sorted,
 552                query_abun_sorted,
 553                lib_mz_sorted,
 554                lib_abun_sorted,
 555                self.ms2_tolerance_da,
 556            )
 557            if cosine > 0.0:
 558                cosine_scores[lib_idx] = cosine
 559
 560        return cosine_scores
 561
 562    def _compute_entropy_matrix_and_pairs(
 563        self,
 564        spectra: list,
 565        spectrum_ids: list[str],
 566        precursor_mzs: list[float | None],
 567        fe_lib_override=None,
 568    ) -> tuple[dict[tuple[str, str], float], list[tuple[int, int]]]:
 569        """Compute entropy similarity and extract upper-triangle pairs.
 570
 571        Uses FlashEntropy's built-in search to efficiently compute pairwise
 572        similarities. Each spectrum is searched once against the library,
 573        and pairwise scores are extracted from the result vectors.
 574
 575        Streams results directly to sparse storage without building dense matrix.
 576
 577        Parameters
 578        ----------
 579        spectra : list
 580            Spectrum objects with .mz_exp and .abundance attributes.
 581        spectrum_ids : list of str
 582            User-provided IDs, one per spectrum.
 583        precursor_mzs : list of float or None
 584            Precursor m/z for each spectrum.
 585        fe_lib_override : FlashEntropySearch, optional
 586            FlashEntropy library to search against. If None, uses self.fe_lib.
 587
 588        Returns
 589        -------
 590        entropy_pairs : dict mapping (id1, id2) → score
 591            Upper-triangle pairs with score > 0.0
 592        pairs_for_additional : list of (i, j)
 593            Index pairs with score >= entropy_threshold_low (for additional metrics)
 594        """
 595        n = len(spectra)
 596        fe = fe_lib_override if fe_lib_override is not None else self.fe_lib
 597
 598        # Stream results directly to sparse storage (no dense intermediate)
 599        entropy_pairs: dict[tuple[str, str], float] = {}
 600        pairs_for_additional: list[tuple[int, int]] = []
 601
 602        for i, (spec, pmz) in enumerate(zip(spectra, precursor_mzs)):
 603            peaks = self._peaks_array(spec)
 604            if peaks.shape[0] == 0:
 605                continue
 606            # Search against the FE library
 607            result_vec = self._clean_and_search(peaks, pmz, fe_lib_override=fe)
 608
 609            if result_vec is None or len(result_vec) == 0:
 610                continue
 611
 612            # Extract all pairs from result_vec, but only store upper-triangle (j > i)
 613            # result_vec[j] contains the similarity score between spectrum i and library entry j
 614            # When searching within same library (all-vs-all), result_vec has n entries
 615            for j in range(min(len(result_vec), n)):
 616                if j <= i:
 617                    # Skip lower triangle and diagonal to avoid duplicates
 618                    continue
 619                score = float(result_vec[j])
 620                if score > 0.0:
 621                    entropy_pairs[(spectrum_ids[i], spectrum_ids[j])] = score
 622                    if score >= self.entropy_threshold_low:
 623                        pairs_for_additional.append((i, j))
 624
 625        return entropy_pairs, pairs_for_additional
 626
 627    def _compute_cosine_for_pairs(
 628        self,
 629        pairs: list[tuple[int, int]],
 630        lib_indices: list[int | None],
 631        fe_lib_override=None,
 632    ) -> dict[tuple[int, int], float]:
 633        """Compute cosine similarity for a list of (i, j) index pairs.
 634
 635        Extracts cleaned peaks directly from the FlashEntropy library for all
 636        unique spectrum indices referenced in the pairs list.
 637
 638        Parameters
 639        ----------
 640        pairs : list of (i, j)
 641            Index pairs. Both i and j are indices into the lib_indices list.
 642        lib_indices : list of int or None
 643            FlashEntropy library indices. For each index in pairs, lib_indices[index]
 644            gives the FE library position. Cleaned peaks are extracted via
 645            fe_lib[lib_indices[idx]]["peaks"]. All spectra must be indexed.
 646        fe_lib_override : FlashEntropySearch, optional
 647            FlashEntropy library to use. If None, uses self.fe_lib.
 648
 649        Returns
 650        -------
 651        dict mapping (i, j) → cosine score
 652        """
 653        if not pairs:
 654            return {}
 655
 656        # Get FE library
 657        fe = fe_lib_override if fe_lib_override is not None else self.fe_lib
 658
 659        # Extract unique indices from all pairs
 660        unique_indices = {i for i, _ in pairs} | {j for _, j in pairs}
 661
 662        # Extract cleaned peaks once per unique spectrum and sort by m/z once
 663        # so pair scoring can use _align_and_compute_cosine_presorted.
 664        sorted_peaks: dict[int, tuple[np.ndarray, np.ndarray]] = {}
 665        for idx in unique_indices:
 666            if lib_indices[idx] is not None:
 667                spec_dict = fe[lib_indices[idx]]
 668                peaks = np.asarray(spec_dict["peaks"], dtype=float)
 669                if peaks.ndim != 2 or peaks.shape[1] < 2:
 670                    raise ValueError(
 671                        f"Spectrum at library index {lib_indices[idx]} has invalid peaks shape={peaks.shape}."
 672                    )
 673
 674                if self.search_type == "neutral_loss":
 675                    peaks = self._transform_to_neutral_loss_space(
 676                        peaks,
 677                        spec_dict.get("precursor_mz"),
 678                        f"Library spectrum at index {lib_indices[idx]}",
 679                    )
 680
 681                mz_s, ab_s = _sort_peaks_by_mz(peaks[:, 0], peaks[:, 1])
 682                sorted_peaks[idx] = (mz_s, ab_s)
 683            else:
 684                raise ValueError(
 685                    f"Spectrum index {idx} has no library index. "
 686                    "All spectra must be indexed in FlashEntropy library."
 687                )
 688
 689        # Score pairs sequentially with pre-sorted peak arrays
 690        cosine_scores: dict[tuple[int, int], float] = {}
 691        tol = self.ms2_tolerance_da
 692        for i, j in pairs:
 693            mz_i, ab_i = sorted_peaks[i]
 694            mz_j, ab_j = sorted_peaks[j]
 695            cosine_scores[(i, j)] = _align_and_compute_cosine_presorted(
 696                mz_i, ab_i, mz_j, ab_j, tol
 697            )
 698
 699        return cosine_scores
 700
 701    # ── Public API ────────────────────────────────────────────────────────────
 702
 703    def search_queries_against_library(
 704        self,
 705        query_spectra: list,
 706        query_ids: list[str],
 707        query_precursor_mzs: list[float | None] | None = None,
 708        *,
 709        format_library_id: Callable[[int], str] | None = None,
 710    ) -> tuple[dict[str, dict[tuple[str, str], float]], int]:
 711        """Search each query spectrum against ``self.fe_lib`` (query–library).
 712
 713        Computes entropy similarity for all library hits with score > 0 and,
 714        when configured, cosine for pairs at or above
 715        :attr:`entropy_threshold_low`.
 716
 717        Parameters
 718        ----------
 719        query_spectra : list
 720            Spectrum objects with ``.mz_exp`` and ``.abundance``.
 721        query_ids : list of str
 722            Unique ID for each query (same length as *query_spectra*).
 723        query_precursor_mzs : list of float or None, optional
 724            Precursor m/z per query.  Required for ``"identity"`` and
 725            ``"neutral_loss"``; optional for ``"open"``.
 726        format_library_id : callable, optional
 727            Maps library index → node ID string used in pair keys.
 728            Default ``str`` (bare index).  Callers that need non-colliding
 729            IDs (e.g. ``MolecularNetwork.library_node_id``) should pass a
 730            formatter.
 731
 732        Returns
 733        -------
 734        scores : dict
 735            ``{metric_name: {(query_id, library_id): score}}`` with
 736            ``"entropy_similarity"`` and optionally ``"cosine"``.
 737        library_size : int
 738            Length of the FlashEntropy result vector (library size used for
 739            registration / indexing).  ``0`` if no successful searches ran.
 740
 741        Raises
 742        ------
 743        RuntimeError
 744            If ``self.fe_lib`` is ``None``.
 745        ValueError
 746            If lengths of *query_ids* / *query_precursor_mzs* do not match
 747            *query_spectra*.
 748        """
 749        if self.fe_lib is None:
 750            raise RuntimeError(
 751                "search_queries_against_library requires a reference FE library "
 752                "(fe_lib)."
 753            )
 754
 755        n_query = len(query_spectra)
 756        if len(query_ids) != n_query:
 757            raise ValueError(
 758                f"query_ids length ({len(query_ids)}) must match "
 759                f"query_spectra length ({n_query})."
 760            )
 761        if query_precursor_mzs is None:
 762            query_precursor_mzs = [None] * n_query
 763        elif len(query_precursor_mzs) != n_query:
 764            raise ValueError(
 765                f"query_precursor_mzs length ({len(query_precursor_mzs)}) must match "
 766                f"query_spectra length ({n_query})."
 767            )
 768
 769        id_fmt = format_library_id if format_library_id is not None else str
 770
 771        entropy_pairs: dict[tuple[str, str], float] = {}
 772        cosine_pairs: dict[tuple[str, str], float] = {}
 773        query_to_lib_indices: dict[int, list[int]] = {}
 774        library_size = 0
 775
 776        for qi, (spec, pmz) in enumerate(zip(query_spectra, query_precursor_mzs)):
 777            peaks = self._peaks_array(spec)
 778            if peaks.shape[0] == 0:
 779                continue
 780            result_vec = self._clean_and_search(peaks, pmz)
 781            if result_vec is None:
 782                continue
 783            library_size = max(library_size, len(result_vec))
 784
 785            lib_indices_for_query: list[int] = []
 786            for lib_idx, score in enumerate(result_vec):
 787                if score > 0.0:
 788                    entropy_pairs[(query_ids[qi], id_fmt(lib_idx))] = float(score)
 789                    if score >= self.entropy_threshold_low:
 790                        lib_indices_for_query.append(lib_idx)
 791
 792            if lib_indices_for_query:
 793                query_to_lib_indices[qi] = lib_indices_for_query
 794
 795        if "cosine" in self.additional_similarities and query_to_lib_indices:
 796            for qi, lib_indices in query_to_lib_indices.items():
 797                cosine_scores = self._compute_cosine_for_query_vs_library(
 798                    query_spectrum=query_spectra[qi],
 799                    query_precursor_mz=query_precursor_mzs[qi],
 800                    library_indices=lib_indices,
 801                )
 802                for lib_idx, score in cosine_scores.items():
 803                    cosine_pairs[(query_ids[qi], id_fmt(lib_idx))] = score
 804
 805        scores: dict[str, dict[tuple[str, str], float]] = {
 806            "entropy_similarity": entropy_pairs
 807        }
 808        if cosine_pairs:
 809            scores["cosine"] = cosine_pairs
 810
 811        return scores, library_size
 812
 813    def build_fe_index_from_spectra(
 814        self,
 815        spectra: list,
 816        precursor_mzs: list[float | None] | None = None,
 817        fe_kwargs: dict | None = None,
 818    ):
 819        """Build a FlashEntropy search index from a list of spectrum objects.
 820
 821        This is used to create a temporary index from query spectra so that
 822        query-vs-query similarities can be computed without requiring the
 823        spectra to already be in the main library.
 824
 825        Parameters
 826        ----------
 827        spectra : list
 828            Spectrum objects with ``.mz_exp`` and ``.abundance`` attributes.
 829        precursor_mzs : list of float or None, optional
 830            Precursor m/z for each spectrum.  Used as the ``precursor_mz``
 831            field in the FlashEntropy library.  Defaults to 0.0 for each.
 832        fe_kwargs : dict, optional
 833            Extra keyword arguments forwarded to ``FlashEntropySearch``.
 834            Defaults to the same settings used for the main library.
 835
 836        Returns
 837        -------
 838        ms_entropy.FlashEntropySearch
 839            A new FlashEntropy search instance indexed on *spectra*.
 840        """
 841        try:
 842            from ms_entropy import FlashEntropySearch
 843        except ImportError:
 844            raise ImportError(
 845                "ms_entropy is required for build_fe_index_from_spectra(). "
 846                "Install with: pip install ms_entropy"
 847            )
 848
 849        if precursor_mzs is None:
 850            precursor_mzs = [None] * len(spectra)
 851
 852        # Default FE kwargs mirror the main library settings
 853        # Extract build_index parameters from the main FE library if available
 854        # (these are stored as custom attributes by _build_flash_entropy_index)
 855        default_fe_kwargs = {
 856            "normalize_intensity": True,
 857            "min_ms2_difference_in_da": self.peak_sep_da,  # Already 2x tolerance from extraction
 858            "max_ms2_tolerance_in_da": self.ms2_tolerance_da,
 859            "max_indexed_mz": getattr(self.fe_lib, "_build_max_indexed_mz", 3000),
 860            "precursor_ions_removal_da": getattr(
 861                self.fe_lib, "_build_precursor_ions_removal_da", None
 862            ),
 863            "noise_threshold": getattr(self.fe_lib, "_build_noise_threshold", 0),
 864        }
 865        if fe_kwargs:
 866            default_fe_kwargs.update(fe_kwargs)
 867
 868        # Build the spectral library list
 869        spectral_library = []
 870        for i, (spec, pmz) in enumerate(zip(spectra, precursor_mzs)):
 871            peaks = self._peaks_array(spec)
 872            if peaks.shape[0] == 0:
 873                continue
 874            spectral_library.append(
 875                {
 876                    "id": i,
 877                    "precursor_mz": float(pmz) if pmz is not None else 0.0,
 878                    "peaks": peaks.tolist(),
 879                }
 880            )
 881
 882        # Match the FlashEntropy constructor/init and build_index signature
 883        fe_init_kws = [
 884            "max_ms2_tolerance_in_da",
 885            "mz_index_step",
 886            "low_memory",
 887            "path_data",
 888        ]
 889        fe_init_kws = {k: v for k, v in default_fe_kwargs.items() if k in fe_init_kws}
 890
 891        fe = FlashEntropySearch(**fe_init_kws)
 892
 893        fe_index_kws = [
 894            "max_indexed_mz",
 895            "precursor_ions_removal_da",
 896            "noise_threshold",
 897            "min_ms2_difference_in_da",
 898            "max_peak_num",
 899        ]
 900        fe_index_kws = {k: v for k, v in default_fe_kwargs.items() if k in fe_index_kws}
 901
 902        fe.build_index(spectral_library, **fe_index_kws, clean_spectra=True)
 903        return fe
 904
 905    def compute_library_vs_library_filtered(
 906        self,
 907        library_indices: list[int],
 908        spectrum_ids: list[str],
 909        precursor_mzs: list[float | None] | None = None,
 910    ) -> dict[str, dict[tuple[str, str], float]]:
 911        """Compute library-vs-library similarities for a filtered subset of library spectra.
 912
 913        Extracts the spectra at *library_indices* from ``self.fe_lib``, builds a
 914        temporary FlashEntropy index from them, and computes all-vs-all pairwise
 915        similarities within that subset.
 916
 917        Parameters
 918        ----------
 919        library_indices : list of int
 920            Indices into ``self.fe_lib`` of the library spectra to include.
 921        spectrum_ids : list of str
 922            IDs to assign to each library spectrum (must be same length as
 923            *library_indices*).
 924        precursor_mzs : list of float or None, optional
 925            Precursor m/z for each library spectrum.  If None, attempts to read
 926            from ``self.fe_lib`` (attribute ``precursor_mz`` on each entry), or
 927            defaults to 0.0.
 928
 929        Returns
 930        -------
 931        dict
 932            ``{metric_name: {(id1, id2): score}}``
 933            All-vs-all pairs within the filtered library subset.
 934        """
 935        n = len(library_indices)
 936        if n == 0:
 937            return {}
 938
 939        if self.fe_lib is None:
 940            raise RuntimeError(
 941                "compute_library_vs_library_filtered requires a reference FE library "
 942                "(fe_lib)."
 943            )
 944
 945        if len(spectrum_ids) != n:
 946            raise ValueError(
 947                f"spectrum_ids length ({len(spectrum_ids)}) must match "
 948                f"library_indices length ({n})."
 949            )
 950        if precursor_mzs is not None and len(precursor_mzs) != n:
 951            raise ValueError(
 952                f"precursor_mzs length ({len(precursor_mzs)}) must match "
 953                f"library_indices length ({n})."
 954            )
 955
 956        # Build lightweight spectrum objects from the raw library entries
 957        class _LibSpec:
 958            __slots__ = ("mz_exp", "abundance")
 959
 960            def __init__(self, peaks_arr):
 961                self.mz_exp = peaks_arr[:, 0]
 962                self.abundance = peaks_arr[:, 1]
 963
 964        lib_spectra: list = []
 965        lib_precursor_mzs: list[float | None] = []
 966        # IDs for successfully extracted entries only (same order as lib_spectra)
 967        valid_ids: list[str] = []
 968
 969        for i, idx in enumerate(library_indices):
 970            try:
 971                entry = self.fe_lib[idx]
 972            except (IndexError, KeyError, TypeError) as exc:
 973                raise ValueError(f"Cannot read library entry at index {idx}.") from exc
 974
 975            if not isinstance(entry, dict):
 976                raise TypeError(
 977                    f"Library entry at index {idx} must be a dict; "
 978                    f"got {type(entry).__name__}."
 979                )
 980
 981            peaks = np.asarray(entry.get("peaks", []), dtype=float)
 982            # Skip empty/invalid peak tables but keep spectrum_ids/precursors
 983            # aligned to the original index *i* for survivors (not first-N trim).
 984            if peaks.ndim != 2 or peaks.shape[1] < 2 or peaks.shape[0] == 0:
 985                continue
 986
 987            lib_spectra.append(_LibSpec(peaks))
 988            valid_ids.append(spectrum_ids[i])
 989            if precursor_mzs is not None:
 990                lib_precursor_mzs.append(precursor_mzs[i])
 991            else:
 992                lib_precursor_mzs.append(float(entry.get("precursor_mz", 0.0) or 0.0))
 993
 994        if not lib_spectra:
 995            return {}
 996
 997        # Build a temporary FE index from the filtered library spectra
 998        temp_fe = self.build_fe_index_from_spectra(
 999            spectra=lib_spectra,
1000            precursor_mzs=lib_precursor_mzs,
1001        )
1002
1003        # Compute all-vs-all using the temporary index
1004        return self.compute_all_vs_all_with_lib(
1005            spectra=lib_spectra,
1006            spectrum_ids=valid_ids,
1007            precursor_mzs=lib_precursor_mzs,
1008            fe_lib_override=temp_fe,
1009        )
1010
1011    def compute_all_vs_all_with_lib(
1012        self,
1013        spectra: list,
1014        spectrum_ids: list[str],
1015        precursor_mzs: list[float | None] | None = None,
1016        fe_lib_override=None,
1017    ) -> dict[str, dict[tuple[str, str], float]]:
1018        """Compute all-vs-all pairwise similarities using a provided FE library.
1019
1020        Canonical all-vs-all path: each spectrum is searched once against the
1021        FlashEntropy index (*fe_lib_override* or ``self.fe_lib``), and
1022        upper-triangle pairwise scores are extracted from the result vectors.
1023
1024        Parameters
1025        ----------
1026        spectra : list
1027            Spectrum objects with .mz_exp and .abundance attributes.
1028        spectrum_ids : list of str
1029            User-provided IDs, one per spectrum.
1030        precursor_mzs : list of float or None, optional
1031            Precursor m/z for each spectrum.
1032        fe_lib_override : ms_entropy.FlashEntropySearch, optional
1033            FlashEntropy library to search against.  If None, falls back to
1034            ``self.fe_lib``.
1035
1036        Returns
1037        -------
1038        dict
1039            ``{metric_name: {(id1, id2): score}}``
1040        """
1041        n = len(spectra)
1042        if n == 0:
1043            return {}
1044
1045        if precursor_mzs is None:
1046            precursor_mzs = [None] * n
1047
1048        # Library indices are simply 0..n-1 (positions in fe_lib_override)
1049        lib_indices = list(range(n))
1050
1051        # ── Stage 1: Compute entropy similarity matrix using FE search ────────
1052        entropy_pairs, pairs_for_additional = self._compute_entropy_matrix_and_pairs(
1053            spectra, spectrum_ids, precursor_mzs, fe_lib_override
1054        )
1055
1056        result: dict[str, dict[tuple[str, str], float]] = {
1057            "entropy_similarity": entropy_pairs
1058        }
1059
1060        # ── Stage 2: Compute additional metrics for pairs above threshold ────
1061        fe = fe_lib_override if fe_lib_override is not None else self.fe_lib
1062        for metric in self.additional_similarities:
1063            if metric == "cosine":
1064                cosine_idx_scores = self._compute_cosine_for_pairs(
1065                    pairs_for_additional,
1066                    lib_indices,
1067                    fe_lib_override=fe,
1068                )
1069                result["cosine"] = {
1070                    (spectrum_ids[i], spectrum_ids[j]): score
1071                    for (i, j), score in cosine_idx_scores.items()
1072                }
1073
1074        return result

Compute pairwise spectral similarities using FlashEntropy + optional extras.

Parameters
  • fe_lib (ms_entropy.FlashEntropySearch or None): Pre-built FlashEntropy search instance. Pass None for query-only mode (only compute_all_vs_all_with_lib() with a temporary index is used; query-vs-library methods are unavailable). When provided, fragment-tolerance parameters are extracted from this library unless overridden by ms2_tolerance_da.
  • search_type (str): FlashEntropy search mode: "identity", "open", or "neutral_loss". Default "open" (matches typical DDA molecular-networking use; use "identity" when precursor filtering is required).
  • additional_similarities (list of str, optional): Extra similarity metrics to compute for pairs that pass the entropy threshold. Currently supported: ["cosine"]. Default ["cosine"]. Cosine is a best-effort peak-aligned score gated by entropy_threshold_low (not a second primary engine).
  • ms1_tolerance_da (float, optional): Precursor m/z tolerance (Da) used only for "identity" search to filter library candidates by precursor m/z. Ignored for "open" and "neutral_loss" (precursor filter is disabled for those modes). If None (default), uses 0.01 Da.
  • ms2_tolerance_da (float, optional): Fragment m/z tolerance (Da) for spectrum cleaning and similarity scoring. Resolution priority: explicit kwarg > extracted from fe_lib > 0.01 Da fallback.
  • entropy_threshold_low (float or None, optional): Minimum entropy similarity score required to trigger additional metric computation. If None and settings does not supply a value, default is 0.1.
  • settings (SpectralSimilaritySearchSettings, optional): Encapsulated spectral-similarity defaults (for example lcms.parameters.mass_spectrum["ms2"].spectral_similarity_search). When provided, fills any constructor fields left as None. Explicit keyword arguments always override settings.
Attributes
  • ms2_tolerance_da (float): Fragment m/z tolerance resolved at construction time.
  • peak_sep_da (float): Minimum peak separation used during spectrum cleaning (2 * ms2_tolerance_da).
  • ms1_tolerance_da (float): Precursor m/z tolerance for identity search.
  • entropy_threshold_low (float): Low-entropy gate for triggering additional metric computation.
Notes

Tolerance resolution order (highest priority first):

  1. Explicit ms2_tolerance_da kwarg.
  2. settings.ms2_tolerance_da when settings is provided and the kwarg is omitted.
  3. fe_lib.entropy_search.max_ms2_tolerance_in_da (when fe_lib is not None).
  4. Hard-coded fallback of 0.01 Da.

Precursor filter behaviour by search type:

  • "identity" : library candidates are filtered by precursor m/z within ms1_tolerance_da before scoring.
  • "open" : precursor filter is disabled (ms1_tolerance_in_da passed to FlashEntropy is set to 1e9).
  • "neutral_loss" : precursor filter is disabled for the same reason as "open"; matching occurs in neutral-loss mass space (precursor_mz − fragment_mz), not on the precursor itself.

Cosine (when requested) runs single-threaded: unique spectra are pre-sorted once, then pairs are scored sequentially via _align_and_compute_cosine_presorted().

SimilarityEngine( fe_lib, search_type: str | None = None, additional_similarities: list[str] | None = None, ms1_tolerance_da: float | None = None, ms2_tolerance_da: float | None = None, entropy_threshold_low: float | None = None, settings=None)
245    def __init__(
246        self,
247        fe_lib,
248        search_type: str | None = None,
249        additional_similarities: list[str] | None = None,
250        ms1_tolerance_da: float | None = None,
251        ms2_tolerance_da: float | None = None,
252        entropy_threshold_low: float | None = None,
253        settings=None,
254    ):
255        if settings is not None:
256            if search_type is None:
257                search_type = settings.search_type
258            if additional_similarities is None:
259                additional_similarities = list(settings.additional_similarities)
260            if ms1_tolerance_da is None:
261                ms1_tolerance_da = settings.ms1_tolerance_da
262            if ms2_tolerance_da is None:
263                ms2_tolerance_da = settings.ms2_tolerance_da
264            if (
265                entropy_threshold_low is None
266                and settings.entropy_threshold_low is not None
267            ):
268                entropy_threshold_low = settings.entropy_threshold_low
269            self._settings = settings
270        else:
271            self._settings = None
272
273        if search_type is None:
274            search_type = "open"
275        if search_type not in _FE_METHOD_MAP:
276            raise ValueError(
277                f"search_type must be one of {list(_FE_METHOD_MAP.keys())}, "
278                f"got '{search_type}'."
279            )
280        if additional_similarities is None:
281            additional_similarities = ["cosine"]
282        unsupported = set(additional_similarities) - _SUPPORTED_ADDITIONAL
283        if unsupported:
284            raise ValueError(
285                f"Unsupported additional_similarities: {unsupported}. "
286                f"Supported: {_SUPPORTED_ADDITIONAL}"
287            )
288
289        self.fe_lib = fe_lib
290        self.search_type = search_type
291        self.additional_similarities = list(additional_similarities)
292
293        # Tolerance priority: explicit kwarg > fe_lib extraction > 0.01 fallback.
294        # When fe_lib is None (queries-only mode), a temporary FE index is built
295        # from the query spectra themselves; ms2_tolerance_da controls that index.
296        if ms2_tolerance_da is not None:
297            self.ms2_tolerance_da = ms2_tolerance_da
298        elif fe_lib is not None:
299            self.ms2_tolerance_da = fe_lib.entropy_search.max_ms2_tolerance_in_da
300        else:
301            self.ms2_tolerance_da = 0.01
302        self.peak_sep_da = 2 * self.ms2_tolerance_da
303        self.ms1_tolerance_da = (
304            ms1_tolerance_da if ms1_tolerance_da is not None else 0.01
305        )
306
307        self.entropy_threshold_low = (
308            entropy_threshold_low if entropy_threshold_low is not None else 0.1
309        )
fe_lib
search_type
additional_similarities
peak_sep_da
ms1_tolerance_da
entropy_threshold_low
def search_queries_against_library( self, query_spectra: list, query_ids: list[str], query_precursor_mzs: list[float | None] | None = None, *, format_library_id: Callable[[int], str] | None = None) -> tuple[dict[str, dict[tuple[str, str], float]], int]:
703    def search_queries_against_library(
704        self,
705        query_spectra: list,
706        query_ids: list[str],
707        query_precursor_mzs: list[float | None] | None = None,
708        *,
709        format_library_id: Callable[[int], str] | None = None,
710    ) -> tuple[dict[str, dict[tuple[str, str], float]], int]:
711        """Search each query spectrum against ``self.fe_lib`` (query–library).
712
713        Computes entropy similarity for all library hits with score > 0 and,
714        when configured, cosine for pairs at or above
715        :attr:`entropy_threshold_low`.
716
717        Parameters
718        ----------
719        query_spectra : list
720            Spectrum objects with ``.mz_exp`` and ``.abundance``.
721        query_ids : list of str
722            Unique ID for each query (same length as *query_spectra*).
723        query_precursor_mzs : list of float or None, optional
724            Precursor m/z per query.  Required for ``"identity"`` and
725            ``"neutral_loss"``; optional for ``"open"``.
726        format_library_id : callable, optional
727            Maps library index → node ID string used in pair keys.
728            Default ``str`` (bare index).  Callers that need non-colliding
729            IDs (e.g. ``MolecularNetwork.library_node_id``) should pass a
730            formatter.
731
732        Returns
733        -------
734        scores : dict
735            ``{metric_name: {(query_id, library_id): score}}`` with
736            ``"entropy_similarity"`` and optionally ``"cosine"``.
737        library_size : int
738            Length of the FlashEntropy result vector (library size used for
739            registration / indexing).  ``0`` if no successful searches ran.
740
741        Raises
742        ------
743        RuntimeError
744            If ``self.fe_lib`` is ``None``.
745        ValueError
746            If lengths of *query_ids* / *query_precursor_mzs* do not match
747            *query_spectra*.
748        """
749        if self.fe_lib is None:
750            raise RuntimeError(
751                "search_queries_against_library requires a reference FE library "
752                "(fe_lib)."
753            )
754
755        n_query = len(query_spectra)
756        if len(query_ids) != n_query:
757            raise ValueError(
758                f"query_ids length ({len(query_ids)}) must match "
759                f"query_spectra length ({n_query})."
760            )
761        if query_precursor_mzs is None:
762            query_precursor_mzs = [None] * n_query
763        elif len(query_precursor_mzs) != n_query:
764            raise ValueError(
765                f"query_precursor_mzs length ({len(query_precursor_mzs)}) must match "
766                f"query_spectra length ({n_query})."
767            )
768
769        id_fmt = format_library_id if format_library_id is not None else str
770
771        entropy_pairs: dict[tuple[str, str], float] = {}
772        cosine_pairs: dict[tuple[str, str], float] = {}
773        query_to_lib_indices: dict[int, list[int]] = {}
774        library_size = 0
775
776        for qi, (spec, pmz) in enumerate(zip(query_spectra, query_precursor_mzs)):
777            peaks = self._peaks_array(spec)
778            if peaks.shape[0] == 0:
779                continue
780            result_vec = self._clean_and_search(peaks, pmz)
781            if result_vec is None:
782                continue
783            library_size = max(library_size, len(result_vec))
784
785            lib_indices_for_query: list[int] = []
786            for lib_idx, score in enumerate(result_vec):
787                if score > 0.0:
788                    entropy_pairs[(query_ids[qi], id_fmt(lib_idx))] = float(score)
789                    if score >= self.entropy_threshold_low:
790                        lib_indices_for_query.append(lib_idx)
791
792            if lib_indices_for_query:
793                query_to_lib_indices[qi] = lib_indices_for_query
794
795        if "cosine" in self.additional_similarities and query_to_lib_indices:
796            for qi, lib_indices in query_to_lib_indices.items():
797                cosine_scores = self._compute_cosine_for_query_vs_library(
798                    query_spectrum=query_spectra[qi],
799                    query_precursor_mz=query_precursor_mzs[qi],
800                    library_indices=lib_indices,
801                )
802                for lib_idx, score in cosine_scores.items():
803                    cosine_pairs[(query_ids[qi], id_fmt(lib_idx))] = score
804
805        scores: dict[str, dict[tuple[str, str], float]] = {
806            "entropy_similarity": entropy_pairs
807        }
808        if cosine_pairs:
809            scores["cosine"] = cosine_pairs
810
811        return scores, library_size

Search each query spectrum against self.fe_lib (query–library).

Computes entropy similarity for all library hits with score > 0 and, when configured, cosine for pairs at or above entropy_threshold_low.

Parameters
  • query_spectra (list): Spectrum objects with .mz_exp and .abundance.
  • query_ids (list of str): Unique ID for each query (same length as query_spectra).
  • query_precursor_mzs (list of float or None, optional): Precursor m/z per query. Required for "identity" and "neutral_loss"; optional for "open".
  • format_library_id (callable, optional): Maps library index → node ID string used in pair keys. Default str (bare index). Callers that need non-colliding IDs (e.g. MolecularNetwork.library_node_id) should pass a formatter.
Returns
  • scores (dict): {metric_name: {(query_id, library_id): score}} with "entropy_similarity" and optionally "cosine".
  • library_size (int): Length of the FlashEntropy result vector (library size used for registration / indexing). 0 if no successful searches ran.
Raises
  • RuntimeError: If self.fe_lib is None.
  • ValueError: If lengths of query_ids / query_precursor_mzs do not match query_spectra.
def build_fe_index_from_spectra( self, spectra: list, precursor_mzs: list[float | None] | None = None, fe_kwargs: dict | None = None):
813    def build_fe_index_from_spectra(
814        self,
815        spectra: list,
816        precursor_mzs: list[float | None] | None = None,
817        fe_kwargs: dict | None = None,
818    ):
819        """Build a FlashEntropy search index from a list of spectrum objects.
820
821        This is used to create a temporary index from query spectra so that
822        query-vs-query similarities can be computed without requiring the
823        spectra to already be in the main library.
824
825        Parameters
826        ----------
827        spectra : list
828            Spectrum objects with ``.mz_exp`` and ``.abundance`` attributes.
829        precursor_mzs : list of float or None, optional
830            Precursor m/z for each spectrum.  Used as the ``precursor_mz``
831            field in the FlashEntropy library.  Defaults to 0.0 for each.
832        fe_kwargs : dict, optional
833            Extra keyword arguments forwarded to ``FlashEntropySearch``.
834            Defaults to the same settings used for the main library.
835
836        Returns
837        -------
838        ms_entropy.FlashEntropySearch
839            A new FlashEntropy search instance indexed on *spectra*.
840        """
841        try:
842            from ms_entropy import FlashEntropySearch
843        except ImportError:
844            raise ImportError(
845                "ms_entropy is required for build_fe_index_from_spectra(). "
846                "Install with: pip install ms_entropy"
847            )
848
849        if precursor_mzs is None:
850            precursor_mzs = [None] * len(spectra)
851
852        # Default FE kwargs mirror the main library settings
853        # Extract build_index parameters from the main FE library if available
854        # (these are stored as custom attributes by _build_flash_entropy_index)
855        default_fe_kwargs = {
856            "normalize_intensity": True,
857            "min_ms2_difference_in_da": self.peak_sep_da,  # Already 2x tolerance from extraction
858            "max_ms2_tolerance_in_da": self.ms2_tolerance_da,
859            "max_indexed_mz": getattr(self.fe_lib, "_build_max_indexed_mz", 3000),
860            "precursor_ions_removal_da": getattr(
861                self.fe_lib, "_build_precursor_ions_removal_da", None
862            ),
863            "noise_threshold": getattr(self.fe_lib, "_build_noise_threshold", 0),
864        }
865        if fe_kwargs:
866            default_fe_kwargs.update(fe_kwargs)
867
868        # Build the spectral library list
869        spectral_library = []
870        for i, (spec, pmz) in enumerate(zip(spectra, precursor_mzs)):
871            peaks = self._peaks_array(spec)
872            if peaks.shape[0] == 0:
873                continue
874            spectral_library.append(
875                {
876                    "id": i,
877                    "precursor_mz": float(pmz) if pmz is not None else 0.0,
878                    "peaks": peaks.tolist(),
879                }
880            )
881
882        # Match the FlashEntropy constructor/init and build_index signature
883        fe_init_kws = [
884            "max_ms2_tolerance_in_da",
885            "mz_index_step",
886            "low_memory",
887            "path_data",
888        ]
889        fe_init_kws = {k: v for k, v in default_fe_kwargs.items() if k in fe_init_kws}
890
891        fe = FlashEntropySearch(**fe_init_kws)
892
893        fe_index_kws = [
894            "max_indexed_mz",
895            "precursor_ions_removal_da",
896            "noise_threshold",
897            "min_ms2_difference_in_da",
898            "max_peak_num",
899        ]
900        fe_index_kws = {k: v for k, v in default_fe_kwargs.items() if k in fe_index_kws}
901
902        fe.build_index(spectral_library, **fe_index_kws, clean_spectra=True)
903        return fe

Build a FlashEntropy search index from a list of spectrum objects.

This is used to create a temporary index from query spectra so that query-vs-query similarities can be computed without requiring the spectra to already be in the main library.

Parameters
  • spectra (list): Spectrum objects with .mz_exp and .abundance attributes.
  • precursor_mzs (list of float or None, optional): Precursor m/z for each spectrum. Used as the precursor_mz field in the FlashEntropy library. Defaults to 0.0 for each.
  • fe_kwargs (dict, optional): Extra keyword arguments forwarded to FlashEntropySearch. Defaults to the same settings used for the main library.
Returns
  • ms_entropy.FlashEntropySearch: A new FlashEntropy search instance indexed on spectra.
def compute_library_vs_library_filtered( self, library_indices: list[int], spectrum_ids: list[str], precursor_mzs: list[float | None] | None = None) -> dict[str, dict[tuple[str, str], float]]:
 905    def compute_library_vs_library_filtered(
 906        self,
 907        library_indices: list[int],
 908        spectrum_ids: list[str],
 909        precursor_mzs: list[float | None] | None = None,
 910    ) -> dict[str, dict[tuple[str, str], float]]:
 911        """Compute library-vs-library similarities for a filtered subset of library spectra.
 912
 913        Extracts the spectra at *library_indices* from ``self.fe_lib``, builds a
 914        temporary FlashEntropy index from them, and computes all-vs-all pairwise
 915        similarities within that subset.
 916
 917        Parameters
 918        ----------
 919        library_indices : list of int
 920            Indices into ``self.fe_lib`` of the library spectra to include.
 921        spectrum_ids : list of str
 922            IDs to assign to each library spectrum (must be same length as
 923            *library_indices*).
 924        precursor_mzs : list of float or None, optional
 925            Precursor m/z for each library spectrum.  If None, attempts to read
 926            from ``self.fe_lib`` (attribute ``precursor_mz`` on each entry), or
 927            defaults to 0.0.
 928
 929        Returns
 930        -------
 931        dict
 932            ``{metric_name: {(id1, id2): score}}``
 933            All-vs-all pairs within the filtered library subset.
 934        """
 935        n = len(library_indices)
 936        if n == 0:
 937            return {}
 938
 939        if self.fe_lib is None:
 940            raise RuntimeError(
 941                "compute_library_vs_library_filtered requires a reference FE library "
 942                "(fe_lib)."
 943            )
 944
 945        if len(spectrum_ids) != n:
 946            raise ValueError(
 947                f"spectrum_ids length ({len(spectrum_ids)}) must match "
 948                f"library_indices length ({n})."
 949            )
 950        if precursor_mzs is not None and len(precursor_mzs) != n:
 951            raise ValueError(
 952                f"precursor_mzs length ({len(precursor_mzs)}) must match "
 953                f"library_indices length ({n})."
 954            )
 955
 956        # Build lightweight spectrum objects from the raw library entries
 957        class _LibSpec:
 958            __slots__ = ("mz_exp", "abundance")
 959
 960            def __init__(self, peaks_arr):
 961                self.mz_exp = peaks_arr[:, 0]
 962                self.abundance = peaks_arr[:, 1]
 963
 964        lib_spectra: list = []
 965        lib_precursor_mzs: list[float | None] = []
 966        # IDs for successfully extracted entries only (same order as lib_spectra)
 967        valid_ids: list[str] = []
 968
 969        for i, idx in enumerate(library_indices):
 970            try:
 971                entry = self.fe_lib[idx]
 972            except (IndexError, KeyError, TypeError) as exc:
 973                raise ValueError(f"Cannot read library entry at index {idx}.") from exc
 974
 975            if not isinstance(entry, dict):
 976                raise TypeError(
 977                    f"Library entry at index {idx} must be a dict; "
 978                    f"got {type(entry).__name__}."
 979                )
 980
 981            peaks = np.asarray(entry.get("peaks", []), dtype=float)
 982            # Skip empty/invalid peak tables but keep spectrum_ids/precursors
 983            # aligned to the original index *i* for survivors (not first-N trim).
 984            if peaks.ndim != 2 or peaks.shape[1] < 2 or peaks.shape[0] == 0:
 985                continue
 986
 987            lib_spectra.append(_LibSpec(peaks))
 988            valid_ids.append(spectrum_ids[i])
 989            if precursor_mzs is not None:
 990                lib_precursor_mzs.append(precursor_mzs[i])
 991            else:
 992                lib_precursor_mzs.append(float(entry.get("precursor_mz", 0.0) or 0.0))
 993
 994        if not lib_spectra:
 995            return {}
 996
 997        # Build a temporary FE index from the filtered library spectra
 998        temp_fe = self.build_fe_index_from_spectra(
 999            spectra=lib_spectra,
1000            precursor_mzs=lib_precursor_mzs,
1001        )
1002
1003        # Compute all-vs-all using the temporary index
1004        return self.compute_all_vs_all_with_lib(
1005            spectra=lib_spectra,
1006            spectrum_ids=valid_ids,
1007            precursor_mzs=lib_precursor_mzs,
1008            fe_lib_override=temp_fe,
1009        )

Compute library-vs-library similarities for a filtered subset of library spectra.

Extracts the spectra at library_indices from self.fe_lib, builds a temporary FlashEntropy index from them, and computes all-vs-all pairwise similarities within that subset.

Parameters
  • library_indices (list of int): Indices into self.fe_lib of the library spectra to include.
  • spectrum_ids (list of str): IDs to assign to each library spectrum (must be same length as library_indices).
  • precursor_mzs (list of float or None, optional): Precursor m/z for each library spectrum. If None, attempts to read from self.fe_lib (attribute precursor_mz on each entry), or defaults to 0.0.
Returns
  • dict: {metric_name: {(id1, id2): score}} All-vs-all pairs within the filtered library subset.
def compute_all_vs_all_with_lib( self, spectra: list, spectrum_ids: list[str], precursor_mzs: list[float | None] | None = None, fe_lib_override=None) -> dict[str, dict[tuple[str, str], float]]:
1011    def compute_all_vs_all_with_lib(
1012        self,
1013        spectra: list,
1014        spectrum_ids: list[str],
1015        precursor_mzs: list[float | None] | None = None,
1016        fe_lib_override=None,
1017    ) -> dict[str, dict[tuple[str, str], float]]:
1018        """Compute all-vs-all pairwise similarities using a provided FE library.
1019
1020        Canonical all-vs-all path: each spectrum is searched once against the
1021        FlashEntropy index (*fe_lib_override* or ``self.fe_lib``), and
1022        upper-triangle pairwise scores are extracted from the result vectors.
1023
1024        Parameters
1025        ----------
1026        spectra : list
1027            Spectrum objects with .mz_exp and .abundance attributes.
1028        spectrum_ids : list of str
1029            User-provided IDs, one per spectrum.
1030        precursor_mzs : list of float or None, optional
1031            Precursor m/z for each spectrum.
1032        fe_lib_override : ms_entropy.FlashEntropySearch, optional
1033            FlashEntropy library to search against.  If None, falls back to
1034            ``self.fe_lib``.
1035
1036        Returns
1037        -------
1038        dict
1039            ``{metric_name: {(id1, id2): score}}``
1040        """
1041        n = len(spectra)
1042        if n == 0:
1043            return {}
1044
1045        if precursor_mzs is None:
1046            precursor_mzs = [None] * n
1047
1048        # Library indices are simply 0..n-1 (positions in fe_lib_override)
1049        lib_indices = list(range(n))
1050
1051        # ── Stage 1: Compute entropy similarity matrix using FE search ────────
1052        entropy_pairs, pairs_for_additional = self._compute_entropy_matrix_and_pairs(
1053            spectra, spectrum_ids, precursor_mzs, fe_lib_override
1054        )
1055
1056        result: dict[str, dict[tuple[str, str], float]] = {
1057            "entropy_similarity": entropy_pairs
1058        }
1059
1060        # ── Stage 2: Compute additional metrics for pairs above threshold ────
1061        fe = fe_lib_override if fe_lib_override is not None else self.fe_lib
1062        for metric in self.additional_similarities:
1063            if metric == "cosine":
1064                cosine_idx_scores = self._compute_cosine_for_pairs(
1065                    pairs_for_additional,
1066                    lib_indices,
1067                    fe_lib_override=fe,
1068                )
1069                result["cosine"] = {
1070                    (spectrum_ids[i], spectrum_ids[j]): score
1071                    for (i, j), score in cosine_idx_scores.items()
1072                }
1073
1074        return result

Compute all-vs-all pairwise similarities using a provided FE library.

Canonical all-vs-all path: each spectrum is searched once against the FlashEntropy index (fe_lib_override or self.fe_lib), and upper-triangle pairwise scores are extracted from the result vectors.

Parameters
  • spectra (list): Spectrum objects with .mz_exp and .abundance attributes.
  • spectrum_ids (list of str): User-provided IDs, one per spectrum.
  • precursor_mzs (list of float or None, optional): Precursor m/z for each spectrum.
  • fe_lib_override (ms_entropy.FlashEntropySearch, optional): FlashEntropy library to search against. If None, falls back to self.fe_lib.
Returns
  • dict: {metric_name: {(id1, id2): score}}
class NetworkVisualizeMixin:
  40class NetworkVisualizeMixin:
  41    """Mixin that adds static and interactive network plotting."""
  42
  43    _DEFAULT_COLOR_MAP = {
  44        "query": "#e74c3c",
  45        "library": "#3498db",
  46        "unknown": "#95a5a6",
  47    }
  48
  49    _DEFAULT_LIBRARY_LABEL_FIELDS = ("compound_name", "name", "spectra_id", "id")
  50
  51    def _library_record_for_node(self, node_id: str) -> dict[str, Any] | None:
  52        """Return FE library record dict for a library node ID, if available."""
  53        fe_lib = getattr(self, "fe_lib", None)
  54        if fe_lib is None:
  55            return None
  56
  57        parse_idx = getattr(self, "library_index_from_node_id", None)
  58        if callable(parse_idx):
  59            lib_idx = parse_idx(node_id)
  60        else:
  61            try:
  62                lib_idx = int(node_id)
  63            except (TypeError, ValueError):
  64                lib_idx = None
  65        if lib_idx is None:
  66            return None
  67
  68        try:
  69            record = fe_lib[lib_idx]
  70        except (IndexError, KeyError, TypeError):
  71            return None
  72
  73        return record if isinstance(record, dict) else None
  74
  75    @staticmethod
  76    def _as_node_attr_value(value: Any) -> Any:
  77        """Convert values to JSON-friendly node attributes for Sigma hover panels."""
  78        if value is None or isinstance(value, (str, int, float, bool)):
  79            return value
  80        return str(value)
  81
  82    def _resolve_library_label(
  83        self,
  84        node_id: str,
  85        record: dict[str, Any] | None,
  86        library_label_field: str | Sequence[str] | None,
  87    ) -> str:
  88        """Resolve display label for a library node from FE record fields."""
  89        fallback = node_id[:20] + "..." if len(node_id) > 20 else node_id
  90        if not record:
  91            return fallback
  92
  93        if library_label_field is None:
  94            candidate_fields: Sequence[str] = self._DEFAULT_LIBRARY_LABEL_FIELDS
  95        elif isinstance(library_label_field, str):
  96            candidate_fields = (library_label_field,)
  97        else:
  98            candidate_fields = library_label_field
  99
 100        for field in candidate_fields:
 101            value = record.get(field)
 102            if value is None:
 103                continue
 104            value_str = str(value).strip()
 105            if value_str:
 106                return value_str
 107
 108        return fallback
 109
 110    def _edges_to_dataframe(
 111        self,
 112        metric: str,
 113        score_threshold: float,
 114        include_queries_only: bool,
 115        exclude_self: bool,
 116    ) -> pd.DataFrame:
 117        """Return filtered edge list as a DataFrame with id1/id2/score."""
 118        mat = self.similarity_matrices[metric]
 119        all_edges = mat.get_pairs_above_threshold(0.0)
 120
 121        if not all_edges:
 122            return pd.DataFrame(columns=["id1", "id2", "score"])
 123
 124        edges = pd.DataFrame(all_edges, columns=["id1", "id2", "score"])
 125        edges = edges[edges["score"] >= score_threshold]
 126
 127        if exclude_self:
 128            edges = edges[edges["id1"] != edges["id2"]]
 129
 130        if include_queries_only and self._all_query_ids:
 131            query_id_set = set(self._all_query_ids)
 132
 133            # Keep all query edges plus library-library edges where both
 134            # library nodes are connected to at least one query node.
 135            q1 = edges["id1"].isin(query_id_set)
 136            q2 = edges["id2"].isin(query_id_set)
 137            ql_or_qq = q1 | q2
 138
 139            query_edges = edges[ql_or_qq]
 140            if query_edges.empty:
 141                return pd.DataFrame(columns=["id1", "id2", "score"])
 142
 143            lib_from_id1 = query_edges.loc[
 144                ~query_edges["id1"].isin(query_id_set), "id1"
 145            ]
 146            lib_from_id2 = query_edges.loc[
 147                ~query_edges["id2"].isin(query_id_set), "id2"
 148            ]
 149            query_connected_libs = set(pd.concat([lib_from_id1, lib_from_id2]).tolist())
 150
 151            ll_mask = (
 152                ~q1
 153                & ~q2
 154                & edges["id1"].isin(query_connected_libs)
 155                & edges["id2"].isin(query_connected_libs)
 156            )
 157
 158            edges = pd.concat(
 159                [query_edges, edges[ll_mask]], ignore_index=True
 160            ).drop_duplicates()
 161
 162        return edges
 163
 164    @staticmethod
 165    def _sparsify_top_k_edges(G, weight_attr: str, top_k: int):
 166        """Return an undirected top-k edge sparsified copy of *G*."""
 167        if top_k <= 0 or G.number_of_nodes() == 0:
 168            return G.copy()
 169
 170        H = G.to_undirected().copy()
 171        selected_edges: set[tuple[str, str]] = set()
 172
 173        for node in H.nodes:
 174            neighbors = []
 175            for nbr in H.neighbors(node):
 176                edge_data = H.get_edge_data(node, nbr, default={})
 177                weight = float(edge_data.get(weight_attr, 1.0))
 178                neighbors.append((nbr, weight))
 179
 180            neighbors.sort(key=lambda t: (-t[1], str(t[0])))
 181            for nbr, _ in neighbors[:top_k]:
 182                edge_key = tuple(sorted((str(node), str(nbr))))
 183                selected_edges.add(edge_key)
 184
 185        S = H.__class__()
 186        S.add_nodes_from(H.nodes(data=True))
 187        for u, v, data in H.edges(data=True):
 188            edge_key = tuple(sorted((str(u), str(v))))
 189            if edge_key in selected_edges:
 190                S.add_edge(u, v, **data)
 191
 192        return S
 193
 194    def _detect_weighted_communities(
 195        self,
 196        G,
 197        *,
 198        weight_attr: str,
 199        super_threshold: int,
 200        sparsify_top_k: int,
 201        recursive_split: bool,
 202    ) -> list[set]:
 203        """Detect weighted communities with optional recursive splitting."""
 204        nx = _import_networkx()
 205
 206        def partition_graph(graph, depth: int = 0) -> list[set]:
 207            if graph.number_of_nodes() <= 1:
 208                return [set(graph.nodes)]
 209
 210            communities = list(
 211                nx.community.greedy_modularity_communities(
 212                    graph,
 213                    weight=weight_attr,
 214                )
 215            )
 216            if len(communities) <= 1:
 217                return [set(graph.nodes)]
 218
 219            sorted_communities = sorted(
 220                communities, key=lambda c: (-len(c), min(str(n) for n in c))
 221            )
 222            out: list[set] = []
 223
 224            for community in sorted_communities:
 225                community_set = set(community)
 226                if not recursive_split or len(community_set) <= super_threshold:
 227                    out.append(community_set)
 228                    continue
 229
 230                sub = graph.subgraph(community_set).copy()
 231                if sparsify_top_k > 0:
 232                    sub = self._sparsify_top_k_edges(
 233                        sub, weight_attr=weight_attr, top_k=sparsify_top_k
 234                    )
 235
 236                if sub.number_of_edges() == 0:
 237                    out.append(community_set)
 238                    continue
 239
 240                sub_parts = partition_graph(sub, depth + 1)
 241                if len(sub_parts) <= 1:
 242                    out.append(community_set)
 243                    continue
 244
 245                for part in sub_parts:
 246                    if part:
 247                        out.append(set(part))
 248
 249            return out
 250
 251        raw = partition_graph(G.to_undirected())
 252        normalized = [set(c) for c in raw if c]
 253        if not normalized:
 254            return [set(G.nodes)]
 255
 256        return sorted(normalized, key=lambda c: (-len(c), min(str(n) for n in c)))
 257
 258    @staticmethod
 259    def _compute_hierarchical_layout(
 260        G, communities: list[set], *, weight_attr: str, seed: int
 261    ):
 262        """Compute two-level (community + local) weighted spring layout."""
 263        nx = _import_networkx()
 264        if G.number_of_nodes() == 0:
 265            return {}
 266
 267        if not communities:
 268            pos = nx.spring_layout(
 269                G,
 270                seed=seed,
 271                weight=weight_attr,
 272                k=2.0 / max(G.number_of_nodes() ** 0.5, 1),
 273            )
 274            return {n: {"x": float(x), "y": float(y)} for n, (x, y) in pos.items()}
 275
 276        node_to_comm = {}
 277        for comm_idx, nodes in enumerate(communities):
 278            for node in nodes:
 279                node_to_comm[node] = comm_idx
 280
 281        meta = nx.Graph()
 282        for comm_idx, nodes in enumerate(communities):
 283            meta.add_node(comm_idx, size=len(nodes))
 284
 285        inter_weights: dict[tuple[int, int], float] = defaultdict(float)
 286        for u, v, data in G.edges(data=True):
 287            cu = node_to_comm.get(u)
 288            cv = node_to_comm.get(v)
 289            if cu is None or cv is None or cu == cv:
 290                continue
 291            edge = (cu, cv) if cu < cv else (cv, cu)
 292            inter_weights[edge] += float(data.get(weight_attr, 1.0))
 293
 294        for (cu, cv), w in inter_weights.items():
 295            meta.add_edge(cu, cv, weight=w)
 296
 297        # Build local layouts first to estimate community footprint radius.
 298        local_layouts: dict[int, dict] = {}
 299        community_radius: dict[int, float] = {}
 300        for comm_idx, nodes in enumerate(communities):
 301            sub = G.subgraph(nodes)
 302            if sub.number_of_nodes() == 1:
 303                only = next(iter(sub.nodes))
 304                local_pos = {only: (0.0, 0.0)}
 305            else:
 306                local_pos = nx.spring_layout(
 307                    sub,
 308                    seed=seed,
 309                    weight=weight_attr,
 310                    k=1.5 / max(sub.number_of_nodes() ** 0.5, 1),
 311                )
 312
 313            max_radius = 0.0
 314            for x, y in local_pos.values():
 315                max_radius = max(max_radius, float((x * x + y * y) ** 0.5))
 316            if max_radius <= 0:
 317                max_radius = 1.0
 318
 319            radius = 0.6 + 0.18 * (len(nodes) ** 0.5)
 320            local_layouts[comm_idx] = local_pos
 321            community_radius[comm_idx] = radius
 322
 323        if meta.number_of_nodes() == 1:
 324            meta_pos = {0: (0.0, 0.0)}
 325        else:
 326            # Larger k spreads community centers to reduce overlap.
 327            meta_pos = nx.spring_layout(
 328                meta,
 329                seed=seed,
 330                weight="weight",
 331                k=6.0 / max(meta.number_of_nodes() ** 0.5, 1),
 332            )
 333
 334        # Apply pairwise separation pass so center distances exceed
 335        # footprint-based minimum spacing.
 336        meta_xy = {idx: [float(x), float(y)] for idx, (x, y) in meta_pos.items()}
 337        comm_ids = list(meta_xy.keys())
 338        for _ in range(8):
 339            moved = False
 340            for i in range(len(comm_ids)):
 341                ci = comm_ids[i]
 342                xi, yi = meta_xy[ci]
 343                for j in range(i + 1, len(comm_ids)):
 344                    cj = comm_ids[j]
 345                    xj, yj = meta_xy[cj]
 346                    dx = xj - xi
 347                    dy = yj - yi
 348                    dist = (dx * dx + dy * dy) ** 0.5
 349                    min_dist = community_radius[ci] + community_radius[cj] + 0.8
 350                    if dist < min_dist:
 351                        moved = True
 352                        if dist <= 1e-9:
 353                            ux, uy = 1.0, 0.0
 354                        else:
 355                            ux, uy = dx / dist, dy / dist
 356                        push = 0.5 * (min_dist - max(dist, 1e-9))
 357                        meta_xy[ci][0] -= ux * push
 358                        meta_xy[ci][1] -= uy * push
 359                        meta_xy[cj][0] += ux * push
 360                        meta_xy[cj][1] += uy * push
 361            if not moved:
 362                break
 363
 364        final_pos: dict = {}
 365        for comm_idx, nodes in enumerate(communities):
 366            local_pos = local_layouts[comm_idx]
 367            max_radius = 0.0
 368            for x, y in local_pos.values():
 369                max_radius = max(max_radius, float((x * x + y * y) ** 0.5))
 370            if max_radius <= 0:
 371                max_radius = 1.0
 372
 373            cx, cy = meta_xy.get(comm_idx, [0.0, 0.0])
 374            radius = community_radius[comm_idx]
 375            for node, (x, y) in local_pos.items():
 376                final_pos[node] = {
 377                    "x": float(cx + radius * (x / max_radius)),
 378                    "y": float(cy + radius * (y / max_radius)),
 379                }
 380
 381        return final_pos
 382
 383    def _ensure_cluster_cache(self) -> dict[str, dict]:
 384        """Return mutable in-memory cluster-artifact cache.
 385
 386        The cache is keyed by metric name and stores artifacts generated by
 387        :meth:`compute_network_clusters` (tables, parameters, and layout).
 388        """
 389        if not hasattr(self, "_network_clusters"):
 390            self._network_clusters = {}
 391        return self._network_clusters
 392
 393    def _export_cluster_node_id(self, node_id: str, query_id_set: set[str]) -> str:
 394        """Return export-safe node ID for cluster file output.
 395
 396        Query IDs are preserved.  Library IDs are mapped through
 397        ``MolecularNetwork._export_node_id`` when present, so FlashEntropy
 398        internal indices can be exported as stable library identifiers (for
 399        example ``spectra_id``).
 400        """
 401        export_fn = getattr(self, "_export_node_id", None)
 402        if callable(export_fn):
 403            try:
 404                return str(export_fn(node_id, query_id_set))
 405            except (TypeError, ValueError, KeyError, IndexError):
 406                pass
 407        return str(node_id)
 408
 409    @staticmethod
 410    def _apply_query_connectivity_filters(
 411        G,
 412        *,
 413        nx,
 414        query_id_set: set,
 415        drop_components_without_queries: bool,
 416        drop_nodes_without_query_connection: bool,
 417    ) -> None:
 418        """Apply query-connectivity pruning in-place on graph ``G``.
 419
 420        Parameters
 421        ----------
 422        G
 423            NetworkX graph to mutate.
 424        nx
 425            Imported ``networkx`` module, passed in to avoid repeated imports.
 426        query_id_set : set
 427            Set of query node IDs.
 428        drop_components_without_queries : bool
 429            Drop connected components that contain no query nodes.
 430        drop_nodes_without_query_connection : bool
 431            Keep only query nodes and one-hop neighbors of query nodes.
 432        """
 433        if drop_components_without_queries and G.number_of_nodes() > 0:
 434            if G.is_directed():
 435                components = list(nx.weakly_connected_components(G))
 436            else:
 437                components = list(nx.connected_components(G))
 438
 439            nodes_to_remove = []
 440            for component in components:
 441                if component.isdisjoint(query_id_set):
 442                    nodes_to_remove.extend(component)
 443
 444            if nodes_to_remove:
 445                G.remove_nodes_from(nodes_to_remove)
 446
 447        if (
 448            drop_nodes_without_query_connection
 449            and G.number_of_nodes() > 0
 450            and query_id_set
 451        ):
 452            query_nodes_in_graph = [n for n in query_id_set if G.has_node(n)]
 453            if query_nodes_in_graph:
 454                keep_nodes = set(query_nodes_in_graph)
 455                for qnode in query_nodes_in_graph:
 456                    keep_nodes.update(G.neighbors(qnode))
 457
 458                if len(keep_nodes) < G.number_of_nodes():
 459                    nodes_to_remove = [n for n in G.nodes if n not in keep_nodes]
 460                    G.remove_nodes_from(nodes_to_remove)
 461
 462    def compute_network_clusters(
 463        self,
 464        metric: str = "entropy_similarity",
 465        *,
 466        include_queries_only: bool = True,
 467        score_threshold: float | None = None,
 468        max_edges: int | None = 500,
 469        max_nodes: int | None = None,
 470        exclude_self: bool = True,
 471        directed: bool = False,
 472        drop_components_without_queries: bool = True,
 473        drop_nodes_without_query_connection: bool = True,
 474        cluster_method: str = "weighted_greedy_modularity",
 475        cluster_weight_attr: str = "score",
 476        cluster_super_threshold: int = 400,
 477        cluster_sparsify_top_k: int = 8,
 478        cluster_recursive_split: bool = True,
 479        cluster_min_size: int = 2,
 480        compute_layout: bool = True,
 481        layout_seed: int = 42,
 482    ) -> dict[str, Any]:
 483        """Compute and cache clustering artifacts for one similarity metric.
 484
 485        This stage is separate from plotting.  It builds a graph from the
 486        selected similarity matrix, applies query-connectivity filters,
 487        computes communities, and stores node/community/edge/layout tables in
 488        an in-memory artifact cache for later plotting or export.
 489
 490        Parameters
 491        ----------
 492        metric : str
 493            Similarity metric key from ``self.similarity_matrices``.
 494        include_queries_only : bool
 495            Use query-focused edge filtering during edge extraction.
 496        score_threshold : float or None
 497            Minimum similarity score to include; ``None`` uses
 498            ``_threshold_for(metric)``.
 499        max_edges : int or None
 500            Keep top-N edges by score after filtering.
 501        max_nodes : int or None
 502            Keep edges whose endpoints are within highest-degree N nodes.
 503        exclude_self : bool
 504            Drop self-loop edges.
 505        directed : bool
 506            Build directed graph when ``True``.
 507        drop_components_without_queries : bool
 508            Remove connected components with no query nodes.
 509        drop_nodes_without_query_connection : bool
 510            Keep only query nodes and one-hop query neighbors.
 511        cluster_method : str
 512            Community method; currently ``"weighted_greedy_modularity"``.
 513        cluster_weight_attr : str
 514            Edge attribute used as clustering weight.
 515        cluster_super_threshold : int
 516            Threshold above which recursive splitting can apply.
 517        cluster_sparsify_top_k : int
 518            Top-k edge sparsification for oversized community refinement.
 519        cluster_recursive_split : bool
 520            Enable recursive refinement of oversized communities.
 521        cluster_min_size : int
 522            Reserved for future use; kept for API stability.
 523        compute_layout : bool
 524            Compute and cache hierarchical node coordinates.
 525        layout_seed : int
 526            Random seed for deterministic layout generation.
 527
 528        Returns
 529        -------
 530        dict
 531            Summary with ``metric``, ``n_nodes``, ``n_edges``, ``n_clusters``,
 532            and ``score_threshold``.
 533
 534        Raises
 535        ------
 536        KeyError
 537            If *metric* is unknown.
 538        ValueError
 539            If *cluster_method* is unsupported.
 540        """
 541        if metric not in self.similarity_matrices:
 542            raise KeyError(
 543                f"Unknown metric '{metric}'. Available: {list(self.similarity_matrices.keys())}"
 544            )
 545
 546        if cluster_method != "weighted_greedy_modularity":
 547            raise ValueError(
 548                "Unsupported cluster_method. "
 549                "Currently supported: ['weighted_greedy_modularity']"
 550            )
 551
 552        nx = _import_networkx()
 553
 554        if score_threshold is None:
 555            score_threshold = self._threshold_for(metric)
 556
 557        edges = self._edges_to_dataframe(
 558            metric=metric,
 559            score_threshold=score_threshold,
 560            include_queries_only=include_queries_only,
 561            exclude_self=exclude_self,
 562        )
 563
 564        if max_nodes is not None and not edges.empty:
 565            degree = (
 566                pd.concat([edges["id1"], edges["id2"]]).value_counts().head(max_nodes)
 567            )
 568            top_nodes = set(degree.index)
 569            edges = edges[edges["id1"].isin(top_nodes) & edges["id2"].isin(top_nodes)]
 570
 571        if max_edges is not None and not edges.empty:
 572            edges = edges.nlargest(max_edges, "score")
 573
 574        G = nx.DiGraph() if directed else nx.Graph()
 575        query_id_set = set(self._all_query_ids)
 576
 577        if not edges.empty:
 578            node_ids = (
 579                pd.concat([edges["id1"], edges["id2"]]).drop_duplicates().tolist()
 580            )
 581            for node_id in node_ids:
 582                G.add_node(
 583                    node_id,
 584                    node_type=("query" if node_id in query_id_set else "library"),
 585                )
 586            for row in edges.itertuples(index=False):
 587                G.add_edge(row.id1, row.id2, score=float(row.score))
 588
 589        self._apply_query_connectivity_filters(
 590            G,
 591            nx=nx,
 592            query_id_set=query_id_set,
 593            drop_components_without_queries=drop_components_without_queries,
 594            drop_nodes_without_query_connection=drop_nodes_without_query_connection,
 595        )
 596
 597        communities: list[set] = []
 598        if G.number_of_nodes() > 0:
 599            communities = self._detect_weighted_communities(
 600                G.to_undirected(),
 601                weight_attr=cluster_weight_attr,
 602                super_threshold=cluster_super_threshold,
 603                sparsify_top_k=cluster_sparsify_top_k,
 604                recursive_split=cluster_recursive_split,
 605            )
 606
 607        node_rows = []
 608        community_rows = []
 609        node_to_cluster: dict[str, str] = {}
 610
 611        for comm_idx, nodes in enumerate(communities):
 612            cluster_id = f"C{comm_idx:03d}"
 613            query_count = sum(1 for n in nodes if n in query_id_set)
 614            community_rows.append(
 615                {
 616                    "cluster_id": cluster_id,
 617                    "community_level": 0,
 618                    "size": len(nodes),
 619                    "query_count_in_community": query_count,
 620                }
 621            )
 622            for node in sorted(nodes, key=str):
 623                node_to_cluster[str(node)] = cluster_id
 624                node_rows.append(
 625                    {
 626                        "node_id": str(node),
 627                        "cluster_id": cluster_id,
 628                        "community_level": 0,
 629                        "community_size": len(nodes),
 630                        "query_count_in_community": query_count,
 631                        "node_type": G.nodes[node].get("node_type", "unknown"),
 632                    }
 633                )
 634
 635        # Ensure isolated nodes still receive a cluster assignment.
 636        if G.number_of_nodes() > 0:
 637            for node in G.nodes:
 638                node_s = str(node)
 639                if node_s in node_to_cluster:
 640                    continue
 641                cluster_id = f"C{len(community_rows):03d}"
 642                node_to_cluster[node_s] = cluster_id
 643                query_count = 1 if node in query_id_set else 0
 644                community_rows.append(
 645                    {
 646                        "cluster_id": cluster_id,
 647                        "community_level": 0,
 648                        "size": 1,
 649                        "query_count_in_community": query_count,
 650                    }
 651                )
 652                node_rows.append(
 653                    {
 654                        "node_id": node_s,
 655                        "cluster_id": cluster_id,
 656                        "community_level": 0,
 657                        "community_size": 1,
 658                        "query_count_in_community": query_count,
 659                        "node_type": G.nodes[node].get("node_type", "unknown"),
 660                    }
 661                )
 662
 663        edge_rows = []
 664        for u, v, data in G.edges(data=True):
 665            u_s = str(u)
 666            v_s = str(v)
 667            edge_rows.append(
 668                {
 669                    "id1": u_s,
 670                    "id2": v_s,
 671                    "score": float(data.get("score", 0.0)),
 672                    "cluster_id_1": node_to_cluster.get(u_s),
 673                    "cluster_id_2": node_to_cluster.get(v_s),
 674                    "intra_cluster": node_to_cluster.get(u_s)
 675                    == node_to_cluster.get(v_s),
 676                }
 677            )
 678
 679        layout_df = pd.DataFrame(columns=["node_id", "x", "y"])
 680        if compute_layout and G.number_of_nodes() > 0:
 681            layout = self._compute_hierarchical_layout(
 682                G,
 683                communities,
 684                weight_attr=cluster_weight_attr,
 685                seed=layout_seed,
 686            )
 687            layout_df = pd.DataFrame(
 688                [
 689                    {"node_id": str(node_id), "x": coords["x"], "y": coords["y"]}
 690                    for node_id, coords in layout.items()
 691                ]
 692            )
 693
 694        artifact = {
 695            "metric": metric,
 696            "params": {
 697                "include_queries_only": include_queries_only,
 698                "score_threshold": score_threshold,
 699                "max_edges": max_edges,
 700                "max_nodes": max_nodes,
 701                "exclude_self": exclude_self,
 702                "directed": directed,
 703                "drop_components_without_queries": drop_components_without_queries,
 704                "drop_nodes_without_query_connection": drop_nodes_without_query_connection,
 705                "cluster_method": cluster_method,
 706                "cluster_weight_attr": cluster_weight_attr,
 707                "cluster_super_threshold": cluster_super_threshold,
 708                "cluster_sparsify_top_k": cluster_sparsify_top_k,
 709                "cluster_recursive_split": cluster_recursive_split,
 710                "cluster_min_size": cluster_min_size,
 711                "compute_layout": compute_layout,
 712                "layout_seed": layout_seed,
 713            },
 714            "node_table": pd.DataFrame(node_rows),
 715            "community_table": pd.DataFrame(community_rows),
 716            "edge_table": pd.DataFrame(edge_rows),
 717            "layout_table": layout_df,
 718            "schema_version": 1,
 719        }
 720
 721        self._ensure_cluster_cache()[metric] = artifact
 722
 723        return {
 724            "metric": metric,
 725            "n_nodes": G.number_of_nodes(),
 726            "n_edges": G.number_of_edges(),
 727            "n_clusters": int(artifact["community_table"].shape[0]),
 728            "score_threshold": float(score_threshold),
 729        }
 730
 731    def drop_network_clusters(self, metric: str | None = None) -> None:
 732        """Drop cached clustering artifacts while preserving similarity results.
 733
 734        Parameters
 735        ----------
 736        metric : str or None
 737            Metric cache key to remove.  When ``None``, clear all cached
 738            clustering artifacts.
 739        """
 740        cache = self._ensure_cluster_cache()
 741        if metric is None:
 742            cache.clear()
 743        else:
 744            cache.pop(metric, None)
 745
 746    def save_network_clusters(
 747        self, output_dir: str, metric: str, run_id: str | None = None
 748    ) -> dict[str, str]:
 749        """Persist computed cluster artifacts to CSV files.
 750
 751        Files written include node, community, edge, layout, and manifest CSVs.
 752        Library node IDs are exported using stable identifiers when available
 753        (for example ``spectra_id``) instead of internal index IDs.
 754
 755        Parameters
 756        ----------
 757        output_dir : str
 758            Output directory for CSV artifacts.
 759        metric : str
 760            Metric cache key to persist.
 761        run_id : str or None
 762            Optional suffix to disambiguate multiple runs.
 763
 764        Returns
 765        -------
 766        dict of str to str
 767            Paths for ``nodes``, ``communities``, ``edges``, ``layout``,
 768            and ``manifest`` files.
 769
 770        Raises
 771        ------
 772        RuntimeError
 773            If no cached clusters exist for *metric*.
 774        """
 775        cache = self._ensure_cluster_cache()
 776        artifact = cache.get(metric)
 777        if artifact is None:
 778            raise RuntimeError(
 779                f"No clusters available for metric '{metric}'. Call compute_network_clusters(...) first."
 780            )
 781
 782        out_dir = Path(output_dir)
 783        out_dir.mkdir(parents=True, exist_ok=True)
 784
 785        suffix = f"_{run_id}" if run_id else ""
 786        prefix = f"{metric}_clusters{suffix}"
 787
 788        node_path = out_dir / f"{prefix}_nodes.csv"
 789        community_path = out_dir / f"{prefix}_communities.csv"
 790        edge_path = out_dir / f"{prefix}_edges.csv"
 791        manifest_path = out_dir / f"{prefix}_manifest.csv"
 792        layout_path = out_dir / f"{prefix}_layout.csv"
 793
 794        query_id_set = set(getattr(self, "_all_query_ids", []) or [])
 795
 796        node_export_df = artifact["node_table"].copy()
 797        if "node_id" in node_export_df.columns:
 798            node_export_df["node_id"] = (
 799                node_export_df["node_id"]
 800                .astype(str)
 801                .map(
 802                    lambda node_id: self._export_cluster_node_id(node_id, query_id_set)
 803                )
 804            )
 805
 806        edge_export_df = artifact["edge_table"].copy()
 807        if "id1" in edge_export_df.columns:
 808            edge_export_df["id1"] = (
 809                edge_export_df["id1"]
 810                .astype(str)
 811                .map(
 812                    lambda node_id: self._export_cluster_node_id(node_id, query_id_set)
 813                )
 814            )
 815        if "id2" in edge_export_df.columns:
 816            edge_export_df["id2"] = (
 817                edge_export_df["id2"]
 818                .astype(str)
 819                .map(
 820                    lambda node_id: self._export_cluster_node_id(node_id, query_id_set)
 821                )
 822            )
 823
 824        layout_export_df = artifact["layout_table"].copy()
 825        if "node_id" in layout_export_df.columns:
 826            layout_export_df["node_id"] = (
 827                layout_export_df["node_id"]
 828                .astype(str)
 829                .map(
 830                    lambda node_id: self._export_cluster_node_id(node_id, query_id_set)
 831                )
 832            )
 833
 834        node_export_df.to_csv(node_path, index=False)
 835        artifact["community_table"].to_csv(community_path, index=False)
 836        edge_export_df.to_csv(edge_path, index=False)
 837        layout_export_df.to_csv(layout_path, index=False)
 838
 839        pd.DataFrame(
 840            [
 841                {
 842                    "schema_version": artifact.get("schema_version", 1),
 843                    "metric": metric,
 844                    "run_id": run_id or "",
 845                    "params_json": json.dumps(
 846                        artifact.get("params", {}), sort_keys=True
 847                    ),
 848                }
 849            ]
 850        ).to_csv(manifest_path, index=False)
 851
 852        return {
 853            "nodes": str(node_path),
 854            "communities": str(community_path),
 855            "edges": str(edge_path),
 856            "layout": str(layout_path),
 857            "manifest": str(manifest_path),
 858        }
 859
 860    def load_network_clusters(
 861        self, output_dir: str, metric: str, run_id: str | None = None
 862    ) -> dict[str, Any]:
 863        """Load cluster artifacts from CSV files into in-memory cache.
 864
 865        Parameters
 866        ----------
 867        output_dir : str
 868            Directory containing saved cluster CSV artifacts.
 869        metric : str
 870            Metric key to load.
 871        run_id : str or None
 872            Optional run suffix used when saving.
 873
 874        Returns
 875        -------
 876        dict
 877            Summary with ``metric``, ``n_nodes``, ``n_edges``, and
 878            ``n_clusters``.
 879
 880        Raises
 881        ------
 882        FileNotFoundError
 883            If required artifact files are missing.
 884        """
 885        out_dir = Path(output_dir)
 886        suffix = f"_{run_id}" if run_id else ""
 887        prefix = f"{metric}_clusters{suffix}"
 888
 889        node_path = out_dir / f"{prefix}_nodes.csv"
 890        community_path = out_dir / f"{prefix}_communities.csv"
 891        edge_path = out_dir / f"{prefix}_edges.csv"
 892        manifest_path = out_dir / f"{prefix}_manifest.csv"
 893        layout_path = out_dir / f"{prefix}_layout.csv"
 894
 895        if (
 896            not node_path.exists()
 897            or not community_path.exists()
 898            or not edge_path.exists()
 899        ):
 900            raise FileNotFoundError(
 901                f"Missing cluster artifact files for metric '{metric}' in '{output_dir}'."
 902            )
 903
 904        params = {}
 905        schema_version = 1
 906        if manifest_path.exists():
 907            mdf = pd.read_csv(manifest_path)
 908            if not mdf.empty:
 909                schema_version = int(mdf.iloc[0].get("schema_version", 1))
 910                params_json = mdf.iloc[0].get("params_json", "{}")
 911                try:
 912                    params = (
 913                        json.loads(params_json) if isinstance(params_json, str) else {}
 914                    )
 915                except (json.JSONDecodeError, TypeError, ValueError):
 916                    params = {}
 917
 918        artifact = {
 919            "metric": metric,
 920            "params": params,
 921            "schema_version": schema_version,
 922            "node_table": pd.read_csv(node_path),
 923            "community_table": pd.read_csv(community_path),
 924            "edge_table": pd.read_csv(edge_path),
 925            "layout_table": pd.read_csv(layout_path)
 926            if layout_path.exists()
 927            else pd.DataFrame(columns=["node_id", "x", "y"]),
 928        }
 929
 930        self._ensure_cluster_cache()[metric] = artifact
 931        return {
 932            "metric": metric,
 933            "n_nodes": int(artifact["node_table"].shape[0]),
 934            "n_edges": int(artifact["edge_table"].shape[0]),
 935            "n_clusters": int(artifact["community_table"].shape[0]),
 936        }
 937
 938    def _build_networkx_graph(
 939        self,
 940        metric: str = "entropy_similarity",
 941        *,
 942        include_queries_only: bool = True,
 943        score_threshold: float | None = None,
 944        max_edges: int | None = 500,
 945        max_nodes: int | None = None,
 946        exclude_self: bool = True,
 947        directed: bool = False,
 948        color_map: dict[str, str] | None = None,
 949        metadata: dict[str, dict] | None = None,
 950        library_label_field: str | Sequence[str] | None = None,
 951        library_node_attrs: Sequence[str] | None = None,
 952        drop_components_without_queries: bool = True,
 953        drop_nodes_without_query_connection: bool = True,
 954        layout_seed: int = 42,
 955        bypass_clustering: bool = True,
 956        separate_communities: bool = True,
 957    ) -> tuple[Any, dict | None, dict[str, str], float]:
 958        """Build a NetworkX graph + layout for static or interactive plotting.
 959
 960        Returns
 961        -------
 962        G : networkx.Graph
 963            Graph with node attrs ``label``, ``node_type``, optional community
 964            and viz styling fields, and edge attr ``score``.
 965        layout : dict or None
 966            Mapping ``node_id → {"x": float, "y": float}`` for drawing.
 967        palette : dict
 968            Color map for ``node_type`` values (query / library / unknown).
 969        score_threshold : float
 970            Effective score threshold used for edges.
 971        """
 972        if metric not in self.similarity_matrices:
 973            raise KeyError(
 974                f"Unknown metric '{metric}'. Available: {list(self.similarity_matrices.keys())}"
 975            )
 976
 977        nx = _import_networkx()
 978
 979        cache = self._ensure_cluster_cache()
 980        artifact = cache.get(metric)
 981        if not bypass_clustering and artifact is None:
 982            raise RuntimeError(
 983                f"No clusters available for metric '{metric}'. "
 984                "Call compute_network_clusters(...) first, or set bypass_clustering=True."
 985            )
 986
 987        if score_threshold is None:
 988            score_threshold = self._threshold_for(metric)
 989
 990        if bypass_clustering:
 991            edges = self._edges_to_dataframe(
 992                metric=metric,
 993                score_threshold=score_threshold,
 994                include_queries_only=include_queries_only,
 995                exclude_self=exclude_self,
 996            )
 997        else:
 998            edges = artifact["edge_table"][["id1", "id2", "score"]].copy()
 999            edges = edges[edges["score"] >= score_threshold]
1000
1001        if max_nodes is not None and not edges.empty:
1002            degree = (
1003                pd.concat([edges["id1"], edges["id2"]]).value_counts().head(max_nodes)
1004            )
1005            top_nodes = set(degree.index)
1006            edges = edges[edges["id1"].isin(top_nodes) & edges["id2"].isin(top_nodes)]
1007
1008        if max_edges is not None and not edges.empty:
1009            edges = edges.nlargest(max_edges, "score")
1010
1011        G = nx.DiGraph() if directed else nx.Graph()
1012
1013        palette = dict(self._DEFAULT_COLOR_MAP)
1014        if color_map:
1015            palette.update(color_map)
1016
1017        query_id_set = set(self._all_query_ids)
1018
1019        if not edges.empty:
1020            node_ids = (
1021                pd.concat([edges["id1"], edges["id2"]]).drop_duplicates().tolist()
1022            )
1023
1024            for node_id in node_ids:
1025                node_kind = "query" if node_id in query_id_set else "library"
1026                lib_record = (
1027                    self._library_record_for_node(node_id)
1028                    if node_kind == "library"
1029                    else None
1030                )
1031
1032                label = (
1033                    self._resolve_library_label(
1034                        node_id=node_id,
1035                        record=lib_record,
1036                        library_label_field=library_label_field,
1037                    )
1038                    if node_kind == "library"
1039                    else (node_id[:20] + "..." if len(node_id) > 20 else node_id)
1040                )
1041
1042                attrs: dict = {
1043                    "label": label,
1044                    "node_type": node_kind,
1045                }
1046
1047                if node_kind == "library" and lib_record and library_node_attrs:
1048                    for field in library_node_attrs:
1049                        if field in lib_record:
1050                            attrs[field] = self._as_node_attr_value(
1051                                lib_record.get(field)
1052                            )
1053
1054                if metadata:
1055                    attrs.update(metadata.get(node_id, {}))
1056
1057                G.add_node(node_id, **attrs)
1058
1059            for row in edges.itertuples(index=False):
1060                G.add_edge(row.id1, row.id2, score=float(row.score))
1061
1062        if bypass_clustering:
1063            self._apply_query_connectivity_filters(
1064                G,
1065                nx=nx,
1066                query_id_set=query_id_set,
1067                drop_components_without_queries=drop_components_without_queries,
1068                drop_nodes_without_query_connection=drop_nodes_without_query_connection,
1069            )
1070
1071        # Default node styling (shared by static and interactive plots)
1072        for node in G.nodes:
1073            node_type = G.nodes[node].get("node_type", "unknown")
1074            if node_type == "query":
1075                G.nodes[node]["viz_size"] = 2
1076                G.nodes[node]["viz_border_color"] = "#000000"
1077                G.nodes[node]["viz_border_size"] = 3
1078            else:
1079                G.nodes[node]["viz_size"] = 1
1080                G.nodes[node]["viz_border_color"] = "#00000000"
1081                G.nodes[node]["viz_border_size"] = 0
1082
1083        community_groups: list[set] = []
1084        if (
1085            not bypass_clustering
1086            and artifact is not None
1087            and not artifact["node_table"].empty
1088        ):
1089            node_table = artifact["node_table"].set_index("node_id")
1090            for node in list(G.nodes):
1091                node_s = str(node)
1092                if node_s not in node_table.index:
1093                    continue
1094                row = node_table.loc[node_s]
1095                G.nodes[node]["community_id"] = row.get("cluster_id")
1096                G.nodes[node]["community_level"] = int(row.get("community_level", 0))
1097                G.nodes[node]["community_size"] = int(row.get("community_size", 1))
1098                G.nodes[node]["query_count_in_community"] = int(
1099                    row.get("query_count_in_community", 0)
1100                )
1101
1102            by_cluster = node_table.reset_index().groupby("cluster_id")["node_id"]
1103            community_groups = [set(group.tolist()) for _, group in by_cluster]
1104
1105            if separate_communities:
1106                inter_edges = []
1107                for u, v in G.edges():
1108                    cu = G.nodes[u].get("community_id")
1109                    cv = G.nodes[v].get("community_id")
1110                    if cu is not None and cv is not None and cu != cv:
1111                        inter_edges.append((u, v))
1112                if inter_edges:
1113                    G.remove_edges_from(inter_edges)
1114
1115        layout: dict | None = None
1116        if G.number_of_nodes() > 0:
1117            if (
1118                not bypass_clustering
1119                and artifact is not None
1120                and not separate_communities
1121                and not artifact["layout_table"].empty
1122            ):
1123                layout = {
1124                    str(row.node_id): {"x": float(row.x), "y": float(row.y)}
1125                    for row in artifact["layout_table"].itertuples(index=False)
1126                    if G.has_node(str(row.node_id))
1127                }
1128                if len(layout) != G.number_of_nodes():
1129                    pos = nx.spring_layout(
1130                        G,
1131                        seed=layout_seed,
1132                        weight="score",
1133                        k=2.0 / max(G.number_of_nodes() ** 0.5, 1),
1134                    )
1135                    for node, (x, y) in pos.items():
1136                        layout.setdefault(str(node), {"x": float(x), "y": float(y)})
1137            elif not bypass_clustering and artifact is not None and community_groups:
1138                present_groups = []
1139                for group in community_groups:
1140                    nodes_present = {n for n in group if G.has_node(n)}
1141                    if nodes_present:
1142                        present_groups.append(nodes_present)
1143                layout = self._compute_hierarchical_layout(
1144                    G,
1145                    present_groups,
1146                    weight_attr="score",
1147                    seed=layout_seed,
1148                )
1149            else:
1150                pos = nx.spring_layout(
1151                    G,
1152                    seed=layout_seed,
1153                    weight="score",
1154                    k=2.0 / max(G.number_of_nodes() ** 0.5, 1),
1155                )
1156                layout = {
1157                    str(n): {"x": float(x), "y": float(y)} for n, (x, y) in pos.items()
1158                }
1159
1160        return G, layout, palette, float(score_threshold)
1161
1162    def plot_network(
1163        self,
1164        metric: str = "entropy_similarity",
1165        *,
1166        ax=None,
1167        path: str | None = None,
1168        return_fig: bool = False,
1169        include_queries_only: bool = True,
1170        score_threshold: float | None = None,
1171        max_edges: int | None = 500,
1172        max_nodes: int | None = None,
1173        exclude_self: bool = True,
1174        drop_components_without_queries: bool = True,
1175        drop_nodes_without_query_connection: bool = True,
1176        layout_seed: int = 42,
1177        bypass_clustering: bool = True,
1178        separate_communities: bool = True,
1179        color_map: dict[str, str] | None = None,
1180        library_label_field: str | Sequence[str] | None = None,
1181        show_labels: bool = True,
1182        label_max_nodes: int = 40,
1183        node_size_query: float = 90,
1184        node_size_library: float = 55,
1185        figsize: tuple[float, float] = (9, 7),
1186        title: str | None = None,
1187        dpi: int = 150,
1188    ):
1189        """Draw a static matplotlib snapshot of the molecular network.
1190
1191        Portable for Jupyter notebooks rendered on GitLab/GitHub (PNG output).
1192        For interactive HTML exploration, use :meth:`plot_interactive_network`
1193        (requires ``ipysigma`` / ``corems[networking]``).
1194
1195        Parameters
1196        ----------
1197        metric : str
1198            Similarity metric key. Default ``"entropy_similarity"``.
1199        ax : matplotlib.axes.Axes, optional
1200            Axes to draw on. If None, a new figure is created.
1201        path : str, optional
1202            If set, save the figure to this path (``savefig``).
1203        return_fig : bool
1204            If True, return the ``Figure`` and do not call ``plt.show()``.
1205            Default False.
1206        include_queries_only, score_threshold, max_edges, max_nodes, …
1207            Edge/node filtering and layout options (see
1208            :meth:`plot_interactive_network`).
1209        bypass_clustering : bool
1210            If True (default), plot the raw filtered graph without requiring
1211            precomputed clusters.
1212        show_labels : bool
1213            Draw node labels (capped by *label_max_nodes*). Default True.
1214        label_max_nodes : int
1215            Maximum number of labels to draw (highest-degree nodes). Default 40.
1216        node_size_query, node_size_library : float
1217            Matplotlib scatter sizes for query vs library nodes.
1218        figsize : tuple of float
1219            Figure size when *ax* is None. Default ``(9, 7)``.
1220        title : str, optional
1221            Axes title. Default summarizes metric, threshold, and edge count.
1222        dpi : int
1223            DPI used when saving *path*. Default 150.
1224
1225        Returns
1226        -------
1227        matplotlib.figure.Figure or None
1228            The figure if *return_fig* is True, otherwise None.
1229        """
1230        import matplotlib.pyplot as plt
1231        from matplotlib.lines import Line2D
1232
1233        G, layout, palette, thr = self._build_networkx_graph(
1234            metric=metric,
1235            include_queries_only=include_queries_only,
1236            score_threshold=score_threshold,
1237            max_edges=max_edges,
1238            max_nodes=max_nodes,
1239            exclude_self=exclude_self,
1240            directed=False,
1241            color_map=color_map,
1242            library_label_field=library_label_field,
1243            drop_components_without_queries=drop_components_without_queries,
1244            drop_nodes_without_query_connection=drop_nodes_without_query_connection,
1245            layout_seed=layout_seed,
1246            bypass_clustering=bypass_clustering,
1247            separate_communities=separate_communities,
1248        )
1249
1250        if ax is None:
1251            fig, ax = plt.subplots(figsize=figsize)
1252        else:
1253            fig = ax.figure
1254
1255        if layout is None or G.number_of_nodes() == 0:
1256            ax.text(0.5, 0.5, "No network edges to plot", ha="center", va="center")
1257            ax.set_axis_off()
1258        else:
1259            pos = {
1260                n: (float(layout[str(n)]["x"]), float(layout[str(n)]["y"]))
1261                for n in G.nodes
1262                if str(n) in layout
1263            }
1264            # Fallback positions for any missing layout keys
1265            for n in G.nodes:
1266                if n not in pos:
1267                    pos[n] = (0.0, 0.0)
1268
1269            scores = [float(d.get("score", 0.0)) for _, _, d in G.edges(data=True)]
1270            smin = min(scores) if scores else 0.0
1271            smax = max(scores) if scores else 1.0
1272            span = (smax - smin) + 1e-12
1273
1274            for u, v, data in G.edges(data=True):
1275                x0, y0 = pos[u]
1276                x1, y1 = pos[v]
1277                score = float(data.get("score", 0.0))
1278                lw = 0.5 + 2.5 * ((score - smin) / span)
1279                ax.plot([x0, x1], [y0, y1], color="0.65", lw=lw, zorder=1, alpha=0.85)
1280
1281            for n, (x, y) in pos.items():
1282                is_query = G.nodes[n].get("node_type") == "query"
1283                ax.scatter(
1284                    [x],
1285                    [y],
1286                    s=node_size_query if is_query else node_size_library,
1287                    c=palette.get("query" if is_query else "library", "#95a5a6"),
1288                    edgecolors="white",
1289                    linewidths=0.6,
1290                    zorder=2,
1291                )
1292
1293            if show_labels and G.number_of_nodes() > 0:
1294                degrees = dict(G.degree())
1295                label_nodes = sorted(degrees, key=lambda n: (-degrees[n], str(n)))[
1296                    : max(0, int(label_max_nodes))
1297                ]
1298                for n in label_nodes:
1299                    x, y = pos[n]
1300                    label = str(G.nodes[n].get("label", n))
1301                    if len(label) > 18:
1302                        label = label[:15] + "…"
1303                    ax.text(x, y, label, fontsize=7, ha="center", va="bottom", zorder=3)
1304
1305            legend = [
1306                Line2D(
1307                    [0],
1308                    [0],
1309                    marker="o",
1310                    color="w",
1311                    markerfacecolor=palette.get("query", "#e74c3c"),
1312                    markersize=9,
1313                    label="Experimental MS2 (query)",
1314                ),
1315                Line2D(
1316                    [0],
1317                    [0],
1318                    marker="o",
1319                    color="w",
1320                    markerfacecolor=palette.get("library", "#3498db"),
1321                    markersize=8,
1322                    label="Library spectrum",
1323                ),
1324            ]
1325            ax.legend(handles=legend, loc="best", frameon=True)
1326            ax.set_axis_off()
1327
1328        if title is None:
1329            title = (
1330                f"Molecular network ({metric}, threshold={thr:g}, "
1331                f"{G.number_of_edges()} edges)"
1332            )
1333        ax.set_title(title)
1334        fig.tight_layout()
1335
1336        if path is not None:
1337            fig.savefig(path, dpi=dpi, bbox_inches="tight")
1338
1339        if return_fig:
1340            return fig
1341
1342        # Prefer IPython Image display so notebooks capture PNG under Agg / headless CI
1343        try:
1344            import io
1345
1346            from IPython import get_ipython
1347            from IPython.display import Image, display
1348
1349            if get_ipython() is not None:
1350                buf = io.BytesIO()
1351                fig.savefig(buf, format="png", dpi=dpi, bbox_inches="tight")
1352                display(Image(data=buf.getvalue()))
1353                plt.close(fig)
1354                return None
1355        except ImportError:
1356            pass
1357
1358        plt.show()
1359        return None
1360
1361    def plot_interactive_network(
1362        self,
1363        metric: str = "entropy_similarity",
1364        out_path: str = "network.html",
1365        *,
1366        include_queries_only: bool = True,
1367        score_threshold: float | None = None,
1368        max_edges: int | None = 500,
1369        max_nodes: int | None = None,
1370        exclude_self: bool = True,
1371        directed: bool = False,
1372        color_map: dict[str, str] | None = None,
1373        sigma_options: dict | None = None,
1374        metadata: dict[str, dict] | None = None,
1375        library_label_field: str | Sequence[str] | None = None,
1376        library_node_attrs: Sequence[str] | None = None,
1377        drop_components_without_queries: bool = True,
1378        drop_nodes_without_query_connection: bool = True,
1379        layout_seed: int = 42,
1380        bypass_clustering: bool = False,
1381        separate_communities: bool = True,
1382        display_in_notebook: bool = False,
1383        iframe_height: int = 650,
1384    ) -> str:
1385        """Write an interactive ipysigma HTML network visualization.
1386
1387        Prefer :meth:`plot_network` for static snapshots that render on
1388        GitLab/GitHub notebook previews. This method requires **ipysigma**
1389        (``pip install "corems[networking]"``).
1390
1391        Parameters
1392        ----------
1393        out_path : str
1394            Output HTML file path.
1395        separate_communities : bool
1396            When plotting from precomputed clusters (``bypass_clustering=False``),
1397            remove inter-community edges so each community renders as a separate
1398            subnetwork.  Default ``True``.
1399        display_in_notebook : bool
1400            If ``True``, also embed the HTML in a local Jupyter frontend via a
1401            data-URI iframe.  Does **not** render on GitLab/GitHub notebook
1402            previews.  Default ``False``.
1403        iframe_height : int
1404            Pixel height of the notebook iframe when *display_in_notebook* is
1405            True.  Default ``650``.
1406
1407        Returns
1408        -------
1409        str
1410            Path to the written HTML file.
1411        """
1412        try:
1413            from ipysigma import Sigma
1414        except ImportError as exc:
1415            raise ImportError(
1416                "ipysigma is required for plot_interactive_network(). "
1417                'Install with: pip install "corems[networking]" '
1418                "(or: pip install ipysigma)"
1419            ) from exc
1420
1421        G, layout, palette, _thr = self._build_networkx_graph(
1422            metric=metric,
1423            include_queries_only=include_queries_only,
1424            score_threshold=score_threshold,
1425            max_edges=max_edges,
1426            max_nodes=max_nodes,
1427            exclude_self=exclude_self,
1428            directed=directed,
1429            color_map=color_map,
1430            metadata=metadata,
1431            library_label_field=library_label_field,
1432            library_node_attrs=library_node_attrs,
1433            drop_components_without_queries=drop_components_without_queries,
1434            drop_nodes_without_query_connection=drop_nodes_without_query_connection,
1435            layout_seed=layout_seed,
1436            bypass_clustering=bypass_clustering,
1437            separate_communities=separate_communities,
1438        )
1439
1440        cache = self._ensure_cluster_cache()
1441        artifact = cache.get(metric)
1442        clustered_mode = (
1443            not bypass_clustering and artifact is not None and G.number_of_nodes() > 0
1444        )
1445        write_kwargs: dict = {
1446            "layout": layout,
1447            "node_color": "community_id" if clustered_mode else "node_type",
1448            "node_size": "viz_size",
1449            "node_size_range": (8, 18),
1450            "node_border_color": "viz_border_color",
1451            "node_border_size": "viz_border_size",
1452            "node_border_size_range": (0, 4),
1453            "default_edge_type": "line",
1454            "default_edge_color": "#aaa",
1455            "fullscreen": True,
1456        }
1457        if not clustered_mode:
1458            write_kwargs["node_color_palette"] = palette
1459        if sigma_options:
1460            write_kwargs.update(sigma_options)
1461
1462        out = Path(out_path)
1463        out.parent.mkdir(parents=True, exist_ok=True)
1464        Sigma.write_html(G, str(out), **write_kwargs)
1465
1466        if display_in_notebook:
1467            self._display_network_html_in_notebook(out, height=iframe_height)
1468
1469        return str(out)
1470
1471    @staticmethod
1472    def _display_network_html_in_notebook(
1473        html_path: Path, *, height: int = 650
1474    ) -> None:
1475        """Embed a written Sigma HTML file in a Jupyter notebook output cell.
1476
1477        Uses a base64 data-URI iframe so the notebook remains portable (no
1478        machine-local ``file://`` paths).  No-ops if IPython is unavailable.
1479        Note: GitLab/GitHub notebook previews do not execute this iframe.
1480        """
1481        try:
1482            from base64 import b64encode
1483
1484            from IPython.display import IFrame, display
1485        except ImportError:
1486            return
1487
1488        html_bytes = Path(html_path).read_bytes()
1489        html_b64 = b64encode(html_bytes).decode("ascii")
1490        display(
1491            IFrame(
1492                src=f"data:text/html;base64,{html_b64}",
1493                width="100%",
1494                height=int(height),
1495            )
1496        )

Mixin that adds static and interactive network plotting.

def compute_network_clusters( self, metric: str = 'entropy_similarity', *, include_queries_only: bool = True, score_threshold: float | None = None, max_edges: int | None = 500, max_nodes: int | None = None, exclude_self: bool = True, directed: bool = False, drop_components_without_queries: bool = True, drop_nodes_without_query_connection: bool = True, cluster_method: str = 'weighted_greedy_modularity', cluster_weight_attr: str = 'score', cluster_super_threshold: int = 400, cluster_sparsify_top_k: int = 8, cluster_recursive_split: bool = True, cluster_min_size: int = 2, compute_layout: bool = True, layout_seed: int = 42) -> dict[str, typing.Any]:
462    def compute_network_clusters(
463        self,
464        metric: str = "entropy_similarity",
465        *,
466        include_queries_only: bool = True,
467        score_threshold: float | None = None,
468        max_edges: int | None = 500,
469        max_nodes: int | None = None,
470        exclude_self: bool = True,
471        directed: bool = False,
472        drop_components_without_queries: bool = True,
473        drop_nodes_without_query_connection: bool = True,
474        cluster_method: str = "weighted_greedy_modularity",
475        cluster_weight_attr: str = "score",
476        cluster_super_threshold: int = 400,
477        cluster_sparsify_top_k: int = 8,
478        cluster_recursive_split: bool = True,
479        cluster_min_size: int = 2,
480        compute_layout: bool = True,
481        layout_seed: int = 42,
482    ) -> dict[str, Any]:
483        """Compute and cache clustering artifacts for one similarity metric.
484
485        This stage is separate from plotting.  It builds a graph from the
486        selected similarity matrix, applies query-connectivity filters,
487        computes communities, and stores node/community/edge/layout tables in
488        an in-memory artifact cache for later plotting or export.
489
490        Parameters
491        ----------
492        metric : str
493            Similarity metric key from ``self.similarity_matrices``.
494        include_queries_only : bool
495            Use query-focused edge filtering during edge extraction.
496        score_threshold : float or None
497            Minimum similarity score to include; ``None`` uses
498            ``_threshold_for(metric)``.
499        max_edges : int or None
500            Keep top-N edges by score after filtering.
501        max_nodes : int or None
502            Keep edges whose endpoints are within highest-degree N nodes.
503        exclude_self : bool
504            Drop self-loop edges.
505        directed : bool
506            Build directed graph when ``True``.
507        drop_components_without_queries : bool
508            Remove connected components with no query nodes.
509        drop_nodes_without_query_connection : bool
510            Keep only query nodes and one-hop query neighbors.
511        cluster_method : str
512            Community method; currently ``"weighted_greedy_modularity"``.
513        cluster_weight_attr : str
514            Edge attribute used as clustering weight.
515        cluster_super_threshold : int
516            Threshold above which recursive splitting can apply.
517        cluster_sparsify_top_k : int
518            Top-k edge sparsification for oversized community refinement.
519        cluster_recursive_split : bool
520            Enable recursive refinement of oversized communities.
521        cluster_min_size : int
522            Reserved for future use; kept for API stability.
523        compute_layout : bool
524            Compute and cache hierarchical node coordinates.
525        layout_seed : int
526            Random seed for deterministic layout generation.
527
528        Returns
529        -------
530        dict
531            Summary with ``metric``, ``n_nodes``, ``n_edges``, ``n_clusters``,
532            and ``score_threshold``.
533
534        Raises
535        ------
536        KeyError
537            If *metric* is unknown.
538        ValueError
539            If *cluster_method* is unsupported.
540        """
541        if metric not in self.similarity_matrices:
542            raise KeyError(
543                f"Unknown metric '{metric}'. Available: {list(self.similarity_matrices.keys())}"
544            )
545
546        if cluster_method != "weighted_greedy_modularity":
547            raise ValueError(
548                "Unsupported cluster_method. "
549                "Currently supported: ['weighted_greedy_modularity']"
550            )
551
552        nx = _import_networkx()
553
554        if score_threshold is None:
555            score_threshold = self._threshold_for(metric)
556
557        edges = self._edges_to_dataframe(
558            metric=metric,
559            score_threshold=score_threshold,
560            include_queries_only=include_queries_only,
561            exclude_self=exclude_self,
562        )
563
564        if max_nodes is not None and not edges.empty:
565            degree = (
566                pd.concat([edges["id1"], edges["id2"]]).value_counts().head(max_nodes)
567            )
568            top_nodes = set(degree.index)
569            edges = edges[edges["id1"].isin(top_nodes) & edges["id2"].isin(top_nodes)]
570
571        if max_edges is not None and not edges.empty:
572            edges = edges.nlargest(max_edges, "score")
573
574        G = nx.DiGraph() if directed else nx.Graph()
575        query_id_set = set(self._all_query_ids)
576
577        if not edges.empty:
578            node_ids = (
579                pd.concat([edges["id1"], edges["id2"]]).drop_duplicates().tolist()
580            )
581            for node_id in node_ids:
582                G.add_node(
583                    node_id,
584                    node_type=("query" if node_id in query_id_set else "library"),
585                )
586            for row in edges.itertuples(index=False):
587                G.add_edge(row.id1, row.id2, score=float(row.score))
588
589        self._apply_query_connectivity_filters(
590            G,
591            nx=nx,
592            query_id_set=query_id_set,
593            drop_components_without_queries=drop_components_without_queries,
594            drop_nodes_without_query_connection=drop_nodes_without_query_connection,
595        )
596
597        communities: list[set] = []
598        if G.number_of_nodes() > 0:
599            communities = self._detect_weighted_communities(
600                G.to_undirected(),
601                weight_attr=cluster_weight_attr,
602                super_threshold=cluster_super_threshold,
603                sparsify_top_k=cluster_sparsify_top_k,
604                recursive_split=cluster_recursive_split,
605            )
606
607        node_rows = []
608        community_rows = []
609        node_to_cluster: dict[str, str] = {}
610
611        for comm_idx, nodes in enumerate(communities):
612            cluster_id = f"C{comm_idx:03d}"
613            query_count = sum(1 for n in nodes if n in query_id_set)
614            community_rows.append(
615                {
616                    "cluster_id": cluster_id,
617                    "community_level": 0,
618                    "size": len(nodes),
619                    "query_count_in_community": query_count,
620                }
621            )
622            for node in sorted(nodes, key=str):
623                node_to_cluster[str(node)] = cluster_id
624                node_rows.append(
625                    {
626                        "node_id": str(node),
627                        "cluster_id": cluster_id,
628                        "community_level": 0,
629                        "community_size": len(nodes),
630                        "query_count_in_community": query_count,
631                        "node_type": G.nodes[node].get("node_type", "unknown"),
632                    }
633                )
634
635        # Ensure isolated nodes still receive a cluster assignment.
636        if G.number_of_nodes() > 0:
637            for node in G.nodes:
638                node_s = str(node)
639                if node_s in node_to_cluster:
640                    continue
641                cluster_id = f"C{len(community_rows):03d}"
642                node_to_cluster[node_s] = cluster_id
643                query_count = 1 if node in query_id_set else 0
644                community_rows.append(
645                    {
646                        "cluster_id": cluster_id,
647                        "community_level": 0,
648                        "size": 1,
649                        "query_count_in_community": query_count,
650                    }
651                )
652                node_rows.append(
653                    {
654                        "node_id": node_s,
655                        "cluster_id": cluster_id,
656                        "community_level": 0,
657                        "community_size": 1,
658                        "query_count_in_community": query_count,
659                        "node_type": G.nodes[node].get("node_type", "unknown"),
660                    }
661                )
662
663        edge_rows = []
664        for u, v, data in G.edges(data=True):
665            u_s = str(u)
666            v_s = str(v)
667            edge_rows.append(
668                {
669                    "id1": u_s,
670                    "id2": v_s,
671                    "score": float(data.get("score", 0.0)),
672                    "cluster_id_1": node_to_cluster.get(u_s),
673                    "cluster_id_2": node_to_cluster.get(v_s),
674                    "intra_cluster": node_to_cluster.get(u_s)
675                    == node_to_cluster.get(v_s),
676                }
677            )
678
679        layout_df = pd.DataFrame(columns=["node_id", "x", "y"])
680        if compute_layout and G.number_of_nodes() > 0:
681            layout = self._compute_hierarchical_layout(
682                G,
683                communities,
684                weight_attr=cluster_weight_attr,
685                seed=layout_seed,
686            )
687            layout_df = pd.DataFrame(
688                [
689                    {"node_id": str(node_id), "x": coords["x"], "y": coords["y"]}
690                    for node_id, coords in layout.items()
691                ]
692            )
693
694        artifact = {
695            "metric": metric,
696            "params": {
697                "include_queries_only": include_queries_only,
698                "score_threshold": score_threshold,
699                "max_edges": max_edges,
700                "max_nodes": max_nodes,
701                "exclude_self": exclude_self,
702                "directed": directed,
703                "drop_components_without_queries": drop_components_without_queries,
704                "drop_nodes_without_query_connection": drop_nodes_without_query_connection,
705                "cluster_method": cluster_method,
706                "cluster_weight_attr": cluster_weight_attr,
707                "cluster_super_threshold": cluster_super_threshold,
708                "cluster_sparsify_top_k": cluster_sparsify_top_k,
709                "cluster_recursive_split": cluster_recursive_split,
710                "cluster_min_size": cluster_min_size,
711                "compute_layout": compute_layout,
712                "layout_seed": layout_seed,
713            },
714            "node_table": pd.DataFrame(node_rows),
715            "community_table": pd.DataFrame(community_rows),
716            "edge_table": pd.DataFrame(edge_rows),
717            "layout_table": layout_df,
718            "schema_version": 1,
719        }
720
721        self._ensure_cluster_cache()[metric] = artifact
722
723        return {
724            "metric": metric,
725            "n_nodes": G.number_of_nodes(),
726            "n_edges": G.number_of_edges(),
727            "n_clusters": int(artifact["community_table"].shape[0]),
728            "score_threshold": float(score_threshold),
729        }

Compute and cache clustering artifacts for one similarity metric.

This stage is separate from plotting. It builds a graph from the selected similarity matrix, applies query-connectivity filters, computes communities, and stores node/community/edge/layout tables in an in-memory artifact cache for later plotting or export.

Parameters
  • metric (str): Similarity metric key from self.similarity_matrices.
  • include_queries_only (bool): Use query-focused edge filtering during edge extraction.
  • score_threshold (float or None): Minimum similarity score to include; None uses _threshold_for(metric).
  • max_edges (int or None): Keep top-N edges by score after filtering.
  • max_nodes (int or None): Keep edges whose endpoints are within highest-degree N nodes.
  • exclude_self (bool): Drop self-loop edges.
  • directed (bool): Build directed graph when True.
  • drop_components_without_queries (bool): Remove connected components with no query nodes.
  • drop_nodes_without_query_connection (bool): Keep only query nodes and one-hop query neighbors.
  • cluster_method (str): Community method; currently "weighted_greedy_modularity".
  • cluster_weight_attr (str): Edge attribute used as clustering weight.
  • cluster_super_threshold (int): Threshold above which recursive splitting can apply.
  • cluster_sparsify_top_k (int): Top-k edge sparsification for oversized community refinement.
  • cluster_recursive_split (bool): Enable recursive refinement of oversized communities.
  • cluster_min_size (int): Reserved for future use; kept for API stability.
  • compute_layout (bool): Compute and cache hierarchical node coordinates.
  • layout_seed (int): Random seed for deterministic layout generation.
Returns
  • dict: Summary with metric, n_nodes, n_edges, n_clusters, and score_threshold.
Raises
  • KeyError: If metric is unknown.
  • ValueError: If cluster_method is unsupported.
def drop_network_clusters(self, metric: str | None = None) -> None:
731    def drop_network_clusters(self, metric: str | None = None) -> None:
732        """Drop cached clustering artifacts while preserving similarity results.
733
734        Parameters
735        ----------
736        metric : str or None
737            Metric cache key to remove.  When ``None``, clear all cached
738            clustering artifacts.
739        """
740        cache = self._ensure_cluster_cache()
741        if metric is None:
742            cache.clear()
743        else:
744            cache.pop(metric, None)

Drop cached clustering artifacts while preserving similarity results.

Parameters
  • metric (str or None): Metric cache key to remove. When None, clear all cached clustering artifacts.
def save_network_clusters( self, output_dir: str, metric: str, run_id: str | None = None) -> dict[str, str]:
746    def save_network_clusters(
747        self, output_dir: str, metric: str, run_id: str | None = None
748    ) -> dict[str, str]:
749        """Persist computed cluster artifacts to CSV files.
750
751        Files written include node, community, edge, layout, and manifest CSVs.
752        Library node IDs are exported using stable identifiers when available
753        (for example ``spectra_id``) instead of internal index IDs.
754
755        Parameters
756        ----------
757        output_dir : str
758            Output directory for CSV artifacts.
759        metric : str
760            Metric cache key to persist.
761        run_id : str or None
762            Optional suffix to disambiguate multiple runs.
763
764        Returns
765        -------
766        dict of str to str
767            Paths for ``nodes``, ``communities``, ``edges``, ``layout``,
768            and ``manifest`` files.
769
770        Raises
771        ------
772        RuntimeError
773            If no cached clusters exist for *metric*.
774        """
775        cache = self._ensure_cluster_cache()
776        artifact = cache.get(metric)
777        if artifact is None:
778            raise RuntimeError(
779                f"No clusters available for metric '{metric}'. Call compute_network_clusters(...) first."
780            )
781
782        out_dir = Path(output_dir)
783        out_dir.mkdir(parents=True, exist_ok=True)
784
785        suffix = f"_{run_id}" if run_id else ""
786        prefix = f"{metric}_clusters{suffix}"
787
788        node_path = out_dir / f"{prefix}_nodes.csv"
789        community_path = out_dir / f"{prefix}_communities.csv"
790        edge_path = out_dir / f"{prefix}_edges.csv"
791        manifest_path = out_dir / f"{prefix}_manifest.csv"
792        layout_path = out_dir / f"{prefix}_layout.csv"
793
794        query_id_set = set(getattr(self, "_all_query_ids", []) or [])
795
796        node_export_df = artifact["node_table"].copy()
797        if "node_id" in node_export_df.columns:
798            node_export_df["node_id"] = (
799                node_export_df["node_id"]
800                .astype(str)
801                .map(
802                    lambda node_id: self._export_cluster_node_id(node_id, query_id_set)
803                )
804            )
805
806        edge_export_df = artifact["edge_table"].copy()
807        if "id1" in edge_export_df.columns:
808            edge_export_df["id1"] = (
809                edge_export_df["id1"]
810                .astype(str)
811                .map(
812                    lambda node_id: self._export_cluster_node_id(node_id, query_id_set)
813                )
814            )
815        if "id2" in edge_export_df.columns:
816            edge_export_df["id2"] = (
817                edge_export_df["id2"]
818                .astype(str)
819                .map(
820                    lambda node_id: self._export_cluster_node_id(node_id, query_id_set)
821                )
822            )
823
824        layout_export_df = artifact["layout_table"].copy()
825        if "node_id" in layout_export_df.columns:
826            layout_export_df["node_id"] = (
827                layout_export_df["node_id"]
828                .astype(str)
829                .map(
830                    lambda node_id: self._export_cluster_node_id(node_id, query_id_set)
831                )
832            )
833
834        node_export_df.to_csv(node_path, index=False)
835        artifact["community_table"].to_csv(community_path, index=False)
836        edge_export_df.to_csv(edge_path, index=False)
837        layout_export_df.to_csv(layout_path, index=False)
838
839        pd.DataFrame(
840            [
841                {
842                    "schema_version": artifact.get("schema_version", 1),
843                    "metric": metric,
844                    "run_id": run_id or "",
845                    "params_json": json.dumps(
846                        artifact.get("params", {}), sort_keys=True
847                    ),
848                }
849            ]
850        ).to_csv(manifest_path, index=False)
851
852        return {
853            "nodes": str(node_path),
854            "communities": str(community_path),
855            "edges": str(edge_path),
856            "layout": str(layout_path),
857            "manifest": str(manifest_path),
858        }

Persist computed cluster artifacts to CSV files.

Files written include node, community, edge, layout, and manifest CSVs. Library node IDs are exported using stable identifiers when available (for example spectra_id) instead of internal index IDs.

Parameters
  • output_dir (str): Output directory for CSV artifacts.
  • metric (str): Metric cache key to persist.
  • run_id (str or None): Optional suffix to disambiguate multiple runs.
Returns
  • dict of str to str: Paths for nodes, communities, edges, layout, and manifest files.
Raises
  • RuntimeError: If no cached clusters exist for metric.
def load_network_clusters( self, output_dir: str, metric: str, run_id: str | None = None) -> dict[str, typing.Any]:
860    def load_network_clusters(
861        self, output_dir: str, metric: str, run_id: str | None = None
862    ) -> dict[str, Any]:
863        """Load cluster artifacts from CSV files into in-memory cache.
864
865        Parameters
866        ----------
867        output_dir : str
868            Directory containing saved cluster CSV artifacts.
869        metric : str
870            Metric key to load.
871        run_id : str or None
872            Optional run suffix used when saving.
873
874        Returns
875        -------
876        dict
877            Summary with ``metric``, ``n_nodes``, ``n_edges``, and
878            ``n_clusters``.
879
880        Raises
881        ------
882        FileNotFoundError
883            If required artifact files are missing.
884        """
885        out_dir = Path(output_dir)
886        suffix = f"_{run_id}" if run_id else ""
887        prefix = f"{metric}_clusters{suffix}"
888
889        node_path = out_dir / f"{prefix}_nodes.csv"
890        community_path = out_dir / f"{prefix}_communities.csv"
891        edge_path = out_dir / f"{prefix}_edges.csv"
892        manifest_path = out_dir / f"{prefix}_manifest.csv"
893        layout_path = out_dir / f"{prefix}_layout.csv"
894
895        if (
896            not node_path.exists()
897            or not community_path.exists()
898            or not edge_path.exists()
899        ):
900            raise FileNotFoundError(
901                f"Missing cluster artifact files for metric '{metric}' in '{output_dir}'."
902            )
903
904        params = {}
905        schema_version = 1
906        if manifest_path.exists():
907            mdf = pd.read_csv(manifest_path)
908            if not mdf.empty:
909                schema_version = int(mdf.iloc[0].get("schema_version", 1))
910                params_json = mdf.iloc[0].get("params_json", "{}")
911                try:
912                    params = (
913                        json.loads(params_json) if isinstance(params_json, str) else {}
914                    )
915                except (json.JSONDecodeError, TypeError, ValueError):
916                    params = {}
917
918        artifact = {
919            "metric": metric,
920            "params": params,
921            "schema_version": schema_version,
922            "node_table": pd.read_csv(node_path),
923            "community_table": pd.read_csv(community_path),
924            "edge_table": pd.read_csv(edge_path),
925            "layout_table": pd.read_csv(layout_path)
926            if layout_path.exists()
927            else pd.DataFrame(columns=["node_id", "x", "y"]),
928        }
929
930        self._ensure_cluster_cache()[metric] = artifact
931        return {
932            "metric": metric,
933            "n_nodes": int(artifact["node_table"].shape[0]),
934            "n_edges": int(artifact["edge_table"].shape[0]),
935            "n_clusters": int(artifact["community_table"].shape[0]),
936        }

Load cluster artifacts from CSV files into in-memory cache.

Parameters
  • output_dir (str): Directory containing saved cluster CSV artifacts.
  • metric (str): Metric key to load.
  • run_id (str or None): Optional run suffix used when saving.
Returns
  • dict: Summary with metric, n_nodes, n_edges, and n_clusters.
Raises
  • FileNotFoundError: If required artifact files are missing.
def plot_network( self, metric: str = 'entropy_similarity', *, ax=None, path: str | None = None, return_fig: bool = False, include_queries_only: bool = True, score_threshold: float | None = None, max_edges: int | None = 500, max_nodes: int | None = None, exclude_self: bool = True, drop_components_without_queries: bool = True, drop_nodes_without_query_connection: bool = True, layout_seed: int = 42, bypass_clustering: bool = True, separate_communities: bool = True, color_map: dict[str, str] | None = None, library_label_field: Union[str, Sequence[str], NoneType] = None, show_labels: bool = True, label_max_nodes: int = 40, node_size_query: float = 90, node_size_library: float = 55, figsize: tuple[float, float] = (9, 7), title: str | None = None, dpi: int = 150):
1162    def plot_network(
1163        self,
1164        metric: str = "entropy_similarity",
1165        *,
1166        ax=None,
1167        path: str | None = None,
1168        return_fig: bool = False,
1169        include_queries_only: bool = True,
1170        score_threshold: float | None = None,
1171        max_edges: int | None = 500,
1172        max_nodes: int | None = None,
1173        exclude_self: bool = True,
1174        drop_components_without_queries: bool = True,
1175        drop_nodes_without_query_connection: bool = True,
1176        layout_seed: int = 42,
1177        bypass_clustering: bool = True,
1178        separate_communities: bool = True,
1179        color_map: dict[str, str] | None = None,
1180        library_label_field: str | Sequence[str] | None = None,
1181        show_labels: bool = True,
1182        label_max_nodes: int = 40,
1183        node_size_query: float = 90,
1184        node_size_library: float = 55,
1185        figsize: tuple[float, float] = (9, 7),
1186        title: str | None = None,
1187        dpi: int = 150,
1188    ):
1189        """Draw a static matplotlib snapshot of the molecular network.
1190
1191        Portable for Jupyter notebooks rendered on GitLab/GitHub (PNG output).
1192        For interactive HTML exploration, use :meth:`plot_interactive_network`
1193        (requires ``ipysigma`` / ``corems[networking]``).
1194
1195        Parameters
1196        ----------
1197        metric : str
1198            Similarity metric key. Default ``"entropy_similarity"``.
1199        ax : matplotlib.axes.Axes, optional
1200            Axes to draw on. If None, a new figure is created.
1201        path : str, optional
1202            If set, save the figure to this path (``savefig``).
1203        return_fig : bool
1204            If True, return the ``Figure`` and do not call ``plt.show()``.
1205            Default False.
1206        include_queries_only, score_threshold, max_edges, max_nodes, …
1207            Edge/node filtering and layout options (see
1208            :meth:`plot_interactive_network`).
1209        bypass_clustering : bool
1210            If True (default), plot the raw filtered graph without requiring
1211            precomputed clusters.
1212        show_labels : bool
1213            Draw node labels (capped by *label_max_nodes*). Default True.
1214        label_max_nodes : int
1215            Maximum number of labels to draw (highest-degree nodes). Default 40.
1216        node_size_query, node_size_library : float
1217            Matplotlib scatter sizes for query vs library nodes.
1218        figsize : tuple of float
1219            Figure size when *ax* is None. Default ``(9, 7)``.
1220        title : str, optional
1221            Axes title. Default summarizes metric, threshold, and edge count.
1222        dpi : int
1223            DPI used when saving *path*. Default 150.
1224
1225        Returns
1226        -------
1227        matplotlib.figure.Figure or None
1228            The figure if *return_fig* is True, otherwise None.
1229        """
1230        import matplotlib.pyplot as plt
1231        from matplotlib.lines import Line2D
1232
1233        G, layout, palette, thr = self._build_networkx_graph(
1234            metric=metric,
1235            include_queries_only=include_queries_only,
1236            score_threshold=score_threshold,
1237            max_edges=max_edges,
1238            max_nodes=max_nodes,
1239            exclude_self=exclude_self,
1240            directed=False,
1241            color_map=color_map,
1242            library_label_field=library_label_field,
1243            drop_components_without_queries=drop_components_without_queries,
1244            drop_nodes_without_query_connection=drop_nodes_without_query_connection,
1245            layout_seed=layout_seed,
1246            bypass_clustering=bypass_clustering,
1247            separate_communities=separate_communities,
1248        )
1249
1250        if ax is None:
1251            fig, ax = plt.subplots(figsize=figsize)
1252        else:
1253            fig = ax.figure
1254
1255        if layout is None or G.number_of_nodes() == 0:
1256            ax.text(0.5, 0.5, "No network edges to plot", ha="center", va="center")
1257            ax.set_axis_off()
1258        else:
1259            pos = {
1260                n: (float(layout[str(n)]["x"]), float(layout[str(n)]["y"]))
1261                for n in G.nodes
1262                if str(n) in layout
1263            }
1264            # Fallback positions for any missing layout keys
1265            for n in G.nodes:
1266                if n not in pos:
1267                    pos[n] = (0.0, 0.0)
1268
1269            scores = [float(d.get("score", 0.0)) for _, _, d in G.edges(data=True)]
1270            smin = min(scores) if scores else 0.0
1271            smax = max(scores) if scores else 1.0
1272            span = (smax - smin) + 1e-12
1273
1274            for u, v, data in G.edges(data=True):
1275                x0, y0 = pos[u]
1276                x1, y1 = pos[v]
1277                score = float(data.get("score", 0.0))
1278                lw = 0.5 + 2.5 * ((score - smin) / span)
1279                ax.plot([x0, x1], [y0, y1], color="0.65", lw=lw, zorder=1, alpha=0.85)
1280
1281            for n, (x, y) in pos.items():
1282                is_query = G.nodes[n].get("node_type") == "query"
1283                ax.scatter(
1284                    [x],
1285                    [y],
1286                    s=node_size_query if is_query else node_size_library,
1287                    c=palette.get("query" if is_query else "library", "#95a5a6"),
1288                    edgecolors="white",
1289                    linewidths=0.6,
1290                    zorder=2,
1291                )
1292
1293            if show_labels and G.number_of_nodes() > 0:
1294                degrees = dict(G.degree())
1295                label_nodes = sorted(degrees, key=lambda n: (-degrees[n], str(n)))[
1296                    : max(0, int(label_max_nodes))
1297                ]
1298                for n in label_nodes:
1299                    x, y = pos[n]
1300                    label = str(G.nodes[n].get("label", n))
1301                    if len(label) > 18:
1302                        label = label[:15] + "…"
1303                    ax.text(x, y, label, fontsize=7, ha="center", va="bottom", zorder=3)
1304
1305            legend = [
1306                Line2D(
1307                    [0],
1308                    [0],
1309                    marker="o",
1310                    color="w",
1311                    markerfacecolor=palette.get("query", "#e74c3c"),
1312                    markersize=9,
1313                    label="Experimental MS2 (query)",
1314                ),
1315                Line2D(
1316                    [0],
1317                    [0],
1318                    marker="o",
1319                    color="w",
1320                    markerfacecolor=palette.get("library", "#3498db"),
1321                    markersize=8,
1322                    label="Library spectrum",
1323                ),
1324            ]
1325            ax.legend(handles=legend, loc="best", frameon=True)
1326            ax.set_axis_off()
1327
1328        if title is None:
1329            title = (
1330                f"Molecular network ({metric}, threshold={thr:g}, "
1331                f"{G.number_of_edges()} edges)"
1332            )
1333        ax.set_title(title)
1334        fig.tight_layout()
1335
1336        if path is not None:
1337            fig.savefig(path, dpi=dpi, bbox_inches="tight")
1338
1339        if return_fig:
1340            return fig
1341
1342        # Prefer IPython Image display so notebooks capture PNG under Agg / headless CI
1343        try:
1344            import io
1345
1346            from IPython import get_ipython
1347            from IPython.display import Image, display
1348
1349            if get_ipython() is not None:
1350                buf = io.BytesIO()
1351                fig.savefig(buf, format="png", dpi=dpi, bbox_inches="tight")
1352                display(Image(data=buf.getvalue()))
1353                plt.close(fig)
1354                return None
1355        except ImportError:
1356            pass
1357
1358        plt.show()
1359        return None

Draw a static matplotlib snapshot of the molecular network.

Portable for Jupyter notebooks rendered on GitLab/GitHub (PNG output). For interactive HTML exploration, use plot_interactive_network() (requires ipysigma / corems[networking]).

Parameters
  • metric (str): Similarity metric key. Default "entropy_similarity".
  • ax (matplotlib.axes.Axes, optional): Axes to draw on. If None, a new figure is created.
  • path (str, optional): If set, save the figure to this path (savefig).
  • return_fig (bool): If True, return the Figure and do not call plt.show(). Default False.
  • include_queries_only, score_threshold, max_edges, max_nodes, …: Edge/node filtering and layout options (see plot_interactive_network()).
  • bypass_clustering (bool): If True (default), plot the raw filtered graph without requiring precomputed clusters.
  • show_labels (bool): Draw node labels (capped by label_max_nodes). Default True.
  • label_max_nodes (int): Maximum number of labels to draw (highest-degree nodes). Default 40.
  • node_size_query, node_size_library (float): Matplotlib scatter sizes for query vs library nodes.
  • figsize (tuple of float): Figure size when ax is None. Default (9, 7).
  • title (str, optional): Axes title. Default summarizes metric, threshold, and edge count.
  • dpi (int): DPI used when saving path. Default 150.
Returns
  • matplotlib.figure.Figure or None: The figure if return_fig is True, otherwise None.
def plot_interactive_network( self, metric: str = 'entropy_similarity', out_path: str = 'network.html', *, include_queries_only: bool = True, score_threshold: float | None = None, max_edges: int | None = 500, max_nodes: int | None = None, exclude_self: bool = True, directed: bool = False, color_map: dict[str, str] | None = None, sigma_options: dict | None = None, metadata: dict[str, dict] | None = None, library_label_field: Union[str, Sequence[str], NoneType] = None, library_node_attrs: Optional[Sequence[str]] = None, drop_components_without_queries: bool = True, drop_nodes_without_query_connection: bool = True, layout_seed: int = 42, bypass_clustering: bool = False, separate_communities: bool = True, display_in_notebook: bool = False, iframe_height: int = 650) -> str:
1361    def plot_interactive_network(
1362        self,
1363        metric: str = "entropy_similarity",
1364        out_path: str = "network.html",
1365        *,
1366        include_queries_only: bool = True,
1367        score_threshold: float | None = None,
1368        max_edges: int | None = 500,
1369        max_nodes: int | None = None,
1370        exclude_self: bool = True,
1371        directed: bool = False,
1372        color_map: dict[str, str] | None = None,
1373        sigma_options: dict | None = None,
1374        metadata: dict[str, dict] | None = None,
1375        library_label_field: str | Sequence[str] | None = None,
1376        library_node_attrs: Sequence[str] | None = None,
1377        drop_components_without_queries: bool = True,
1378        drop_nodes_without_query_connection: bool = True,
1379        layout_seed: int = 42,
1380        bypass_clustering: bool = False,
1381        separate_communities: bool = True,
1382        display_in_notebook: bool = False,
1383        iframe_height: int = 650,
1384    ) -> str:
1385        """Write an interactive ipysigma HTML network visualization.
1386
1387        Prefer :meth:`plot_network` for static snapshots that render on
1388        GitLab/GitHub notebook previews. This method requires **ipysigma**
1389        (``pip install "corems[networking]"``).
1390
1391        Parameters
1392        ----------
1393        out_path : str
1394            Output HTML file path.
1395        separate_communities : bool
1396            When plotting from precomputed clusters (``bypass_clustering=False``),
1397            remove inter-community edges so each community renders as a separate
1398            subnetwork.  Default ``True``.
1399        display_in_notebook : bool
1400            If ``True``, also embed the HTML in a local Jupyter frontend via a
1401            data-URI iframe.  Does **not** render on GitLab/GitHub notebook
1402            previews.  Default ``False``.
1403        iframe_height : int
1404            Pixel height of the notebook iframe when *display_in_notebook* is
1405            True.  Default ``650``.
1406
1407        Returns
1408        -------
1409        str
1410            Path to the written HTML file.
1411        """
1412        try:
1413            from ipysigma import Sigma
1414        except ImportError as exc:
1415            raise ImportError(
1416                "ipysigma is required for plot_interactive_network(). "
1417                'Install with: pip install "corems[networking]" '
1418                "(or: pip install ipysigma)"
1419            ) from exc
1420
1421        G, layout, palette, _thr = self._build_networkx_graph(
1422            metric=metric,
1423            include_queries_only=include_queries_only,
1424            score_threshold=score_threshold,
1425            max_edges=max_edges,
1426            max_nodes=max_nodes,
1427            exclude_self=exclude_self,
1428            directed=directed,
1429            color_map=color_map,
1430            metadata=metadata,
1431            library_label_field=library_label_field,
1432            library_node_attrs=library_node_attrs,
1433            drop_components_without_queries=drop_components_without_queries,
1434            drop_nodes_without_query_connection=drop_nodes_without_query_connection,
1435            layout_seed=layout_seed,
1436            bypass_clustering=bypass_clustering,
1437            separate_communities=separate_communities,
1438        )
1439
1440        cache = self._ensure_cluster_cache()
1441        artifact = cache.get(metric)
1442        clustered_mode = (
1443            not bypass_clustering and artifact is not None and G.number_of_nodes() > 0
1444        )
1445        write_kwargs: dict = {
1446            "layout": layout,
1447            "node_color": "community_id" if clustered_mode else "node_type",
1448            "node_size": "viz_size",
1449            "node_size_range": (8, 18),
1450            "node_border_color": "viz_border_color",
1451            "node_border_size": "viz_border_size",
1452            "node_border_size_range": (0, 4),
1453            "default_edge_type": "line",
1454            "default_edge_color": "#aaa",
1455            "fullscreen": True,
1456        }
1457        if not clustered_mode:
1458            write_kwargs["node_color_palette"] = palette
1459        if sigma_options:
1460            write_kwargs.update(sigma_options)
1461
1462        out = Path(out_path)
1463        out.parent.mkdir(parents=True, exist_ok=True)
1464        Sigma.write_html(G, str(out), **write_kwargs)
1465
1466        if display_in_notebook:
1467            self._display_network_html_in_notebook(out, height=iframe_height)
1468
1469        return str(out)

Write an interactive ipysigma HTML network visualization.

Prefer plot_network() for static snapshots that render on GitLab/GitHub notebook previews. This method requires ipysigma (pip install "corems[networking]").

Parameters
  • out_path (str): Output HTML file path.
  • separate_communities (bool): When plotting from precomputed clusters (bypass_clustering=False), remove inter-community edges so each community renders as a separate subnetwork. Default True.
  • display_in_notebook (bool): If True, also embed the HTML in a local Jupyter frontend via a data-URI iframe. Does not render on GitLab/GitHub notebook previews. Default False.
  • iframe_height (int): Pixel height of the notebook iframe when display_in_notebook is True. Default 650.
Returns
  • str: Path to the written HTML file.