Skip to content

seroverse.core

Core Structure-of-Arrays (SoA) protocols, genomic intervals, sequence & alphabet engines, k-mer/randstrobe indices, banded Smith-Waterman-Gotoh alignment, clustering, and graph models.


Container Protocols (seroverse.core.collections)

collections

Base classes, 1D NumPy array type aliases, and Numba kernels for Structure-of-Arrays (SoA) containers.

This module defines the concrete base classes (ScalarView, BatchedContainer, RaggedArrayContainer, Spec, SpecContainer, and SpecEnum) and 1D NumPy array type aliases that govern high-performance, vectorized containers and compiled ontology registries across SeroVerse.

Upon class definition (__init_subclass__), BatchedContainer and RaggedArrayContainer inspect @dataclass field annotations, verify that all stored container fields are NumPy arrays, register their np.dtypes, and automatically provide __len__, __getitem__, __eq__, filter, empty, concat, and from_list.

Classes:

Name Description
ScalarView

Concrete base class for immutable scalar views extracted from a BatchedContainer.

BatchedContainer

Concrete generic base class for Structure-of-Arrays (SoA) batch containers.

RaggedArrayContainer

Concrete generic base class for ragged Structure-of-Arrays (SoA) containers.

Spec

Immutable scalar specification for an ontology or reference database record.

SpecContainer

Immutable, read-only Structure-of-Arrays (SoA) database container for Spec records.

SpecEnum

Base IntEnum for 0-based contiguous ontology registries backed by a singleton SpecContainer.

Functions:

Name Description
lengths_to_offsets

Compute 0-based exclusive prefix-sum offsets from a 1D array of element lengths.

normalize_indices

Normalize a slice, boolean mask, or integer index sequence into a 1D int32 index array.

ScalarView

Concrete base class for immutable scalar views extracted from a BatchedContainer.

All scalar views are @dataclass(frozen=True, slots=True) records representing a single element of a Structure-of-Arrays (SoA) batch container. Inheriting from ScalarView automatically provides: - NumPy-safe structural equality (__eq__) using np.array_equal for any np.ndarray fields while preserving @dataclass-generated __hash__ on scalar-only views. - Functional copy-update via .replace(**changes). - Shallow dictionary serialization via .to_dict().

Methods:

Name Description
replace

Return a new scalar view instance with specified fields replaced.

to_dict

Return a shallow dictionary mapping field names to their values.

Source code in src/seroverse/core/collections.py
class ScalarView:
    r"""Concrete base class for immutable scalar views extracted from a [`BatchedContainer`][seroverse.core.collections.BatchedContainer].

    All scalar views are `@dataclass(frozen=True, slots=True)` records representing a single
    element of a Structure-of-Arrays (SoA) batch container. Inheriting from `ScalarView`
    automatically provides:
    - NumPy-safe structural equality (`__eq__`) using `np.array_equal` for any `np.ndarray` fields
      while preserving `@dataclass`-generated `__hash__` on scalar-only views.
    - Functional copy-update via [`.replace(**changes)`][seroverse.core.collections.ScalarView.replace].
    - Shallow dictionary serialization via [`.to_dict()`][seroverse.core.collections.ScalarView.to_dict].
    """

    __slots__ = ()
    __dataclass_fields__: ClassVar[dict[str, Field[Any]]]

    def __init_subclass__(cls, **kwargs: Any) -> None:
        super().__init_subclass__(**kwargs)
        if "__dataclass_fields__" in cls.__dict__:
            cls.__eq__ = ScalarView._dataclass_eq  # type: ignore[method-assign]

    def _dataclass_eq(self, other: object) -> bool:
        r"""Compare two ScalarView instances, handling both scalar and `np.ndarray` fields safely."""
        if type(self) is not type(other):
            return False
        for f in fields(self):
            if not f.compare:
                continue
            a = getattr(self, f.name)
            b = getattr(other, f.name)
            if isinstance(a, np.ndarray) or isinstance(b, np.ndarray):
                if not np.array_equal(a, b):
                    return False
            elif a != b:
                return False
        return True

    def replace(self, **changes: Any) -> Self:
        r"""Return a new scalar view instance with specified fields replaced."""
        return dataclass_replace(self, **changes)

    def to_dict(self) -> dict[str, Any]:
        r"""Return a shallow dictionary mapping field names to their values."""
        return {f.name: getattr(self, f.name) for f in fields(self)}

replace

replace(**changes: Any) -> Self

Return a new scalar view instance with specified fields replaced.

Source code in src/seroverse/core/collections.py
def replace(self, **changes: Any) -> Self:
    r"""Return a new scalar view instance with specified fields replaced."""
    return dataclass_replace(self, **changes)

to_dict

to_dict() -> dict[str, Any]

Return a shallow dictionary mapping field names to their values.

Source code in src/seroverse/core/collections.py
def to_dict(self) -> dict[str, Any]:
    r"""Return a shallow dictionary mapping field names to their values."""
    return {f.name: getattr(self, f.name) for f in fields(self)}

BatchedContainer

Concrete generic base class for Structure-of-Arrays (SoA) batch containers.

Subclasses must be @dataclass(frozen=True, slots=True) classes whose stored fields are all annotated as 1D NumPy arrays (e.g. Int32Array, UInt32Array, Float64Array). Upon class creation, BatchedContainer.__init_subclass__ validates and registers each field's np.dtype into _field_dtypes and automatically provides: - __len__, __iter__, and NumPy-safe __eq__ - __getitem__ with @overload typing, negative-index bounds checking, and automatic dispatch to _get_scalar(idx) (for integer indices) and _get_slice(item) (for slices/masks/index arrays) - filter(mask), replace(**changes), empty(), concat(batches), and from_list(items)

Class Type Parameters:

Name Bound or Constraints Description Default
T ScalarView

The ScalarView dataclass returned when indexing a single element.

required
S BatchedContainer[Any, Any]

The batched container type returned when slicing or filtering.

required

Methods:

Name Description
filter

Return a filtered container containing only records where mask is True.

replace

Return a new container instance with specified array fields replaced.

to_dict

Return a shallow dictionary mapping container field names to their underlying NumPy arrays.

chunks

Yield contiguous \(O(1)\) zero-copy mini-batch slices of at most size records.

argsort

Compute the stable 0-based int32 permutation indices that sort this container by *by.

sort_by

Return a new container sorted stably by one or more 1D array fields or properties.

empty

Create an empty 0-length container with registered np.dtype arrays.

concat

Concatenate multiple containers of the same type into a single batch.

from_list

Pack a sequence of ScalarView instances into a batch container.

Source code in src/seroverse/core/collections.py
class BatchedContainer[T: ScalarView, S: BatchedContainer[Any, Any]]:
    r"""Concrete generic base class for Structure-of-Arrays (SoA) batch containers.

    Subclasses must be `@dataclass(frozen=True, slots=True)` classes whose stored fields are all
    annotated as 1D NumPy arrays (e.g. `Int32Array`, `UInt32Array`, `Float64Array`). Upon class
    creation, `BatchedContainer.__init_subclass__` validates and registers each field's `np.dtype`
    into `_field_dtypes` and automatically provides:
    - `__len__`, `__iter__`, and NumPy-safe `__eq__`
    - `__getitem__` with `@overload` typing, negative-index bounds checking, and automatic dispatch
      to `_get_scalar(idx)` (for integer indices) and `_get_slice(item)` (for slices/masks/index arrays)
    - `filter(mask)`, `replace(**changes)`, `empty()`, `concat(batches)`, and `from_list(items)`

    Type Parameters:
        T: The [`ScalarView`][seroverse.core.collections.ScalarView] dataclass returned when indexing a single element.
        S: The batched container type returned when slicing or filtering.
    """

    __slots__ = ()
    __dataclass_fields__: ClassVar[dict[str, Field[Any]]]
    _field_dtypes: ClassVar[dict[str, np.dtype[Any]]] = {}
    _field_ndims: ClassVar[dict[str, int]] = {}
    _meta_fields: ClassVar[tuple[str, ...]] = ()
    _primary_field: ClassVar[str] = ""
    _scalar_type: ClassVar[type[Any] | None] = None
    _scalar_source_fields: ClassVar[tuple[str, ...]] = ()
    _scalar_converters: ClassVar[tuple[Callable[[Any], Any] | None, ...]] = ()

    def __init_subclass__(cls, **kwargs: Any) -> None:
        super().__init_subclass__(**kwargs)
        # Extract scalar view type parameter T from generic bases if concrete
        for base in getattr(cls, "__orig_bases__", ()):
            origin = get_origin(base)
            if origin is not None and isinstance(origin, type) and issubclass(origin, BatchedContainer):
                args = get_args(base)
                if args and isinstance(args[0], type) and issubclass(args[0], ScalarView):
                    cls._scalar_type = args[0]
                    break

        if "__dataclass_fields__" not in cls.__dict__:
            return

        base_dtypes: dict[str, np.dtype[Any]] = {}
        base_ndims: dict[str, int] = {}
        for mro_cls in reversed(cls.__mro__[1:]):
            if issubclass(mro_cls, BatchedContainer) and "_field_dtypes" in mro_cls.__dict__:
                base_dtypes.update(mro_cls._field_dtypes)
                base_ndims.update(mro_cls._field_ndims)

        try:
            hints = annotationlib.get_annotations(cls, format=annotationlib.Format.VALUE)
        except Exception:
            hints = annotationlib.get_annotations(cls, format=annotationlib.Format.FORWARDREF)

        dtypes: dict[str, np.dtype[Any]] = {}
        ndims: dict[str, int] = {}
        meta: list[str] = []
        for f in fields(cls):
            if not f.init:
                continue
            if f.name in base_dtypes and f.name not in hints:
                dtypes[f.name] = base_dtypes[f.name]
                ndims[f.name] = base_ndims.get(f.name, 1)
                continue
            ann = hints.get(f.name, f.type)
            has_default = f.default is not MISSING or f.default_factory is not MISSING
            try:
                dtypes[f.name] = _resolve_array_dtype(ann, cls.__name__, f.name)
                ndims[f.name] = _resolve_array_ndim(ann)
            except TypeError:
                if has_default or (isinstance(ann, type) and issubclass(ann, BatchedContainer)):
                    meta.append(f.name)
                else:
                    raise

        cls._field_dtypes = dtypes
        cls._field_ndims = ndims
        cls._meta_fields = tuple(meta)
        cls._primary_field = next(iter(dtypes), "")

        scalar_cls = cls._scalar_type
        if scalar_cls is not None and "__dataclass_fields__" in scalar_cls.__dict__:
            s_fields = fields(scalar_cls)
            cls._scalar_source_fields = tuple(dtypes)[: len(s_fields)]
            s_hints: dict[str, Any] = {}
            for s_mro in reversed(scalar_cls.__mro__):
                if "__dataclass_fields__" in s_mro.__dict__:
                    try:
                        s_hints.update(annotationlib.get_annotations(s_mro, format=annotationlib.Format.VALUE))
                    except Exception:
                        pass
            convs: list[Callable[[Any], Any] | None] = []
            has_conv = False
            for sf in s_fields:
                sf_ann = s_hints.get(sf.name, sf.type)
                if isinstance(sf_ann, type) and issubclass(sf_ann, Enum):
                    convs.append(sf_ann)
                    has_conv = True
                else:
                    convs.append(None)
            cls._scalar_converters = tuple(convs) if has_conv else ()
        else:
            cls._scalar_source_fields = tuple(dtypes)
            cls._scalar_converters = ()

        cls.__eq__ = BatchedContainer._container_eq  # type: ignore[method-assign]

    def _container_eq(self, other: object) -> bool:
        r"""Compare two containers for exact array equality across all registered SoA fields."""
        if type(self) is not type(other):
            return False
        return all(np.array_equal(getattr(self, name), getattr(other, name)) for name in self._field_dtypes) and all(
            getattr(self, m) == getattr(other, m) for m in self._meta_fields
        )

    def __len__(self) -> int:
        r"""Return the number of records in the batch."""
        return len(getattr(self, self._primary_field))

    def _resolve_index(self, item: int | np.integer[Any]) -> int:
        r"""Normalize a potentially negative integer index and validate bounds."""
        idx = int(item)
        n = len(self)
        if idx < 0:
            idx += n
        if idx < 0 or idx >= n:
            raise IndexError(f"{type(self).__name__} index {item} out of range for batch of length {n}")
        return idx

    def _get_scalar(self, idx: int) -> T:
        r"""Materialize the scalar view `T` at normalized 0-based index `idx`."""
        scalar_cls = self._scalar_type
        if scalar_cls is None:
            raise NotImplementedError(f"{type(self).__name__} must implement _get_scalar(idx)")
        convs = self._scalar_converters
        if convs:
            return cast(
                T,
                scalar_cls(
                    *(
                        conv(getattr(self, f)[idx].item()) if conv is not None else getattr(self, f)[idx].item()
                        for f, conv in zip(self._scalar_source_fields, convs, strict=True)
                    )
                ),
            )
        return cast(T, scalar_cls(*(getattr(self, f)[idx].item() for f in self._scalar_source_fields)))

    def _get_slice(self, item: slice | np.ndarray[Any, Any] | list[Any]) -> S:
        r"""Slice all 1D SoA arrays by a slice, boolean mask, or integer index sequence."""
        indices: Any = item if isinstance(item, slice) else self._normalize_indices(item)
        return cast(S, type(self)(**{f: getattr(self, f)[indices] for f in self._field_dtypes}))

    @overload
    def __getitem__(self, item: int | np.integer[Any]) -> T: ...

    @overload
    def __getitem__(self, item: slice | np.ndarray[tuple[int], Any] | list[Any]) -> S: ...

    @overload
    def __getitem__(self, item: int | np.integer[Any] | slice | np.ndarray[tuple[int], Any] | list[Any]) -> T | S: ...

    def __getitem__(self, item: int | np.integer[Any] | slice | np.ndarray[tuple[int], Any] | list[Any]) -> T | S:
        r"""Access a single scalar record `T` by integer index, or a sliced container `S` by slice/mask."""
        if isinstance(item, (int, np.integer)):
            return self._get_scalar(self._resolve_index(item))
        return self._get_slice(item)

    def __iter__(self) -> Iterator[T]:
        r"""Iterate over the container, yielding each scalar view `T` in order."""
        for i in range(len(self)):
            yield self._get_scalar(i)

    def _normalize_indices(self, item: slice | np.ndarray[Any, Any] | list[Any]) -> Int32Array:
        r"""Resolve a slice, boolean mask, or index list into a 1D `int32` index array."""
        return normalize_indices(item, len(self))

    def filter(self, mask: BoolArray | list[bool]) -> S:
        r"""Return a filtered container containing only records where `mask` is True."""
        return self._get_slice(mask)

    def replace(self, **changes: Any) -> Self:
        r"""Return a new container instance with specified array fields replaced."""
        return dataclass_replace(self, **changes)

    def to_dict(self) -> dict[str, Any]:
        r"""Return a shallow dictionary mapping container field names to their underlying NumPy arrays."""
        out: dict[str, Any] = {f: getattr(self, f) for f in self._field_dtypes}
        for m in self._meta_fields:
            out[m] = getattr(self, m)
        return out

    def chunks(self, size: int) -> Iterator[S]:
        r"""Yield contiguous $O(1)$ zero-copy mini-batch slices of at most `size` records.

        Args:
            size: Maximum number of records per chunk (must be `> 0`).

        Yields:
            S: Contiguous mini-batch container slice.

        Raises:
            ValueError: If `size <= 0`.
        """
        if size <= 0:
            raise ValueError(f"Chunk size must be positive, got {size}")
        n = len(self)
        for start in range(0, n, size):
            yield self._get_slice(slice(start, min(start + size, n)))

    def argsort(
        self,
        *by: str,
        descending: bool | Sequence[bool] = False,
    ) -> Int32Array:
        r"""Compute the stable 0-based `int32` permutation indices that sort this container by `*by`.

        Supports sorting by one or more registered 1D array columns or computed 1D array properties
        (in primary-to-secondary priority order), with either a single `descending` boolean or a
        per-key sequence of booleans.

        Args:
            *by: Field or property names to sort by (defaults to the primary container field).
            descending: Sort direction (`bool` applied to all keys, or a `Sequence[bool]` per key).

        Returns:
            Int32Array: 1D `int32` index permutation of length `len(self)`.

        Raises:
            ValueError: If a key does not resolve to a 1D NumPy array of length `len(self)` or
                if `descending` length does not match `len(by)`.
        """
        n = len(self)
        keys = by if by else (self._primary_field,)
        if isinstance(descending, bool):
            desc_flags = (descending,) * len(keys)
        else:
            desc_flags = tuple(bool(d) for d in descending)
            if len(desc_flags) != len(keys):
                raise ValueError(
                    f"Length of descending ({len(desc_flags)}) must match number of sort keys ({len(keys)})"
                )

        arrays: list[np.ndarray[Any, Any]] = []
        for k in keys:
            if not hasattr(self, k):
                raise ValueError(f"{type(self).__name__} has no field or property {k!r}")
            arr = getattr(self, k)
            if not isinstance(arr, np.ndarray) or arr.ndim != 1 or len(arr) != n:
                raise ValueError(f"Sort key {k!r} on {type(self).__name__} must be a 1D NumPy array of length {n}")
            arrays.append(arr)

        if n <= 1:
            return np.arange(n, dtype=np.int32)

        order = np.arange(n, dtype=np.int32)
        for arr, desc in zip(reversed(arrays), reversed(desc_flags), strict=True):
            sub = arr[order]
            if desc:
                step = (n - 1) - np.argsort(sub[::-1], kind="stable")[::-1]
            else:
                step = np.argsort(sub, kind="stable")
            order = order[step]
        return order.astype(np.int32, copy=False)

    def sort_by(
        self,
        *by: str,
        descending: bool | Sequence[bool] = False,
    ) -> S:
        r"""Return a new container sorted stably by one or more 1D array fields or properties."""
        if len(self) <= 1 and not by:
            return cast(S, self)
        return self._get_slice(self.argsort(*by, descending=descending))

    @classmethod
    def empty(cls) -> Self:
        r"""Create an empty 0-length container with registered `np.dtype` arrays."""
        ndims = cls._field_ndims
        return cls(
            **{
                f: np.empty((0,) * ndims.get(f, 1) if ndims.get(f, 1) > 1 else 0, dtype=dt)
                for f, dt in cls._field_dtypes.items()
            }
        )

    @classmethod
    def concat(cls, batches: Iterable[Self]) -> Self:
        r"""Concatenate multiple containers of the same type into a single batch."""
        batches_list = [b for b in batches if len(b) > 0]
        if not batches_list:
            return cls.empty()
        if len(batches_list) == 1:
            return batches_list[0]
        return cls(**{f: np.concatenate([getattr(b, f) for b in batches_list]) for f in cls._field_dtypes})

    @classmethod
    def from_list(cls, items: Sequence[T]) -> Self:
        r"""Pack a sequence of [`ScalarView`][seroverse.core.collections.ScalarView] instances into a batch container."""
        if not items:
            return cls.empty()
        scalar_cls = cls._scalar_type or type(items[0])
        s_fields = tuple(f.name for f in fields(scalar_cls))
        return cls(
            **{
                b_f: np.array([getattr(it, s_f) for it in items], dtype=dt)
                for (b_f, dt), s_f in zip(cls._field_dtypes.items(), s_fields, strict=True)
            }
        )

filter

filter(mask: BoolArray | list[bool]) -> S

Return a filtered container containing only records where mask is True.

Source code in src/seroverse/core/collections.py
def filter(self, mask: BoolArray | list[bool]) -> S:
    r"""Return a filtered container containing only records where `mask` is True."""
    return self._get_slice(mask)

replace

replace(**changes: Any) -> Self

Return a new container instance with specified array fields replaced.

Source code in src/seroverse/core/collections.py
def replace(self, **changes: Any) -> Self:
    r"""Return a new container instance with specified array fields replaced."""
    return dataclass_replace(self, **changes)

to_dict

to_dict() -> dict[str, Any]

Return a shallow dictionary mapping container field names to their underlying NumPy arrays.

Source code in src/seroverse/core/collections.py
def to_dict(self) -> dict[str, Any]:
    r"""Return a shallow dictionary mapping container field names to their underlying NumPy arrays."""
    out: dict[str, Any] = {f: getattr(self, f) for f in self._field_dtypes}
    for m in self._meta_fields:
        out[m] = getattr(self, m)
    return out

chunks

chunks(size: int) -> Iterator[S]

Yield contiguous \(O(1)\) zero-copy mini-batch slices of at most size records.

Parameters:

Name Type Description Default
size int

Maximum number of records per chunk (must be > 0).

required

Yields:

Name Type Description
S S

Contiguous mini-batch container slice.

Raises:

Type Description
ValueError

If size <= 0.

Source code in src/seroverse/core/collections.py
def chunks(self, size: int) -> Iterator[S]:
    r"""Yield contiguous $O(1)$ zero-copy mini-batch slices of at most `size` records.

    Args:
        size: Maximum number of records per chunk (must be `> 0`).

    Yields:
        S: Contiguous mini-batch container slice.

    Raises:
        ValueError: If `size <= 0`.
    """
    if size <= 0:
        raise ValueError(f"Chunk size must be positive, got {size}")
    n = len(self)
    for start in range(0, n, size):
        yield self._get_slice(slice(start, min(start + size, n)))

argsort

argsort(
    *by: str, descending: bool | Sequence[bool] = False
) -> Int32Array

Compute the stable 0-based int32 permutation indices that sort this container by *by.

Supports sorting by one or more registered 1D array columns or computed 1D array properties (in primary-to-secondary priority order), with either a single descending boolean or a per-key sequence of booleans.

Parameters:

Name Type Description Default
*by str

Field or property names to sort by (defaults to the primary container field).

()
descending bool | Sequence[bool]

Sort direction (bool applied to all keys, or a Sequence[bool] per key).

False

Returns:

Name Type Description
Int32Array Int32Array

1D int32 index permutation of length len(self).

Raises:

Type Description
ValueError

If a key does not resolve to a 1D NumPy array of length len(self) or if descending length does not match len(by).

Source code in src/seroverse/core/collections.py
def argsort(
    self,
    *by: str,
    descending: bool | Sequence[bool] = False,
) -> Int32Array:
    r"""Compute the stable 0-based `int32` permutation indices that sort this container by `*by`.

    Supports sorting by one or more registered 1D array columns or computed 1D array properties
    (in primary-to-secondary priority order), with either a single `descending` boolean or a
    per-key sequence of booleans.

    Args:
        *by: Field or property names to sort by (defaults to the primary container field).
        descending: Sort direction (`bool` applied to all keys, or a `Sequence[bool]` per key).

    Returns:
        Int32Array: 1D `int32` index permutation of length `len(self)`.

    Raises:
        ValueError: If a key does not resolve to a 1D NumPy array of length `len(self)` or
            if `descending` length does not match `len(by)`.
    """
    n = len(self)
    keys = by if by else (self._primary_field,)
    if isinstance(descending, bool):
        desc_flags = (descending,) * len(keys)
    else:
        desc_flags = tuple(bool(d) for d in descending)
        if len(desc_flags) != len(keys):
            raise ValueError(
                f"Length of descending ({len(desc_flags)}) must match number of sort keys ({len(keys)})"
            )

    arrays: list[np.ndarray[Any, Any]] = []
    for k in keys:
        if not hasattr(self, k):
            raise ValueError(f"{type(self).__name__} has no field or property {k!r}")
        arr = getattr(self, k)
        if not isinstance(arr, np.ndarray) or arr.ndim != 1 or len(arr) != n:
            raise ValueError(f"Sort key {k!r} on {type(self).__name__} must be a 1D NumPy array of length {n}")
        arrays.append(arr)

    if n <= 1:
        return np.arange(n, dtype=np.int32)

    order = np.arange(n, dtype=np.int32)
    for arr, desc in zip(reversed(arrays), reversed(desc_flags), strict=True):
        sub = arr[order]
        if desc:
            step = (n - 1) - np.argsort(sub[::-1], kind="stable")[::-1]
        else:
            step = np.argsort(sub, kind="stable")
        order = order[step]
    return order.astype(np.int32, copy=False)

sort_by

sort_by(
    *by: str, descending: bool | Sequence[bool] = False
) -> S

Return a new container sorted stably by one or more 1D array fields or properties.

Source code in src/seroverse/core/collections.py
def sort_by(
    self,
    *by: str,
    descending: bool | Sequence[bool] = False,
) -> S:
    r"""Return a new container sorted stably by one or more 1D array fields or properties."""
    if len(self) <= 1 and not by:
        return cast(S, self)
    return self._get_slice(self.argsort(*by, descending=descending))

empty classmethod

empty() -> Self

Create an empty 0-length container with registered np.dtype arrays.

Source code in src/seroverse/core/collections.py
@classmethod
def empty(cls) -> Self:
    r"""Create an empty 0-length container with registered `np.dtype` arrays."""
    ndims = cls._field_ndims
    return cls(
        **{
            f: np.empty((0,) * ndims.get(f, 1) if ndims.get(f, 1) > 1 else 0, dtype=dt)
            for f, dt in cls._field_dtypes.items()
        }
    )

concat classmethod

concat(batches: Iterable[Self]) -> Self

Concatenate multiple containers of the same type into a single batch.

Source code in src/seroverse/core/collections.py
@classmethod
def concat(cls, batches: Iterable[Self]) -> Self:
    r"""Concatenate multiple containers of the same type into a single batch."""
    batches_list = [b for b in batches if len(b) > 0]
    if not batches_list:
        return cls.empty()
    if len(batches_list) == 1:
        return batches_list[0]
    return cls(**{f: np.concatenate([getattr(b, f) for b in batches_list]) for f in cls._field_dtypes})

from_list classmethod

from_list(items: Sequence[T]) -> Self

Pack a sequence of ScalarView instances into a batch container.

Source code in src/seroverse/core/collections.py
@classmethod
def from_list(cls, items: Sequence[T]) -> Self:
    r"""Pack a sequence of [`ScalarView`][seroverse.core.collections.ScalarView] instances into a batch container."""
    if not items:
        return cls.empty()
    scalar_cls = cls._scalar_type or type(items[0])
    s_fields = tuple(f.name for f in fields(scalar_cls))
    return cls(
        **{
            b_f: np.array([getattr(it, s_f) for it in items], dtype=dt)
            for (b_f, dt), s_f in zip(cls._field_dtypes.items(), s_fields, strict=True)
        }
    )

RaggedArrayContainer

Bases: BatchedContainer[T, S]

Concrete generic base class for ragged Structure-of-Arrays (SoA) containers.

Ragged containers store variable-length per-record arrays (declared in _ragged_fields) in flat, contiguous 1D NumPy buffers indexed by offsets and lengths (Int32Array), alongside any fixed-length per-record 1D arrays (_record_fields).

Subclasses only need to declare _ragged_fields: ClassVar[tuple[str, ...]] (e.g. ("seqs",) or ("members",)), and inherit __len__, __getitem__, _get_scalar, _get_slice, empty, concat, from_list, filter, and indptr automatically.

Methods:

Name Description
lengths_to_offsets

Compute 0-based starting offsets from a 1D lengths array.

concat

Concatenate multiple ragged containers into a single contiguous batch.

from_list

Pack a sequence of ScalarView instances into a ragged container.

pad

Pack a ragged 1D field into a dense 2D (N, max_len) NumPy matrix.

Attributes:

Name Type Description
indptr Int32Array

CSR-compatible \((N + 1)\)-length index pointer array ([0, *cumsum(lengths)]).

Source code in src/seroverse/core/collections.py
class RaggedArrayContainer[T: ScalarView, S: BatchedContainer[Any, Any]](BatchedContainer[T, S]):
    r"""Concrete generic base class for ragged Structure-of-Arrays (SoA) containers.

    Ragged containers store variable-length per-record arrays (declared in `_ragged_fields`)
    in flat, contiguous 1D NumPy buffers indexed by `offsets` and `lengths` (`Int32Array`),
    alongside any fixed-length per-record 1D arrays (`_record_fields`).

    Subclasses only need to declare `_ragged_fields: ClassVar[tuple[str, ...]]` (e.g. `("seqs",)`
    or `("members",)`), and inherit `__len__`, `__getitem__`, `_get_scalar`, `_get_slice`,
    `empty`, `concat`, `from_list`, `filter`, and `indptr` automatically.
    """

    __slots__ = ()
    offsets: Int32Array
    lengths: Int32Array
    _ragged_fields: ClassVar[tuple[str, ...]] = ()
    _ragged_fields_set: ClassVar[frozenset[str]] = frozenset()
    _record_fields: ClassVar[tuple[str, ...]] = ()
    _data_fields: ClassVar[tuple[str, ...]] = ()

    def __init_subclass__(cls, **kwargs: Any) -> None:
        super().__init_subclass__(**kwargs)
        if "__dataclass_fields__" not in cls.__dict__:
            return
        ragged_set = frozenset(cls._ragged_fields)
        cls._ragged_fields_set = ragged_set
        cls._data_fields = tuple(f for f in cls._field_dtypes if f not in ("offsets", "lengths"))
        cls._record_fields = tuple(f for f in cls._data_fields if f not in ragged_set)

    def __len__(self) -> int:
        r"""Return the number of records in the ragged container."""
        return len(self.offsets)

    @property
    def indptr(self) -> Int32Array:
        r"""CSR-compatible $(N + 1)$-length index pointer array (`[0, *cumsum(lengths)]`)."""
        n = len(self.offsets)
        if n == 0:
            return np.zeros(1, dtype=np.int32)
        out = np.empty(n + 1, dtype=np.int32)
        out[:n] = self.offsets
        out[n] = self.offsets[-1] + self.lengths[-1]
        return out

    @staticmethod
    def lengths_to_offsets(
        lengths: Int32Array,
    ) -> Int32Array:
        r"""Compute 0-based starting offsets from a 1D `lengths` array."""
        return lengths_to_offsets(lengths)

    def _get_scalar(self, idx: int) -> T:
        r"""Materialize the scalar view `T` at normalized 0-based index `idx`."""
        scalar_cls = self._scalar_type
        if scalar_cls is None:
            raise NotImplementedError(f"{type(self).__name__} must implement _get_scalar(idx)")
        off = int(self.offsets[idx])
        end = off + int(self.lengths[idx])
        ragged = self._ragged_fields_set
        return cast(
            T,
            scalar_cls(
                *(getattr(self, f)[off:end] if f in ragged else getattr(self, f)[idx].item() for f in self._data_fields)
            ),
        )

    def _get_slice(self, item: slice | np.ndarray[Any, Any] | list[Any]) -> S:
        r"""Slice both per-record arrays and variable-length ragged arrays in parallel."""
        ragged_arrays = tuple(getattr(self, f) for f in self._ragged_fields)
        indices, new_offsets, new_lengths, sliced_ragged = self._slice_ragged(item, *ragged_arrays)
        if len(indices) == 0:
            return cast(S, self.empty())
        kwargs: dict[str, Any] = {"offsets": new_offsets, "lengths": new_lengths}
        for f, arr in zip(self._ragged_fields, sliced_ragged, strict=True):
            kwargs[f] = arr
        for f in self._record_fields:
            kwargs[f] = getattr(self, f)[indices]
        return cast(S, type(self)(**kwargs))

    @classmethod
    def concat(cls, batches: Iterable[Self]) -> Self:
        r"""Concatenate multiple ragged containers into a single contiguous batch."""
        batches_list = [b for b in batches if len(b) > 0]
        if not batches_list:
            return cls.empty()
        if len(batches_list) == 1:
            return batches_list[0]
        all_lengths = np.concatenate([b.lengths for b in batches_list]).astype(np.int32, copy=False)
        kwargs: dict[str, Any] = {
            "offsets": lengths_to_offsets(all_lengths),
            "lengths": all_lengths,
        }
        for f in cls._ragged_fields:
            parts = [getattr(b, f) for b in batches_list if len(getattr(b, f)) > 0]
            kwargs[f] = np.concatenate(parts) if parts else np.empty(0, dtype=cls._field_dtypes[f])
        for f in cls._record_fields:
            kwargs[f] = np.concatenate([getattr(b, f) for b in batches_list])
        return cls(**kwargs)

    @classmethod
    def from_list(cls, items: Sequence[T]) -> Self:
        r"""Pack a sequence of [`ScalarView`][seroverse.core.collections.ScalarView] instances into a ragged container."""
        if not items:
            return cls.empty()
        scalar_cls = cls._scalar_type or type(items[0])
        s_fields = tuple(f.name for f in fields(scalar_cls))
        field_map = dict(zip(cls._data_fields, s_fields, strict=True))
        first_ragged_s = field_map[cls._ragged_fields[0]]
        lengths = np.array([len(getattr(it, first_ragged_s)) for it in items], dtype=np.int32)
        has_elems = bool(np.any(lengths > 0))
        kwargs: dict[str, Any] = {
            "offsets": lengths_to_offsets(lengths),
            "lengths": lengths,
        }
        for b_f in cls._ragged_fields:
            s_f = field_map[b_f]
            dt = cls._field_dtypes[b_f]
            parts = [np.asarray(getattr(it, s_f), dtype=dt) for it in items if len(getattr(it, s_f)) > 0]
            kwargs[b_f] = np.concatenate(parts) if (has_elems and parts) else np.empty(0, dtype=dt)
        for b_f in cls._record_fields:
            s_f = field_map[b_f]
            dt = cls._field_dtypes[b_f]
            kwargs[b_f] = np.array([getattr(it, s_f) for it in items], dtype=dt)
        return cls(**kwargs)

    def _slice_ragged(
        self,
        item: slice | np.ndarray[Any, Any] | list[Any],
        *arrays: np.ndarray[tuple[int], Any],
    ) -> tuple[
        Int32Array,
        Int32Array,
        Int32Array,
        tuple[np.ndarray[tuple[int], Any], ...],
    ]:
        r"""Slice or gather one or more parallel ragged data arrays while maintaining contiguous packing.

        Uses an $O(1)$ zero-copy view slice on `arrays` when `item` is a contiguous `slice(start, stop, 1)`,
        and falls back to the parallel Numba `_gather_ragged_kernel` for stepped slices, boolean masks,
        or arbitrary index arrays.

        Args:
            item: Slice, boolean mask, or integer index list/array.
            *arrays: Parallel 1D flat data arrays indexed by `self.offsets` and `self.lengths`.

        Returns:
            tuple: `(indices, new_offsets, new_lengths, sliced_arrays)`.
        """
        n = len(self)
        if isinstance(item, slice) and item.step in (None, 1):
            start, stop, _ = item.indices(n)
            if stop <= start:
                empty_i32 = np.empty(0, dtype=np.int32)
                return (
                    empty_i32,
                    empty_i32,
                    empty_i32,
                    tuple(np.empty(0, dtype=a.dtype) for a in arrays),
                )
            indices = np.arange(start, stop, dtype=np.int32)
            new_lengths = self.lengths[start:stop]
            base_offset = int(self.offsets[start])
            end_offset = int(self.offsets[stop - 1]) + int(self.lengths[stop - 1])
            new_offsets = self.offsets[start:stop] - base_offset
            return (
                indices,
                new_offsets,
                new_lengths,
                tuple(a[base_offset:end_offset] for a in arrays),
            )

        indices = self._normalize_indices(item)
        if len(indices) == 0:
            empty_i32 = np.empty(0, dtype=np.int32)
            return (
                indices,
                empty_i32,
                empty_i32,
                tuple(np.empty(0, dtype=a.dtype) for a in arrays),
            )

        new_lengths = self.lengths[indices].astype(np.int32, copy=False)
        new_offsets = lengths_to_offsets(new_lengths)
        total_len = int(new_offsets[-1] + new_lengths[-1])
        if total_len == 0:
            return (
                indices,
                new_offsets,
                new_lengths,
                tuple(np.empty(0, dtype=a.dtype) for a in arrays),
            )

        sliced = tuple(
            _gather_ragged_kernel(a, self.offsets, new_offsets, new_lengths, indices, total_len) for a in arrays
        )
        return indices, new_offsets, new_lengths, sliced

    def pad(
        self,
        field_name: str | None = None,
        *,
        max_len: int | None = None,
        fill_value: Any = 0,
    ) -> np.ndarray[tuple[int, int], Any]:
        r"""Pack a ragged 1D field into a dense 2D `(N, max_len)` NumPy matrix.

        Args:
            field_name: Name of the ragged field in `_ragged_fields` to pad. Defaults to the sole
                ragged field when `len(_ragged_fields) == 1`.
            max_len: Target column width $L$ of the output 2D matrix. Defaults to `max(self.lengths)`
                (or `0` when the container is empty); rows longer than `max_len` are truncated.
            fill_value: Padding value used to fill positions `[lengths[i] : max_len]`.

        Returns:
            np.ndarray[tuple[int, int], Any]: Dense 2D array of shape `(len(self), max_len)`.

        Raises:
            ValueError: If `field_name` is omitted on a multi-field ragged container, is not in
                `_ragged_fields`, or `max_len < 0`.
        """
        if field_name is None:
            if len(self._ragged_fields) != 1:
                raise ValueError(
                    f"{type(self).__name__} has {len(self._ragged_fields)} ragged fields {self._ragged_fields}; "
                    "specify field_name explicitly"
                )
            target_field = self._ragged_fields[0]
        else:
            if field_name not in self._ragged_fields_set:
                raise ValueError(
                    f"{field_name!r} is not a ragged field of {type(self).__name__} ({self._ragged_fields})"
                )
            target_field = field_name

        data = getattr(self, target_field)
        n = len(self)
        target_len = (int(np.max(self.lengths)) if n > 0 else 0) if max_len is None else int(max_len)
        if target_len < 0:
            raise ValueError(f"max_len must be non-negative, got {target_len}")
        if n == 0 or target_len == 0:
            return np.empty((n, max(target_len, 0)), dtype=data.dtype)
        if data.dtype.kind in ("S", "U", "O"):
            out = np.full((n, target_len), fill_value, dtype=data.dtype)
            for i in range(n):
                row_len = min(int(self.lengths[i]), target_len)
                if row_len > 0:
                    off = int(self.offsets[i])
                    out[i, :row_len] = data[off : off + row_len]
            return out
        typed_fill = data.dtype.type(fill_value)
        return _pad_ragged_kernel(data, self.offsets, self.lengths, target_len, typed_fill)

indptr property

indptr: Int32Array

CSR-compatible \((N + 1)\)-length index pointer array ([0, *cumsum(lengths)]).

lengths_to_offsets staticmethod

lengths_to_offsets(lengths: Int32Array) -> Int32Array

Compute 0-based starting offsets from a 1D lengths array.

Source code in src/seroverse/core/collections.py
@staticmethod
def lengths_to_offsets(
    lengths: Int32Array,
) -> Int32Array:
    r"""Compute 0-based starting offsets from a 1D `lengths` array."""
    return lengths_to_offsets(lengths)

concat classmethod

concat(batches: Iterable[Self]) -> Self

Concatenate multiple ragged containers into a single contiguous batch.

Source code in src/seroverse/core/collections.py
@classmethod
def concat(cls, batches: Iterable[Self]) -> Self:
    r"""Concatenate multiple ragged containers into a single contiguous batch."""
    batches_list = [b for b in batches if len(b) > 0]
    if not batches_list:
        return cls.empty()
    if len(batches_list) == 1:
        return batches_list[0]
    all_lengths = np.concatenate([b.lengths for b in batches_list]).astype(np.int32, copy=False)
    kwargs: dict[str, Any] = {
        "offsets": lengths_to_offsets(all_lengths),
        "lengths": all_lengths,
    }
    for f in cls._ragged_fields:
        parts = [getattr(b, f) for b in batches_list if len(getattr(b, f)) > 0]
        kwargs[f] = np.concatenate(parts) if parts else np.empty(0, dtype=cls._field_dtypes[f])
    for f in cls._record_fields:
        kwargs[f] = np.concatenate([getattr(b, f) for b in batches_list])
    return cls(**kwargs)

from_list classmethod

from_list(items: Sequence[T]) -> Self

Pack a sequence of ScalarView instances into a ragged container.

Source code in src/seroverse/core/collections.py
@classmethod
def from_list(cls, items: Sequence[T]) -> Self:
    r"""Pack a sequence of [`ScalarView`][seroverse.core.collections.ScalarView] instances into a ragged container."""
    if not items:
        return cls.empty()
    scalar_cls = cls._scalar_type or type(items[0])
    s_fields = tuple(f.name for f in fields(scalar_cls))
    field_map = dict(zip(cls._data_fields, s_fields, strict=True))
    first_ragged_s = field_map[cls._ragged_fields[0]]
    lengths = np.array([len(getattr(it, first_ragged_s)) for it in items], dtype=np.int32)
    has_elems = bool(np.any(lengths > 0))
    kwargs: dict[str, Any] = {
        "offsets": lengths_to_offsets(lengths),
        "lengths": lengths,
    }
    for b_f in cls._ragged_fields:
        s_f = field_map[b_f]
        dt = cls._field_dtypes[b_f]
        parts = [np.asarray(getattr(it, s_f), dtype=dt) for it in items if len(getattr(it, s_f)) > 0]
        kwargs[b_f] = np.concatenate(parts) if (has_elems and parts) else np.empty(0, dtype=dt)
    for b_f in cls._record_fields:
        s_f = field_map[b_f]
        dt = cls._field_dtypes[b_f]
        kwargs[b_f] = np.array([getattr(it, s_f) for it in items], dtype=dt)
    return cls(**kwargs)

pad

pad(
    field_name: str | None = None,
    *,
    max_len: int | None = None,
    fill_value: Any = 0,
) -> ndarray[tuple[int, int], Any]

Pack a ragged 1D field into a dense 2D (N, max_len) NumPy matrix.

Parameters:

Name Type Description Default
field_name str | None

Name of the ragged field in _ragged_fields to pad. Defaults to the sole ragged field when len(_ragged_fields) == 1.

None
max_len int | None

Target column width \(L\) of the output 2D matrix. Defaults to max(self.lengths) (or 0 when the container is empty); rows longer than max_len are truncated.

None
fill_value Any

Padding value used to fill positions [lengths[i] : max_len].

0

Returns:

Type Description
ndarray[tuple[int, int], Any]

np.ndarray[tuple[int, int], Any]: Dense 2D array of shape (len(self), max_len).

Raises:

Type Description
ValueError

If field_name is omitted on a multi-field ragged container, is not in _ragged_fields, or max_len < 0.

Source code in src/seroverse/core/collections.py
def pad(
    self,
    field_name: str | None = None,
    *,
    max_len: int | None = None,
    fill_value: Any = 0,
) -> np.ndarray[tuple[int, int], Any]:
    r"""Pack a ragged 1D field into a dense 2D `(N, max_len)` NumPy matrix.

    Args:
        field_name: Name of the ragged field in `_ragged_fields` to pad. Defaults to the sole
            ragged field when `len(_ragged_fields) == 1`.
        max_len: Target column width $L$ of the output 2D matrix. Defaults to `max(self.lengths)`
            (or `0` when the container is empty); rows longer than `max_len` are truncated.
        fill_value: Padding value used to fill positions `[lengths[i] : max_len]`.

    Returns:
        np.ndarray[tuple[int, int], Any]: Dense 2D array of shape `(len(self), max_len)`.

    Raises:
        ValueError: If `field_name` is omitted on a multi-field ragged container, is not in
            `_ragged_fields`, or `max_len < 0`.
    """
    if field_name is None:
        if len(self._ragged_fields) != 1:
            raise ValueError(
                f"{type(self).__name__} has {len(self._ragged_fields)} ragged fields {self._ragged_fields}; "
                "specify field_name explicitly"
            )
        target_field = self._ragged_fields[0]
    else:
        if field_name not in self._ragged_fields_set:
            raise ValueError(
                f"{field_name!r} is not a ragged field of {type(self).__name__} ({self._ragged_fields})"
            )
        target_field = field_name

    data = getattr(self, target_field)
    n = len(self)
    target_len = (int(np.max(self.lengths)) if n > 0 else 0) if max_len is None else int(max_len)
    if target_len < 0:
        raise ValueError(f"max_len must be non-negative, got {target_len}")
    if n == 0 or target_len == 0:
        return np.empty((n, max(target_len, 0)), dtype=data.dtype)
    if data.dtype.kind in ("S", "U", "O"):
        out = np.full((n, target_len), fill_value, dtype=data.dtype)
        for i in range(n):
            row_len = min(int(self.lengths[i]), target_len)
            if row_len > 0:
                off = int(self.offsets[i])
                out[i, :row_len] = data[off : off + row_len]
        return out
    typed_fill = data.dtype.type(fill_value)
    return _pad_ragged_kernel(data, self.offsets, self.lengths, target_len, typed_fill)

Spec dataclass

Bases: ScalarView

Immutable scalar specification for an ontology or reference database record.

Serves as the scalar element view T for SpecContainer and SpecEnum. Every Spec defines a canonical ASCII/UTF-8 name: bytes attribute. Subclasses may add domain-specific fields (e.g. abbreviations, category enums, bitmasks, or numerical priors) and optionally override lookup_keys to register secondary lookup keys.

Methods:

Name Description
lookup_keys

Return normalized lowercase bytes keys used to index this spec in a SpecContainer.

Attributes:

Name Type Description
name_str str

Canonical specification name decoded as a UTF-8 str.

Source code in src/seroverse/core/collections.py
@dataclass(frozen=True, slots=True)
class Spec(ScalarView):
    r"""Immutable scalar specification for an ontology or reference database record.

    Serves as the scalar element view `T` for [`SpecContainer`][seroverse.core.collections.SpecContainer]
    and [`SpecEnum`][seroverse.core.collections.SpecEnum]. Every `Spec` defines a canonical ASCII/UTF-8
    `name: bytes` attribute. Subclasses may add domain-specific fields (e.g. abbreviations, category
    enums, bitmasks, or numerical priors) and optionally override
    [`lookup_keys`][seroverse.core.collections.Spec.lookup_keys] to register secondary lookup keys.
    """

    name: bytes

    @property
    def name_str(self) -> str:
        r"""Canonical specification name decoded as a UTF-8 `str`."""
        return self.name.decode("utf-8", errors="replace")

    def lookup_keys(self) -> tuple[bytes, ...]:
        r"""Return normalized lowercase `bytes` keys used to index this spec in a [`SpecContainer`][seroverse.core.collections.SpecContainer]."""
        return (self.name.strip().lower(),)

name_str property

name_str: str

Canonical specification name decoded as a UTF-8 str.

lookup_keys

lookup_keys() -> tuple[bytes, ...]

Return normalized lowercase bytes keys used to index this spec in a SpecContainer.

Source code in src/seroverse/core/collections.py
def lookup_keys(self) -> tuple[bytes, ...]:
    r"""Return normalized lowercase `bytes` keys used to index this spec in a [`SpecContainer`][seroverse.core.collections.SpecContainer]."""
    return (self.name.strip().lower(),)

SpecContainer dataclass

Bases: BatchedContainer[T, S]

Immutable, read-only Structure-of-Arrays (SoA) database container for Spec records.

Extends BatchedContainer with: - A canonical names: BytesArray column. - Automatic read-only array freezing (arr.setflags(write=False)) across all registered NumPy arrays upon construction (__post_init__). - Lazy \(O(1)\) case-insensitive name/key lookup via .index(key), .resolve_index(key), key in container, and container[key] (str | bytes indexing).

Methods:

Name Description
index

Resolve a Spec name, lookup key, or integer index to its 0-based integer index.

resolve_index

Resolve a Spec name, lookup key, or integer index, returning default if not found.

Source code in src/seroverse/core/collections.py
@dataclass(frozen=True, slots=True)
class SpecContainer[T: Spec, S: SpecContainer[Any, Any]](BatchedContainer[T, S]):
    r"""Immutable, read-only Structure-of-Arrays (SoA) database container for [`Spec`][seroverse.core.collections.Spec] records.

    Extends [`BatchedContainer`][seroverse.core.collections.BatchedContainer] with:
    - A canonical `names: BytesArray` column.
    - Automatic read-only array freezing (`arr.setflags(write=False)`) across all registered NumPy
      arrays upon construction (`__post_init__`).
    - Lazy $O(1)$ case-insensitive name/key lookup via [`.index(key)`][seroverse.core.collections.SpecContainer.index],
      [`.resolve_index(key)`][seroverse.core.collections.SpecContainer.resolve_index], `key in container`,
      and `container[key]` (`str | bytes` indexing).
    """

    names: BytesArray
    _lookup_cache: dict[bytes, int] | None = field(default=None, init=False, repr=False, compare=False, hash=False)
    _scalar_type: ClassVar[type[Any] | None] = Spec

    def __post_init__(self) -> None:
        r"""Freeze all NumPy arrays in the container as read-only (`write=False`)."""
        for f in self._field_dtypes:
            arr = getattr(self, f)
            if isinstance(arr, np.ndarray):
                arr.setflags(write=False)

    def _get_lookup(self) -> dict[bytes, int]:
        r"""Build and cache the case-insensitive `bytes -> int` lookup dictionary."""
        cache = self._lookup_cache
        if cache is not None:
            return cache
        lookup: dict[bytes, int] = {}
        scalar_cls = self._scalar_type or Spec
        has_custom_keys = scalar_cls.lookup_keys is not Spec.lookup_keys
        n = len(self)
        for i in range(n):
            raw_name = bytes(self.names[i]).strip().lower()
            if raw_name:
                lookup[raw_name] = i
            if has_custom_keys:
                for k in self._get_scalar(i).lookup_keys():
                    norm_k = k.strip().lower()
                    if norm_k:
                        lookup[norm_k] = i
        object.__setattr__(self, "_lookup_cache", lookup)
        return lookup

    def index(self, key: str | bytes | int | np.integer[Any]) -> int:
        r"""Resolve a `Spec` name, lookup key, or integer index to its 0-based integer index.

        Args:
            key: Canonical name (`str` or `bytes`, case-insensitive), secondary lookup key, or integer index.

        Returns:
            int: Normalized 0-based index in `0..len(self) - 1`.

        Raises:
            IndexError: If `key` is an out-of-range integer.
            KeyError: If `key` is a `str` or `bytes` not found in the container.
        """
        if isinstance(key, (int, np.integer)):
            return self._resolve_index(key)
        b = key.encode("utf-8") if isinstance(key, str) else bytes(key)
        norm = b.strip().lower()
        lookup = self._get_lookup()
        if norm in lookup:
            return lookup[norm]
        raise KeyError(f"Unknown {type(self).__name__} key: {key!r}")

    def resolve_index(self, key: str | bytes | int | np.integer[Any], default: int = -1) -> int:
        r"""Resolve a `Spec` name, lookup key, or integer index, returning `default` if not found."""
        try:
            return self.index(key)
        except KeyError, IndexError:
            return default

    def __contains__(self, key: object) -> bool:
        r"""Return `True` if `key` (`str`, `bytes`, or `Spec`) exists in this container."""
        if isinstance(key, Spec):
            return key.name.strip().lower() in self._get_lookup()
        if isinstance(key, (str, bytes)):
            b = key.encode("utf-8") if isinstance(key, str) else bytes(key)
            return b.strip().lower() in self._get_lookup()
        return False

    @overload
    def __getitem__(self, item: int | np.integer[Any] | str | bytes) -> T: ...

    @overload
    def __getitem__(self, item: slice | np.ndarray[tuple[int], Any] | list[Any]) -> S: ...

    @overload
    def __getitem__(
        self,
        item: int | np.integer[Any] | str | bytes | slice | np.ndarray[tuple[int], Any] | list[Any],
    ) -> T | S: ...

    def __getitem__(
        self,
        item: int | np.integer[Any] | str | bytes | slice | np.ndarray[tuple[int], Any] | list[Any],
    ) -> T | S:
        r"""Access a single `Spec` view `T` by integer index or name (`str | bytes`), or slice `S`."""
        if isinstance(item, (int, np.integer)):
            return self._get_scalar(self._resolve_index(item))
        if isinstance(item, (str, bytes)):
            return self._get_scalar(self.index(item))
        return self._get_slice(item)

index

index(key: str | bytes | int | integer[Any]) -> int

Resolve a Spec name, lookup key, or integer index to its 0-based integer index.

Parameters:

Name Type Description Default
key str | bytes | int | integer[Any]

Canonical name (str or bytes, case-insensitive), secondary lookup key, or integer index.

required

Returns:

Name Type Description
int int

Normalized 0-based index in 0..len(self) - 1.

Raises:

Type Description
IndexError

If key is an out-of-range integer.

KeyError

If key is a str or bytes not found in the container.

Source code in src/seroverse/core/collections.py
def index(self, key: str | bytes | int | np.integer[Any]) -> int:
    r"""Resolve a `Spec` name, lookup key, or integer index to its 0-based integer index.

    Args:
        key: Canonical name (`str` or `bytes`, case-insensitive), secondary lookup key, or integer index.

    Returns:
        int: Normalized 0-based index in `0..len(self) - 1`.

    Raises:
        IndexError: If `key` is an out-of-range integer.
        KeyError: If `key` is a `str` or `bytes` not found in the container.
    """
    if isinstance(key, (int, np.integer)):
        return self._resolve_index(key)
    b = key.encode("utf-8") if isinstance(key, str) else bytes(key)
    norm = b.strip().lower()
    lookup = self._get_lookup()
    if norm in lookup:
        return lookup[norm]
    raise KeyError(f"Unknown {type(self).__name__} key: {key!r}")

resolve_index

resolve_index(
    key: str | bytes | int | integer[Any], default: int = -1
) -> int

Resolve a Spec name, lookup key, or integer index, returning default if not found.

Source code in src/seroverse/core/collections.py
def resolve_index(self, key: str | bytes | int | np.integer[Any], default: int = -1) -> int:
    r"""Resolve a `Spec` name, lookup key, or integer index, returning `default` if not found."""
    try:
        return self.index(key)
    except KeyError, IndexError:
        return default

SpecEnum

Bases: IntEnum

Base IntEnum for 0-based contiguous ontology registries backed by a singleton SpecContainer.

Subclasses declare standard 0-based integer enum members (MEMBER = 0..N-1) and optionally override _compile_specs to return a domain-specific SpecContainer (if not overridden, a default SpecContainer[Spec, SpecContainer] is compiled from the lowercase enum member names).

Provides out-of-the-box: - cls.specs(): Cached singleton SpecContainer validated to have 1-to-1 contiguous correspondence (0..N-1) with cls. - self.spec: O(1) scalar Spec view for the enum member. - self.name_bytes and self.name_str: Canonical Spec name accessors. - cls.from_name(key) and cls.resolve(key, default=...): O(1) case-insensitive resolution across enum instances, integer IDs, enum member names (_, -, or space-separated), canonical Spec.name, and any secondary Spec.lookup_keys.

Methods:

Name Description
specs

Return the cached, validated singleton SpecContainer for this enum.

from_name

Resolve an enum member by instance, integer value, member name, or Spec name/key.

resolve

Resolve an enum identifier, falling back to default (or cls.UNKNOWN if defined).

Attributes:

Name Type Description
spec Any

Return the scalar Spec view for this enum member.

name_bytes bytes

Canonical Spec.name as raw ASCII/UTF-8 bytes.

name_str str

Canonical Spec.name decoded as a UTF-8 str.

Source code in src/seroverse/core/collections.py
class SpecEnum(IntEnum):
    r"""Base `IntEnum` for 0-based contiguous ontology registries backed by a singleton [`SpecContainer`][seroverse.core.collections.SpecContainer].

    Subclasses declare standard 0-based integer enum members (`MEMBER = 0..N-1`) and optionally override
    `_compile_specs` to return a domain-specific [`SpecContainer`][seroverse.core.collections.SpecContainer]
    (if not overridden, a default `SpecContainer[Spec, SpecContainer]` is compiled from the lowercase
    enum member names).

    Provides out-of-the-box:
    - [`cls.specs()`][seroverse.core.collections.SpecEnum.specs]: Cached singleton `SpecContainer` validated
      to have 1-to-1 contiguous correspondence (`0..N-1`) with `cls`.
    - [`self.spec`][seroverse.core.collections.SpecEnum.spec]: O(1) scalar `Spec` view for the enum member.
    - [`self.name_bytes`][seroverse.core.collections.SpecEnum.name_bytes] and
      [`self.name_str`][seroverse.core.collections.SpecEnum.name_str]: Canonical `Spec` name accessors.
    - [`cls.from_name(key)`][seroverse.core.collections.SpecEnum.from_name] and
      [`cls.resolve(key, default=...)`][seroverse.core.collections.SpecEnum.resolve]: O(1) case-insensitive
      resolution across enum instances, integer IDs, enum member names (`_`, `-`, or space-separated),
      canonical `Spec.name`, and any secondary [`Spec.lookup_keys`][seroverse.core.collections.Spec.lookup_keys].
    """

    @classmethod
    def _compile_specs(cls) -> SpecContainer[Any, Any]:
        r"""Compile the singleton [`SpecContainer`][seroverse.core.collections.SpecContainer] for this enum class."""
        return SpecContainer.from_list([Spec(name=m.name.lower().encode("ascii")) for m in cls])

    @classmethod
    def specs(cls) -> SpecContainer[Any, Any]:
        r"""Return the cached, validated singleton [`SpecContainer`][seroverse.core.collections.SpecContainer] for this enum."""
        cached = _ENUM_SPECS_CACHE.get(cls)
        if cached is not None:
            return cached
        members = tuple(cls)
        expected_ids = tuple(range(len(members)))
        actual_ids = tuple(int(m) for m in members)
        if actual_ids != expected_ids:
            raise ValueError(
                f"{cls.__name__} members must be contiguous 0-based integers 0..{len(members) - 1}, got {actual_ids}"
            )
        container = cls._compile_specs()
        if len(container) != len(members):
            raise ValueError(
                f"{cls.__name__}._compile_specs() returned {len(container)} specs for {len(members)} enum members"
            )
        _ENUM_SPECS_CACHE[cls] = container
        return container

    @property
    def spec(self) -> Any:
        r"""Return the scalar [`Spec`][seroverse.core.collections.Spec] view for this enum member."""
        return self.specs()[int(self)]

    @property
    def name_bytes(self) -> bytes:
        r"""Canonical `Spec.name` as raw ASCII/UTF-8 `bytes`."""
        return bytes(self.specs().names[int(self)])

    @property
    def name_str(self) -> str:
        r"""Canonical `Spec.name` decoded as a UTF-8 `str`."""
        return self.name_bytes.decode("utf-8", errors="replace")

    @classmethod
    def _get_lookup(cls) -> dict[bytes, Self]:
        r"""Build and cache the case-insensitive `bytes -> Self` lookup table for this enum."""
        cached = _ENUM_LOOKUP_CACHE.get(cls)
        if cached is not None:
            return cached
        specs = cls.specs()
        lookup: dict[bytes, Self] = {}
        for m in cls:
            low_member = m.name.lower()
            lookup[low_member.encode("ascii")] = m
            lookup[low_member.replace("_", " ").encode("ascii")] = m
            lookup[low_member.replace("_", "-").encode("ascii")] = m
        for key_bytes, idx in specs._get_lookup().items():
            lookup[key_bytes] = cls(idx)
        _ENUM_LOOKUP_CACHE[cls] = lookup
        return lookup

    @classmethod
    def from_name(cls, key: str | bytes | int | np.integer[Any] | Self) -> Self:
        r"""Resolve an enum member by instance, integer value, member name, or `Spec` name/key.

        Args:
            key: Enum member, 0-based integer value, or string/bytes identifier (case-insensitive).

        Returns:
            Self: Resolved enum member.

        Raises:
            ValueError: If `key` is an integer not corresponding to a valid enum member.
            KeyError: If `key` is a `str` or `bytes` not found in the lookup table.
        """
        if isinstance(key, cls):
            return key
        if isinstance(key, (int, np.integer)) and not isinstance(key, bool):
            return cls(int(key))
        if isinstance(key, (str, bytes)):
            b = key.encode("utf-8") if isinstance(key, str) else bytes(key)
            norm = b.strip().lower()
            lookup = cls._get_lookup()
            if norm in lookup:
                return lookup[norm]
        raise KeyError(f"Unknown {cls.__name__} identifier: {key!r}")

    @classmethod
    def resolve(
        cls,
        key: str | bytes | int | np.integer[Any] | Self,
        default: Self | None = None,
    ) -> Self:
        r"""Resolve an enum identifier, falling back to `default` (or `cls.UNKNOWN` if defined).

        Args:
            key: Enum member, 0-based integer value, or string/bytes identifier.
            default: Explicit fallback member if `key` cannot be resolved. If `None`, falls back
                to `cls.UNKNOWN` when defined on `cls`.

        Returns:
            Self: Resolved enum member or fallback.

        Raises:
            KeyError: If `key` cannot be resolved and neither `default` nor `cls.UNKNOWN` is available.
        """
        try:
            return cls.from_name(key)
        except KeyError, ValueError:
            if default is not None:
                return default
            unknown = cls.__members__.get("UNKNOWN")
            if unknown is not None:
                return unknown
            raise

spec property

spec: Any

Return the scalar Spec view for this enum member.

name_bytes property

name_bytes: bytes

Canonical Spec.name as raw ASCII/UTF-8 bytes.

name_str property

name_str: str

Canonical Spec.name decoded as a UTF-8 str.

specs classmethod

specs() -> SpecContainer[Any, Any]

Return the cached, validated singleton SpecContainer for this enum.

Source code in src/seroverse/core/collections.py
@classmethod
def specs(cls) -> SpecContainer[Any, Any]:
    r"""Return the cached, validated singleton [`SpecContainer`][seroverse.core.collections.SpecContainer] for this enum."""
    cached = _ENUM_SPECS_CACHE.get(cls)
    if cached is not None:
        return cached
    members = tuple(cls)
    expected_ids = tuple(range(len(members)))
    actual_ids = tuple(int(m) for m in members)
    if actual_ids != expected_ids:
        raise ValueError(
            f"{cls.__name__} members must be contiguous 0-based integers 0..{len(members) - 1}, got {actual_ids}"
        )
    container = cls._compile_specs()
    if len(container) != len(members):
        raise ValueError(
            f"{cls.__name__}._compile_specs() returned {len(container)} specs for {len(members)} enum members"
        )
    _ENUM_SPECS_CACHE[cls] = container
    return container

from_name classmethod

from_name(
    key: str | bytes | int | integer[Any] | Self,
) -> Self

Resolve an enum member by instance, integer value, member name, or Spec name/key.

Parameters:

Name Type Description Default
key str | bytes | int | integer[Any] | Self

Enum member, 0-based integer value, or string/bytes identifier (case-insensitive).

required

Returns:

Name Type Description
Self Self

Resolved enum member.

Raises:

Type Description
ValueError

If key is an integer not corresponding to a valid enum member.

KeyError

If key is a str or bytes not found in the lookup table.

Source code in src/seroverse/core/collections.py
@classmethod
def from_name(cls, key: str | bytes | int | np.integer[Any] | Self) -> Self:
    r"""Resolve an enum member by instance, integer value, member name, or `Spec` name/key.

    Args:
        key: Enum member, 0-based integer value, or string/bytes identifier (case-insensitive).

    Returns:
        Self: Resolved enum member.

    Raises:
        ValueError: If `key` is an integer not corresponding to a valid enum member.
        KeyError: If `key` is a `str` or `bytes` not found in the lookup table.
    """
    if isinstance(key, cls):
        return key
    if isinstance(key, (int, np.integer)) and not isinstance(key, bool):
        return cls(int(key))
    if isinstance(key, (str, bytes)):
        b = key.encode("utf-8") if isinstance(key, str) else bytes(key)
        norm = b.strip().lower()
        lookup = cls._get_lookup()
        if norm in lookup:
            return lookup[norm]
    raise KeyError(f"Unknown {cls.__name__} identifier: {key!r}")

resolve classmethod

resolve(
    key: str | bytes | int | integer[Any] | Self,
    default: Self | None = None,
) -> Self

Resolve an enum identifier, falling back to default (or cls.UNKNOWN if defined).

Parameters:

Name Type Description Default
key str | bytes | int | integer[Any] | Self

Enum member, 0-based integer value, or string/bytes identifier.

required
default Self | None

Explicit fallback member if key cannot be resolved. If None, falls back to cls.UNKNOWN when defined on cls.

None

Returns:

Name Type Description
Self Self

Resolved enum member or fallback.

Raises:

Type Description
KeyError

If key cannot be resolved and neither default nor cls.UNKNOWN is available.

Source code in src/seroverse/core/collections.py
@classmethod
def resolve(
    cls,
    key: str | bytes | int | np.integer[Any] | Self,
    default: Self | None = None,
) -> Self:
    r"""Resolve an enum identifier, falling back to `default` (or `cls.UNKNOWN` if defined).

    Args:
        key: Enum member, 0-based integer value, or string/bytes identifier.
        default: Explicit fallback member if `key` cannot be resolved. If `None`, falls back
            to `cls.UNKNOWN` when defined on `cls`.

    Returns:
        Self: Resolved enum member or fallback.

    Raises:
        KeyError: If `key` cannot be resolved and neither `default` nor `cls.UNKNOWN` is available.
    """
    try:
        return cls.from_name(key)
    except KeyError, ValueError:
        if default is not None:
            return default
        unknown = cls.__members__.get("UNKNOWN")
        if unknown is not None:
            return unknown
        raise

lengths_to_offsets

lengths_to_offsets(lengths: Int32Array) -> Int32Array

Compute 0-based exclusive prefix-sum offsets from a 1D array of element lengths.

Parameters:

Name Type Description Default
lengths Int32Array

1D array of non-negative record lengths (int32).

required

Returns:

Name Type Description
Int32Array Int32Array

1D int32 array of starting offsets.

Source code in src/seroverse/core/collections.py
def lengths_to_offsets(
    lengths: Int32Array,
) -> Int32Array:
    r"""Compute 0-based exclusive prefix-sum offsets from a 1D array of element lengths.

    Args:
        lengths: 1D array of non-negative record lengths (`int32`).

    Returns:
        Int32Array: 1D `int32` array of starting offsets.
    """
    n = len(lengths)
    offsets = np.zeros(n, dtype=np.int32)
    if n > 1:
        np.cumsum(lengths[:-1], out=offsets[1:])
    return offsets

normalize_indices

normalize_indices(
    item: slice | ndarray[Any, Any] | list[Any], n: int
) -> Int32Array

Normalize a slice, boolean mask, or integer index sequence into a 1D int32 index array.

Parameters:

Name Type Description Default
item slice | ndarray[Any, Any] | list[Any]

Slice, boolean mask array, or integer index sequence/array.

required
n int

Total number of records in the target container.

required

Returns:

Name Type Description
Int32Array Int32Array

Resolved 0-based int32 indices.

Source code in src/seroverse/core/collections.py
def normalize_indices(
    item: slice | np.ndarray[Any, Any] | list[Any],
    n: int,
) -> Int32Array:
    r"""Normalize a slice, boolean mask, or integer index sequence into a 1D `int32` index array.

    Args:
        item: Slice, boolean mask array, or integer index sequence/array.
        n: Total number of records in the target container.

    Returns:
        Int32Array: Resolved 0-based `int32` indices.
    """
    if isinstance(item, slice):
        return np.arange(n, dtype=np.int32)[item]
    arr = np.asarray(item)
    if arr.dtype.kind == "b":
        return np.nonzero(arr)[0].astype(np.int32)
    if len(arr) == 0:
        return np.empty(0, dtype=np.int32)
    indices = arr.astype(np.int32, copy=False)
    if np.any(indices < 0):
        indices = np.where(indices < 0, indices + n, indices)
    return indices

Genomic Intervals (seroverse.core.interval)

interval

Genomic interval representation, strand orientations, and vectorized Structure-of-Arrays operations.

This module provides discrete coordinate intervals via Interval, strand representations via Strand, and high-performance, vectorized interval collections via Intervals.

Classes:

Name Description
Strand

Integer representation of genomic strand orientation.

Interval

A single 0-based, half-open genomic interval with strand orientation.

Intervals

A high-performance Structure-of-Arrays (SoA) collection of genomic intervals.

Strand

Bases: IntEnum

Integer representation of genomic strand orientation.

Supports conversion from common string formats ('+', '-', '1', '-1').

Attributes:

Name Type Description
FORWARD int

Forward strand (+1).

REVERSE int

Reverse strand (-1).

UNSTRANDED int

Unstranded or unknown orientation (0).

Source code in src/seroverse/core/interval.py
class Strand(IntEnum):
    r"""Integer representation of genomic strand orientation.

    Supports conversion from common string formats (`'+'`, `'-'`, `'1'`, `'-1'`).

    Attributes:
        FORWARD (int): Forward strand (`+1`).
        REVERSE (int): Reverse strand (`-1`).
        UNSTRANDED (int): Unstranded or unknown orientation (`0`).
    """

    FORWARD = 1
    REVERSE = -1
    UNSTRANDED = 0

    @classmethod
    def _missing_(cls, value: object) -> Strand:
        r"""Coerce bytes or integers to a valid [`Strand`][seroverse.core.interval.Strand] instance.

        Args:
            value (object): Value to coerce (e.g. `b'+'`, `b'-'`, `b'1'`, `b'-1'`, `1`, `-1`).

        Returns:
            Strand: Corresponding [`Strand`][seroverse.core.interval.Strand] enum member.
        """
        if value in (b"+", b"1", b"+1", "+", "1", "+1"):
            return Strand.FORWARD
        if value in (b"-", b"-1", "-", "-1"):
            return Strand.REVERSE
        return Strand.UNSTRANDED

    def __bytes__(self) -> bytes:
        r"""Return the single-byte ASCII representation of the strand (`b'+'`, `b'-'`, or `b'.'`)."""
        if self == Strand.FORWARD:
            return b"+"
        if self == Strand.REVERSE:
            return b"-"
        return b"."

    def __str__(self) -> str:
        r"""Return the string representation of the strand (`'+'`, `'-'`, or `'.'`).

        Returns:
            str: Single-character string representation.
        """
        if self == Strand.FORWARD:
            return "+"
        if self == Strand.REVERSE:
            return "-"
        return "."

Interval dataclass

Bases: ScalarView

A single 0-based, half-open genomic interval with strand orientation.

Represents a discrete sequence segment defined by start (inclusive), end (exclusive), and strand orientation.

Attributes:

Name Type Description
start int

0-based starting position (inclusive).

end int

0-based ending position (exclusive).

strand Strand

Strand orientation Strand.

Source code in src/seroverse/core/interval.py
@dataclass(frozen=True, slots=True)
class Interval(ScalarView):
    r"""A single 0-based, half-open genomic interval with strand orientation.

    Represents a discrete sequence segment defined by `start` (inclusive), `end` (exclusive),
    and `strand` orientation.

    Attributes:
        start (int): 0-based starting position (inclusive).
        end (int): 0-based ending position (exclusive).
        strand (Strand): Strand orientation [`Strand`][seroverse.core.interval.Strand].
    """

    start: int
    end: int
    strand: Strand = Strand.UNSTRANDED

    def __len__(self) -> int:
        r"""Calculate the length of the interval in base pairs (`end - start`).

        Returns:
            int: Interval length.
        """
        return self.end - self.start

Intervals dataclass

Bases: BatchedContainer[Interval, 'Intervals']

A high-performance Structure-of-Arrays (SoA) collection of genomic intervals.

Stores starts, ends, and strands as parallel 1D NumPy arrays for memory-efficient vectorized operations and Numba spatial algorithms.

Attributes:

Name Type Description
starts Int32Array

1D array of 0-based start coordinates.

ends Int32Array

1D array of 0-based end coordinates.

strands Int8Array

1D array of strand values (+1, -1, 0).

Source code in src/seroverse/core/interval.py
@dataclass(frozen=True, slots=True)
class Intervals(BatchedContainer[Interval, "Intervals"]):
    r"""A high-performance Structure-of-Arrays (SoA) collection of genomic intervals.

    Stores starts, ends, and strands as parallel 1D NumPy arrays
    for memory-efficient vectorized operations and Numba spatial algorithms.

    Attributes:
        starts (Int32Array): 1D array of 0-based start coordinates.
        ends (Int32Array): 1D array of 0-based end coordinates.
        strands (Int8Array): 1D array of strand values (+1, -1, 0).
    """

    starts: Int32Array
    ends: Int32Array
    strands: Int8Array

    @property
    def lengths(self) -> Int32Array:
        r"""Compute the lengths of all intervals in base pairs (`ends - starts`).

        Returns:
            Int32Array: 1D array of interval lengths.
        """
        return self.ends - self.starts

lengths property

lengths: Int32Array

Compute the lengths of all intervals in base pairs (ends - starts).

Returns:

Name Type Description
Int32Array Int32Array

1D array of interval lengths.


Genome Assemblies & FASTA I/O (seroverse.core.genome, seroverse.core.io)

genome

Classes:

Name Description
GenomeAssembly

Container for a genome assembly with support for transparent decompression.

GenomeAssembly dataclass

Bases: Sequences

Container for a genome assembly with support for transparent decompression.

Stores contig sequence data as a contiguous memory block using Sequences and supports thread-safe, lazy-cached index creation for high-performance sequence alignment.

Attributes:

Name Type Description
id bytes

Unique genome assembly identifier.

Methods:

Name Description
ensure

Ensure the input object is coerced into a GenomeAssembly.

from_fasta

Construct a GenomeAssembly directly from raw binary FASTA data.

from_file

Load a genome assembly from a FASTA file path.

from_stream

Load a genome assembly from an open binary stream.

from_records

Construct a GenomeAssembly from sequence records.

Source code in src/seroverse/core/genome.py
@dataclass(slots=True, frozen=True)
class GenomeAssembly(Sequences):
    r"""Container for a genome assembly with support for transparent decompression.

    Stores contig sequence data as a contiguous memory block using [`Sequences`][seroverse.core.seq.seq.Sequences]
    and supports thread-safe, lazy-cached index creation for high-performance sequence alignment.

    Attributes:
        id (bytes): Unique genome assembly identifier.
    """

    _SEQUENCE_FILE_REGEX: ClassVar[Pattern[str]] = re_compile(
        r"\.(?P<ext>f(asta|a|na|fn|as))(\.(?P<compression>gz|bz2|xz|zst|zstd))?$"
    )
    _OPENERS: ClassVar[dict[str, Callable[..., Any]]] = dict(
        gz=gzip.open, bz2=bz2.open, xz=lzma.open, zst=zstd.open, zstd=zstd.open
    )

    id: bytes = b"unknown"

    @classmethod
    def ensure(cls, genome: Self | str | Path | IO[bytes]) -> Self:
        r"""Ensure the input object is coerced into a [`GenomeAssembly`][seroverse.core.genome.GenomeAssembly].

        Args:
            genome (Self | str | Path | IO[bytes]): Existing assembly object, file path,
                or binary FASTA stream.

        Returns:
            GenomeAssembly: Validated or loaded [`GenomeAssembly`][seroverse.core.genome.GenomeAssembly] instance.
        """
        if isinstance(genome, cls):
            return genome
        elif isinstance(genome, (str, Path)):
            return cls.from_file(genome)
        return cls.from_stream(genome)  # type: ignore

    @classmethod
    def from_fasta(
        cls,
        data: bytes | bytearray | memoryview,
        id_: bytes = b"unknown",
        *,
        truncate_id: bool = True,
        uppercase: bool = True,
    ) -> Self:
        r"""Construct a GenomeAssembly directly from raw binary FASTA data.

        Args:
            data (bytes | bytearray | memoryview): Raw binary FASTA contents.
            id_ (bytes): Genome assembly identifier.
            truncate_id (bool): If `True`, truncates header IDs at first whitespace.
            uppercase (bool): If `True`, normalizes sequence bytes to uppercase ASCII.

        Returns:
            GenomeAssembly: Constructed [`GenomeAssembly`][seroverse.core.genome.GenomeAssembly] instance.
        """
        seqs = FastaReader.from_bytes(data, truncate_id=truncate_id, uppercase=uppercase).load()
        return cls(seqs.ids, seqs.seqs, seqs.offsets, seqs.lengths, id=bytes(id_))

    @classmethod
    def from_file(cls, filepath: str | Path) -> Self:
        r"""Load a genome assembly from a FASTA file path.

        Supports plain `.fasta` / `.fa` / `.fna` files as well as compressed
        `.gz`, `.bz2`, and `.xz` files.

        Args:
            filepath (str | Path): Path to the target FASTA file.

        Returns:
            GenomeAssembly: Loaded [`GenomeAssembly`][seroverse.core.genome.GenomeAssembly] instance.

        Raises:
            NotImplementedError: If the file format or compression extension is unsupported.
        """
        filepath = Path(filepath)
        if not (m := cls._SEQUENCE_FILE_REGEX.search(filepath.name)):
            raise NotImplementedError(f"Unsupported format: {filepath}")

        with cls._OPENERS.get(m.group("compression"), open)(filepath, mode="rb") as handle:
            stem = filepath.name.removesuffix(m.group()).encode("utf-8")
            return cls.from_stream(handle, id_=stem)

    @classmethod
    def from_stream(cls, handle: IO[bytes], id_: bytes | None = None) -> Self:
        r"""Load a genome assembly from an open binary stream.

        Args:
            handle (IO[bytes]): Open binary FASTA stream handle.
            id_ (bytes | None): Custom assembly identifier. If None, derived from
                handle name or defaults to `b'unknown'`.

        Returns:
            GenomeAssembly: Loaded [`GenomeAssembly`][seroverse.core.genome.GenomeAssembly] instance.
        """
        if id_ is None:
            raw_name = getattr(handle, "name", b"unknown")
            id_bytes = raw_name.encode("utf-8") if isinstance(raw_name, str) else bytes(raw_name)
        else:
            id_bytes = bytes(id_)

        seqs = FastaReader(handle).load()
        return cls(seqs.ids, seqs.seqs, seqs.offsets, seqs.lengths, id=id_bytes)

    @classmethod
    def from_records(
        cls,
        records: list[Sequence] | tuple[Sequence, ...] | Iterable[Sequence],
        id_: bytes = b"unknown",
    ) -> Self:
        r"""Construct a [`GenomeAssembly`][seroverse.core.genome.GenomeAssembly] from sequence records.

        Args:
            records (Iterable[Sequence]): Iterable of [`Sequence`][seroverse.core.seq.seq.Sequence] objects.
            id_ (bytes): Genome assembly identifier.

        Returns:
            GenomeAssembly: Constructed [`GenomeAssembly`][seroverse.core.genome.GenomeAssembly] instance.
        """
        seqs = Sequences.from_records(records)
        return cls(seqs.ids, seqs.seqs, seqs.offsets, seqs.lengths, id=bytes(id_))

ensure classmethod

ensure(genome: Self | str | Path | IO[bytes]) -> Self

Ensure the input object is coerced into a GenomeAssembly.

Parameters:

Name Type Description Default
genome Self | str | Path | IO[bytes]

Existing assembly object, file path, or binary FASTA stream.

required

Returns:

Name Type Description
GenomeAssembly Self

Validated or loaded GenomeAssembly instance.

Source code in src/seroverse/core/genome.py
@classmethod
def ensure(cls, genome: Self | str | Path | IO[bytes]) -> Self:
    r"""Ensure the input object is coerced into a [`GenomeAssembly`][seroverse.core.genome.GenomeAssembly].

    Args:
        genome (Self | str | Path | IO[bytes]): Existing assembly object, file path,
            or binary FASTA stream.

    Returns:
        GenomeAssembly: Validated or loaded [`GenomeAssembly`][seroverse.core.genome.GenomeAssembly] instance.
    """
    if isinstance(genome, cls):
        return genome
    elif isinstance(genome, (str, Path)):
        return cls.from_file(genome)
    return cls.from_stream(genome)  # type: ignore

from_fasta classmethod

from_fasta(
    data: bytes | bytearray | memoryview,
    id_: bytes = b"unknown",
    *,
    truncate_id: bool = True,
    uppercase: bool = True,
) -> Self

Construct a GenomeAssembly directly from raw binary FASTA data.

Parameters:

Name Type Description Default
data bytes | bytearray | memoryview

Raw binary FASTA contents.

required
id_ bytes

Genome assembly identifier.

b'unknown'
truncate_id bool

If True, truncates header IDs at first whitespace.

True
uppercase bool

If True, normalizes sequence bytes to uppercase ASCII.

True

Returns:

Name Type Description
GenomeAssembly Self

Constructed GenomeAssembly instance.

Source code in src/seroverse/core/genome.py
@classmethod
def from_fasta(
    cls,
    data: bytes | bytearray | memoryview,
    id_: bytes = b"unknown",
    *,
    truncate_id: bool = True,
    uppercase: bool = True,
) -> Self:
    r"""Construct a GenomeAssembly directly from raw binary FASTA data.

    Args:
        data (bytes | bytearray | memoryview): Raw binary FASTA contents.
        id_ (bytes): Genome assembly identifier.
        truncate_id (bool): If `True`, truncates header IDs at first whitespace.
        uppercase (bool): If `True`, normalizes sequence bytes to uppercase ASCII.

    Returns:
        GenomeAssembly: Constructed [`GenomeAssembly`][seroverse.core.genome.GenomeAssembly] instance.
    """
    seqs = FastaReader.from_bytes(data, truncate_id=truncate_id, uppercase=uppercase).load()
    return cls(seqs.ids, seqs.seqs, seqs.offsets, seqs.lengths, id=bytes(id_))

from_file classmethod

from_file(filepath: str | Path) -> Self

Load a genome assembly from a FASTA file path.

Supports plain .fasta / .fa / .fna files as well as compressed .gz, .bz2, and .xz files.

Parameters:

Name Type Description Default
filepath str | Path

Path to the target FASTA file.

required

Returns:

Name Type Description
GenomeAssembly Self

Loaded GenomeAssembly instance.

Raises:

Type Description
NotImplementedError

If the file format or compression extension is unsupported.

Source code in src/seroverse/core/genome.py
@classmethod
def from_file(cls, filepath: str | Path) -> Self:
    r"""Load a genome assembly from a FASTA file path.

    Supports plain `.fasta` / `.fa` / `.fna` files as well as compressed
    `.gz`, `.bz2`, and `.xz` files.

    Args:
        filepath (str | Path): Path to the target FASTA file.

    Returns:
        GenomeAssembly: Loaded [`GenomeAssembly`][seroverse.core.genome.GenomeAssembly] instance.

    Raises:
        NotImplementedError: If the file format or compression extension is unsupported.
    """
    filepath = Path(filepath)
    if not (m := cls._SEQUENCE_FILE_REGEX.search(filepath.name)):
        raise NotImplementedError(f"Unsupported format: {filepath}")

    with cls._OPENERS.get(m.group("compression"), open)(filepath, mode="rb") as handle:
        stem = filepath.name.removesuffix(m.group()).encode("utf-8")
        return cls.from_stream(handle, id_=stem)

from_stream classmethod

from_stream(
    handle: IO[bytes], id_: bytes | None = None
) -> Self

Load a genome assembly from an open binary stream.

Parameters:

Name Type Description Default
handle IO[bytes]

Open binary FASTA stream handle.

required
id_ bytes | None

Custom assembly identifier. If None, derived from handle name or defaults to b'unknown'.

None

Returns:

Name Type Description
GenomeAssembly Self

Loaded GenomeAssembly instance.

Source code in src/seroverse/core/genome.py
@classmethod
def from_stream(cls, handle: IO[bytes], id_: bytes | None = None) -> Self:
    r"""Load a genome assembly from an open binary stream.

    Args:
        handle (IO[bytes]): Open binary FASTA stream handle.
        id_ (bytes | None): Custom assembly identifier. If None, derived from
            handle name or defaults to `b'unknown'`.

    Returns:
        GenomeAssembly: Loaded [`GenomeAssembly`][seroverse.core.genome.GenomeAssembly] instance.
    """
    if id_ is None:
        raw_name = getattr(handle, "name", b"unknown")
        id_bytes = raw_name.encode("utf-8") if isinstance(raw_name, str) else bytes(raw_name)
    else:
        id_bytes = bytes(id_)

    seqs = FastaReader(handle).load()
    return cls(seqs.ids, seqs.seqs, seqs.offsets, seqs.lengths, id=id_bytes)

from_records classmethod

from_records(
    records: list[Sequence]
    | tuple[Sequence, ...]
    | Iterable[Sequence],
    id_: bytes = b"unknown",
) -> Self

Construct a GenomeAssembly from sequence records.

Parameters:

Name Type Description Default
records Iterable[Sequence]

Iterable of Sequence objects.

required
id_ bytes

Genome assembly identifier.

b'unknown'

Returns:

Name Type Description
GenomeAssembly Self

Constructed GenomeAssembly instance.

Source code in src/seroverse/core/genome.py
@classmethod
def from_records(
    cls,
    records: list[Sequence] | tuple[Sequence, ...] | Iterable[Sequence],
    id_: bytes = b"unknown",
) -> Self:
    r"""Construct a [`GenomeAssembly`][seroverse.core.genome.GenomeAssembly] from sequence records.

    Args:
        records (Iterable[Sequence]): Iterable of [`Sequence`][seroverse.core.seq.seq.Sequence] objects.
        id_ (bytes): Genome assembly identifier.

    Returns:
        GenomeAssembly: Constructed [`GenomeAssembly`][seroverse.core.genome.GenomeAssembly] instance.
    """
    seqs = Sequences.from_records(records)
    return cls(seqs.ids, seqs.seqs, seqs.offsets, seqs.lengths, id=bytes(id_))

fasta

High-performance C-level and zero-copy NumPy FASTA I/O.

Classes:

Name Description
FastaReader

High-performance stateful FASTA stream iterator and vectorized batch loader.

FastaReader

Bases: Iterator[Sequence]

High-performance stateful FASTA stream iterator and vectorized batch loader.

Streams binary FASTA handles in chunks, splitting on \n> boundaries and applying C-level bytes.translate to yield Sequence instances with \(O(1)\) memory overhead, or loads an entire stream at once into a contiguous Sequences Structure-of-Arrays container via load.

Parameters:

Name Type Description Default
handle IO[bytes] | BufferedIOBase | RawIOBase

Open binary stream (IO[bytes]) containing FASTA sequence data.

required
truncate_id bool

If True, truncates header IDs at the first whitespace character.

True
uppercase bool

If True, normalizes sequence bytes to uppercase ASCII.

True
chunk_size int

Read buffer size in bytes for streaming iteration (default 4 MiB).

4 * 1024 * 1024

Methods:

Name Description
from_bytes

Construct a FastaReader from an in-memory binary buffer.

close

Close the underlying binary stream.

load

Load the FASTA stream into memory as a contiguous Sequences batch.

Source code in src/seroverse/core/io/fasta.py
class FastaReader(Iterator[Sequence]):
    r"""High-performance stateful FASTA stream iterator and vectorized batch loader.

    Streams binary FASTA handles in chunks, splitting on `\n>` boundaries and applying
    C-level `bytes.translate` to yield [`Sequence`][seroverse.core.seq.seq.Sequence] instances
    with $O(1)$ memory overhead, or loads an entire stream at once into a contiguous
    [`Sequences`][seroverse.core.seq.seq.Sequences] Structure-of-Arrays container via [`load`][seroverse.core.io.fasta.FastaReader.load].

    Args:
        handle: Open binary stream (`IO[bytes]`) containing FASTA sequence data.
        truncate_id: If `True`, truncates header IDs at the first whitespace character.
        uppercase: If `True`, normalizes sequence bytes to uppercase ASCII.
        chunk_size: Read buffer size in bytes for streaming iteration (default 4 MiB).
    """

    # Precomputed 256-byte C translation table for single-pass uppercase + whitespace deletion
    _UPPER_TABLE: bytes = bytes.maketrans(b"abcdefghijklmnopqrstuvwxyz", b"ABCDEFGHIJKLMNOPQRSTUVWXYZ")
    _WHITESPACE: bytes = b"\r\n\t "

    __slots__ = (
        "_handle",
        "_truncate_id",
        "_uppercase",
        "_chunk_size",
        "_stream",
    )

    def __init__(
        self,
        handle: IO[bytes] | BufferedIOBase | RawIOBase,
        truncate_id: bool = True,
        uppercase: bool = True,
        chunk_size: int = 4 * 1024 * 1024,
    ) -> None:
        """Initialize the stateful FASTA reader."""
        self._handle = handle
        self._truncate_id = truncate_id
        self._uppercase = uppercase
        self._chunk_size = chunk_size
        self._stream: Iterator[Sequence] = self._iter_records()

    @classmethod
    def from_bytes(
        cls,
        data: bytes | bytearray | memoryview,
        truncate_id: bool = True,
        uppercase: bool = True,
        chunk_size: int = 4 * 1024 * 1024,
    ) -> Self:
        """Construct a [`FastaReader`][seroverse.core.io.fasta.FastaReader] from an in-memory binary buffer.

        Args:
            data: Raw binary FASTA buffer (`bytes`, `bytearray`, or `memoryview`).
            truncate_id: If `True`, truncates header IDs at the first whitespace character.
            uppercase: If `True`, normalizes sequence bytes to uppercase ASCII.
            chunk_size: Read buffer size in bytes for streaming iteration.

        Returns:
            FastaReader: Reader wrapping the binary buffer.
        """
        return cls(
            BytesIO(data),
            truncate_id=truncate_id,
            uppercase=uppercase,
            chunk_size=chunk_size,
        )

    def __iter__(self) -> Self:
        """Return the stateful iterator object itself."""
        return self

    def __next__(self) -> Sequence:
        """Fetch the next [`Sequence`][seroverse.core.seq.seq.Sequence] record from the FASTA stream."""
        return next(self._stream)

    def __enter__(self) -> Self:
        """Enter the runtime context for the reader."""
        return self

    def __exit__(self, *_: object) -> None:
        """Exit the runtime context and close the underlying binary stream."""
        self.close()

    def close(self) -> None:
        """Close the underlying binary stream."""
        self._handle.close()

    def _parse_header(self, header: bytes) -> bytes:
        """Extract sequence identifier from a raw FASTA header line (without leading `>`)."""
        if self._truncate_id:
            parts = header.split(None, 1)
            return parts[0] if parts else b""
        return header.strip()

    def _iter_records(self) -> Iterator[Sequence]:
        """Stateful generator yielding `Sequence` records across chunk boundaries."""
        table = self._UPPER_TABLE if self._uppercase else None
        buf = b""
        started = False

        while True:
            chunk = self._handle.read(self._chunk_size)
            if not chunk:
                break
            buf = b"%s%s" % (buf, chunk) if buf else chunk

            if not started:
                if buf[0] == 62:  # ord('>')
                    buf = buf[1:]
                    started = True
                else:
                    nl_gt = buf.find(b"\n>")
                    if nl_gt == -1:
                        buf = buf[-1:] if buf.endswith(b"\n") else b""
                        continue
                    buf = buf[nl_gt + 2 :]
                    started = True

            parts = buf.split(b"\n>")
            if len(parts) > 1:
                for rec in parts[:-1]:
                    header, _, seq_raw = rec.partition(b"\n")
                    yield Sequence(
                        id=self._parse_header(header),
                        seq=seq_raw.translate(table, self._WHITESPACE),
                    )
                buf = parts[-1]

        if started and buf:
            header, _, seq_raw = buf.partition(b"\n")
            yield Sequence(
                id=self._parse_header(header),
                seq=seq_raw.translate(table, self._WHITESPACE),
            )

    def load(self) -> Sequences:
        r"""Load the FASTA stream into memory as a contiguous [`Sequences`][seroverse.core.seq.seq.Sequences] batch.

        Uses C-level `bytes.split`, `bytes.partition`, and 256-byte lookup table `bytes.translate`
        combined with a single `bytearray.join` allocation and zero-copy `np.frombuffer` view.

        Returns:
            Sequences: Packed Structure-of-Arrays container of all parsed sequences.
        """
        raw = self._handle.read()
        if not raw:
            return Sequences.empty()

        # Locate first valid '>' at start of buffer or immediately following a newline
        if raw[0] == 62:  # ord('>')
            first_gt = 0
        else:
            nl_gt = raw.find(b"\n>")
            if nl_gt == -1:
                return Sequences.empty()
            first_gt = nl_gt + 1

        # Split records at line-initial '>' boundaries using C memmem
        records = raw[first_gt + 1 :].split(b"\n>")
        n = len(records)

        table = self._UPPER_TABLE if self._uppercase else None
        ids_list: list[bytes] = []
        seq_parts: list[bytes] = []

        for rec in records:
            header, _, seq_raw = rec.partition(b"\n")
            ids_list.append(self._parse_header(header))
            seq_parts.append(seq_raw.translate(table, self._WHITESPACE))

        ids = np.array(ids_list, dtype="S")
        lengths = np.fromiter((len(s) for s in seq_parts), dtype=np.int32, count=n)
        offsets = np.zeros(n, dtype=np.int32)
        if n > 1:
            np.cumsum(lengths[:-1], out=offsets[1:])

        # Single C-level buffer allocation + zero-copy writable uint8 NumPy view
        joined_buf = bytearray().join(seq_parts)
        seqs = np.frombuffer(joined_buf, dtype=np.uint8) if len(joined_buf) > 0 else np.empty(0, dtype=np.uint8)
        return Sequences(ids, seqs, offsets, lengths)

from_bytes classmethod

from_bytes(
    data: bytes | bytearray | memoryview,
    truncate_id: bool = True,
    uppercase: bool = True,
    chunk_size: int = 4 * 1024 * 1024,
) -> Self

Construct a FastaReader from an in-memory binary buffer.

Parameters:

Name Type Description Default
data bytes | bytearray | memoryview

Raw binary FASTA buffer (bytes, bytearray, or memoryview).

required
truncate_id bool

If True, truncates header IDs at the first whitespace character.

True
uppercase bool

If True, normalizes sequence bytes to uppercase ASCII.

True
chunk_size int

Read buffer size in bytes for streaming iteration.

4 * 1024 * 1024

Returns:

Name Type Description
FastaReader Self

Reader wrapping the binary buffer.

Source code in src/seroverse/core/io/fasta.py
@classmethod
def from_bytes(
    cls,
    data: bytes | bytearray | memoryview,
    truncate_id: bool = True,
    uppercase: bool = True,
    chunk_size: int = 4 * 1024 * 1024,
) -> Self:
    """Construct a [`FastaReader`][seroverse.core.io.fasta.FastaReader] from an in-memory binary buffer.

    Args:
        data: Raw binary FASTA buffer (`bytes`, `bytearray`, or `memoryview`).
        truncate_id: If `True`, truncates header IDs at the first whitespace character.
        uppercase: If `True`, normalizes sequence bytes to uppercase ASCII.
        chunk_size: Read buffer size in bytes for streaming iteration.

    Returns:
        FastaReader: Reader wrapping the binary buffer.
    """
    return cls(
        BytesIO(data),
        truncate_id=truncate_id,
        uppercase=uppercase,
        chunk_size=chunk_size,
    )

close

close() -> None

Close the underlying binary stream.

Source code in src/seroverse/core/io/fasta.py
def close(self) -> None:
    """Close the underlying binary stream."""
    self._handle.close()

load

load() -> Sequences

Load the FASTA stream into memory as a contiguous Sequences batch.

Uses C-level bytes.split, bytes.partition, and 256-byte lookup table bytes.translate combined with a single bytearray.join allocation and zero-copy np.frombuffer view.

Returns:

Name Type Description
Sequences Sequences

Packed Structure-of-Arrays container of all parsed sequences.

Source code in src/seroverse/core/io/fasta.py
def load(self) -> Sequences:
    r"""Load the FASTA stream into memory as a contiguous [`Sequences`][seroverse.core.seq.seq.Sequences] batch.

    Uses C-level `bytes.split`, `bytes.partition`, and 256-byte lookup table `bytes.translate`
    combined with a single `bytearray.join` allocation and zero-copy `np.frombuffer` view.

    Returns:
        Sequences: Packed Structure-of-Arrays container of all parsed sequences.
    """
    raw = self._handle.read()
    if not raw:
        return Sequences.empty()

    # Locate first valid '>' at start of buffer or immediately following a newline
    if raw[0] == 62:  # ord('>')
        first_gt = 0
    else:
        nl_gt = raw.find(b"\n>")
        if nl_gt == -1:
            return Sequences.empty()
        first_gt = nl_gt + 1

    # Split records at line-initial '>' boundaries using C memmem
    records = raw[first_gt + 1 :].split(b"\n>")
    n = len(records)

    table = self._UPPER_TABLE if self._uppercase else None
    ids_list: list[bytes] = []
    seq_parts: list[bytes] = []

    for rec in records:
        header, _, seq_raw = rec.partition(b"\n")
        ids_list.append(self._parse_header(header))
        seq_parts.append(seq_raw.translate(table, self._WHITESPACE))

    ids = np.array(ids_list, dtype="S")
    lengths = np.fromiter((len(s) for s in seq_parts), dtype=np.int32, count=n)
    offsets = np.zeros(n, dtype=np.int32)
    if n > 1:
        np.cumsum(lengths[:-1], out=offsets[1:])

    # Single C-level buffer allocation + zero-copy writable uint8 NumPy view
    joined_buf = bytearray().join(seq_parts)
    seqs = np.frombuffer(joined_buf, dtype=np.uint8) if len(joined_buf) > 0 else np.empty(0, dtype=np.uint8)
    return Sequences(ids, seqs, offsets, lengths)

Alphabets & Genetic Codes (seroverse.core.seq.alphabet)

alphabet

ASCII-based biological alphabets, translation tables, and genetic codes.

This module provides high-performance ASCII alphabet definitions (Alphabet) with pre-computed 256-byte lookup tables for instant \(O(1)\) k-mer bit-packing and reduced alphabet transformations, alongside translation tables (GeneticCode) for vectorised parallel protein translation.

Classes:

Name Description
[`Alphabet`][seroverse.core.seq.alphabet.Alphabet]

ASCII biological alphabet representation with dual LUTs.

[`GeneticCode`][seroverse.core.seq.alphabet.GeneticCode]

Translation table and genetic code representation.

[`AlphabetError`][seroverse.core.seq.alphabet.AlphabetError]

Base exception for alphabet operations.

AlphabetError

Bases: ValueError

Raised when an alphabet definition is invalid or an operation is incompatible.

Source code in src/seroverse/core/seq/alphabet.py
class AlphabetError(ValueError):
    r"""Raised when an alphabet definition is invalid or an operation is incompatible."""

Alphabet dataclass

ASCII biological alphabet with pre-computed lookup tables and dynamic singleton caching.

Provides pre-computed 256-byte lookup tables: - kmer_lut: Maps ASCII characters to dense \([0 \dots K-1]\) integers (with unknown characters mapped to \(K\)) for on-the-fly rolling k-mer and syncmer bit-packing in L1 cache. - ascii_lut: Maps ASCII characters to canonical uppercase / reduced group representative ASCII bytes. - complement_lut: 256-element byte translation table for nucleotide complementation.

Attributes:

Name Type Description
symbols bytes

Canonical uppercase ASCII symbols representing distinct alphabet states.

complement bytes | None

Complemented symbols corresponding to symbols (for nucleotide alphabets).

bits_per_char int

Exact bit width required to represent characters (\((\text{len}-1).\text{bit\_length}()\)).

default_k int

Recommended default k-mer size for indexing.

supports_canonical bool

Whether reverse-complement canonicalization is valid.

kmer_lut ndarray

Read-only 256-byte array for \(O(1)\) dense integer mapping.

ascii_lut ndarray

Read-only 256-byte array for \(O(1)\) ASCII normalization / reduction.

complement_lut ndarray | None

Read-only 256-byte array for nucleotide complementation.

Methods:

Name Description
masker

Calculate k-mer bit-shift parameters and word mask for sequence hashing.

encode

Encode ASCII sequence bytes into dense integer values [0..K-1].

reduce

Perform vectorized reduction of ASCII sequence bytes into reduced representative ASCII characters.

Source code in src/seroverse/core/seq/alphabet.py
@dataclass(frozen=True, slots=True, eq=False, init=False)
class Alphabet:
    r"""ASCII biological alphabet with pre-computed lookup tables and dynamic singleton caching.

    Provides pre-computed 256-byte lookup tables:
    - `kmer_lut`: Maps ASCII characters to dense $[0 \dots K-1]$ integers (with unknown characters mapped to $K$)
      for on-the-fly rolling k-mer and syncmer bit-packing in L1 cache.
    - `ascii_lut`: Maps ASCII characters to canonical uppercase / reduced group representative ASCII bytes.
    - `complement_lut`: 256-element byte translation table for nucleotide complementation.

    Attributes:
        symbols (bytes): Canonical uppercase ASCII symbols representing distinct alphabet states.
        complement (bytes | None): Complemented symbols corresponding to `symbols` (for nucleotide alphabets).
        bits_per_char (int): Exact bit width required to represent characters ($(\text{len}-1).\text{bit\_length}()$).
        default_k (int): Recommended default k-mer size for indexing.
        supports_canonical (bool): Whether reverse-complement canonicalization is valid.
        kmer_lut (np.ndarray): Read-only 256-byte array for $O(1)$ dense integer mapping.
        ascii_lut (np.ndarray): Read-only 256-byte array for $O(1)$ ASCII normalization / reduction.
        complement_lut (np.ndarray | None): Read-only 256-byte array for nucleotide complementation.
    """

    symbols: bytes
    complement: bytes | None = None
    aliases: dict[bytes, bytes] | None = None
    groups: dict[bytes, tuple[bytes, ...]] | None = None
    bits_per_char: int = 0
    default_k: int = 0
    supports_canonical: bool = False
    name: bytes | None = None
    kmer_lut: np.ndarray[tuple[int], np.dtype[np.uint8]] = field(
        default_factory=lambda: np.empty(0, dtype=np.uint8), repr=False
    )
    ascii_lut: np.ndarray[tuple[int], np.dtype[np.uint8]] = field(
        default_factory=lambda: np.empty(0, dtype=np.uint8), repr=False
    )
    complement_lut: np.ndarray[tuple[int], np.dtype[np.uint8]] = field(
        default_factory=lambda: np.empty(0, dtype=np.uint8), repr=False
    )

    _CACHE: ClassVar[dict[Any, Self]] = {}

    DNA: ClassVar[Alphabet]
    AMINO: ClassVar[Alphabet]
    MMSEQS12: ClassVar[Alphabet]

    def __new__(
        cls,
        symbols: bytes,
        complement: bytes | None = None,
        aliases: Mapping[bytes, bytes] | None = None,
        groups: Mapping[bytes, Iterable[bytes]] | None = None,
        default_k: int | None = None,
        supports_canonical: bool | None = None,
        name: bytes | None = None,
    ) -> Self:
        r"""Create or return a cached singleton instance of an Alphabet."""
        sym_bytes = symbols.upper()
        comp_bytes = complement.upper() if complement is not None else None
        norm_aliases = dict(aliases) if aliases else None
        norm_groups = {k: tuple(v) for k, v in groups.items()} if groups else None

        bits_per_char = (len(sym_bytes) - 1).bit_length()
        resolved_k = default_k if default_k is not None else (21 if bits_per_char <= 2 else 7)
        resolved_canonical = supports_canonical if supports_canonical is not None else (comp_bytes is not None)

        cache_key = (
            sym_bytes,
            comp_bytes,
            tuple(sorted(norm_aliases.items())) if norm_aliases else None,
            tuple(sorted((k, tuple(v)) for k, v in norm_groups.items())) if norm_groups else None,
            resolved_k,
            resolved_canonical,
        )

        if cache_key in cls._CACHE:
            existing = cls._CACHE[cache_key]
            if name and not existing.name:
                object.__setattr__(existing, "name", name)
            return existing

        instance = object.__new__(cls)
        cls._CACHE[cache_key] = instance
        instance._init_alphabet(
            symbols=sym_bytes,
            complement=comp_bytes,
            aliases=norm_aliases,
            groups=norm_groups,
            default_k=resolved_k,
            supports_canonical=resolved_canonical,
            name=name,
        )
        return instance

    def _init_alphabet(
        self,
        symbols: bytes,
        complement: bytes | None,
        aliases: dict[bytes, bytes] | None,
        groups: dict[bytes, tuple[bytes, ...]] | None,
        default_k: int,
        supports_canonical: bool,
        name: bytes | None = None,
    ) -> None:
        r"""Internal initialization for building lookup tables and properties."""
        if not symbols.isascii():
            raise AlphabetError("Alphabet symbols must be valid ASCII characters.")

        n_syms = len(symbols)
        if len(set(symbols)) != n_syms:
            raise AlphabetError("Alphabet symbols must be unique.")

        # Bit depth
        bits_per_char = (n_syms - 1).bit_length()

        # 1. Build kmer_lut (ASCII byte -> 0..K-1 dense index; unknowns -> K)
        kmer_lut = np.full(256, n_syms, dtype=np.uint8)

        # 2. Build ascii_lut (ASCII byte -> representative ASCII uppercase byte)
        ascii_lut = np.full(256, ord("X"), dtype=np.uint8)

        if groups is not None:
            # Reduced alphabet groups mapping (e.g. MMseqs12)
            for idx, (rep, members) in enumerate(groups.items()):
                rep_byte = rep[0]
                for m in members:
                    m_byte = m[0]
                    m_lower = m_byte | 0x20
                    kmer_lut[m_byte] = idx
                    kmer_lut[m_lower] = idx
                    ascii_lut[m_byte] = rep_byte
                    ascii_lut[m_lower] = rep_byte
        else:
            # Standard alphabet mapping
            for idx, b in enumerate(symbols):
                b_lower = b | 0x20
                kmer_lut[b] = idx
                kmer_lut[b_lower] = idx
                ascii_lut[b] = b
                ascii_lut[b_lower] = b

        # Apply Aliases
        if aliases:
            for src, dst in aliases.items():
                src_b = src[0]
                src_lower = src_b | 0x20
                dst_b = dst[0]
                target_kmer_idx = kmer_lut[dst_b]
                target_ascii = ascii_lut[dst_b]
                kmer_lut[src_b] = target_kmer_idx
                kmer_lut[src_lower] = target_kmer_idx
                ascii_lut[src_b] = target_ascii
                ascii_lut[src_lower] = target_ascii

        kmer_lut.flags.writeable = False
        ascii_lut.flags.writeable = False

        # 3. Build complement_lut
        if complement is not None:
            if len(complement) != n_syms:
                raise AlphabetError("Complement must have the exact same length as symbols.")
            comp_lut = np.full(256, ord("N"), dtype=np.uint8)
            comp_lut[ord("n")] = ord("n")
            for s, c in zip(symbols, complement, strict=False):
                comp_lut[s] = c
                comp_lut[s | 0x20] = c | 0x20
            if aliases:
                for src, dst in aliases.items():
                    src_b = src[0]
                    dst_b = dst[0]
                    comp_lut[src_b] = comp_lut[dst_b]
                    comp_lut[src_b | 0x20] = comp_lut[dst_b | 0x20]
        else:
            comp_lut = np.arange(256, dtype=np.uint8)
        comp_lut.flags.writeable = False

        object.__setattr__(self, "symbols", symbols)
        object.__setattr__(self, "complement", complement)
        object.__setattr__(self, "aliases", aliases)
        object.__setattr__(self, "groups", groups)
        object.__setattr__(self, "bits_per_char", bits_per_char)
        object.__setattr__(self, "default_k", default_k)
        object.__setattr__(self, "supports_canonical", supports_canonical)
        object.__setattr__(self, "name", name)
        object.__setattr__(self, "kmer_lut", kmer_lut)
        object.__setattr__(self, "ascii_lut", ascii_lut)
        object.__setattr__(self, "complement_lut", comp_lut)

    def __len__(self) -> int:
        r"""Return the number of primary symbols in the alphabet."""
        return len(self.symbols)

    def __contains__(self, item: bytes | int) -> bool:
        r"""Check if a byte or integer byte value is a valid member of this alphabet."""
        if isinstance(item, bytes):
            return len(item) == 1 and bool(self.kmer_lut[item[0]] < len(self.symbols))
        if isinstance(item, int):
            return 0 <= item < 256 and bool(self.kmer_lut[item] < len(self.symbols))
        return False

    def __repr__(self) -> str:
        r"""Return a clean string representation of the Alphabet."""
        if self.name:
            return f"Alphabet.{self.name.decode('ascii', errors='replace')}"
        return f"Alphabet({self.symbols!r}, bits={self.bits_per_char})"

    def __eq__(self, other: object) -> bool:
        r"""Check equality based on alphabet identity or specifications."""
        if self is other:
            return True
        if not isinstance(other, Alphabet):
            return False
        return (
            self.symbols == other.symbols
            and self.complement == other.complement
            and self.bits_per_char == other.bits_per_char
            and self.default_k == other.default_k
            and self.supports_canonical == other.supports_canonical
        )

    def __hash__(self) -> int:
        r"""Return hash for using Alphabet instances in sets or dictionary keys."""
        return hash((self.symbols, self.complement, self.bits_per_char, self.default_k, self.supports_canonical))

    def masker(self, k: int) -> tuple[int, int, type[np.uint32 | np.uint64]]:
        r"""Calculate k-mer bit-shift parameters and word mask for sequence hashing.

        Args:
            k (int): K-mer length.

        Returns:
            tuple[int, int, type[np.uint32 | np.uint64]]: Tuple of (bits_per_char, bitmask, integer_dtype).
        """
        bps = self.bits_per_char
        total_bits = k * bps
        if total_bits <= 32:
            dtype = np.uint32
        elif total_bits <= 64:
            dtype = np.uint64
        else:
            raise ValueError(f"K={k} is too large for 64-bit word packing with {self}")
        mask = (1 << (bps * (k - 1))) - 1
        return bps, mask, dtype

    def encode(
        self, seq: bytes | bytearray | memoryview | np.ndarray[tuple[int], np.dtype[np.uint8]]
    ) -> np.ndarray[tuple[int], np.dtype[np.uint8]]:
        r"""Encode ASCII sequence bytes into dense integer values [0..K-1].

        Args:
            seq (bytes | bytearray | memoryview | np.ndarray): Input sequence bytes or 1D array.

        Returns:
            np.ndarray[tuple[int], np.dtype[np.uint8]]: Array of dense integer indices.
        """
        arr = seq if isinstance(seq, np.ndarray) else np.frombuffer(seq, dtype=np.uint8)
        if arr.ndim == 1 and arr.dtype == np.uint8:
            if len(arr) >= 65_536:
                return _lut_map_parallel(arr, self.kmer_lut)
            return _lut_map_serial(arr, self.kmer_lut)
        return self.kmer_lut[arr]

    def reduce(
        self, seq: bytes | bytearray | memoryview | np.ndarray[tuple[int], np.dtype[np.uint8]]
    ) -> bytes | np.ndarray[tuple[int], np.dtype[np.uint8]]:
        r"""Perform vectorized reduction of ASCII sequence bytes into reduced representative ASCII characters.

        Args:
            seq (bytes | bytearray | memoryview | np.ndarray): Input sequence bytes or 1D array.

        Returns:
            bytes | np.ndarray[tuple[int], np.dtype[np.uint8]]: Reduced sequence(s) in ASCII format.
        """
        if isinstance(seq, np.ndarray):
            if seq.ndim == 1 and seq.dtype == np.uint8:
                if len(seq) >= 65_536:
                    return _lut_map_parallel(seq, self.ascii_lut)
                return _lut_map_serial(seq, self.ascii_lut)
            return self.ascii_lut[seq]
        arr = np.frombuffer(seq, dtype=np.uint8)
        if len(arr) >= 65_536:
            return _lut_map_parallel(arr, self.ascii_lut).tobytes()
        return _lut_map_serial(arr, self.ascii_lut).tobytes()

masker

masker(k: int) -> tuple[int, int, type[uint32 | uint64]]

Calculate k-mer bit-shift parameters and word mask for sequence hashing.

Parameters:

Name Type Description Default
k int

K-mer length.

required

Returns:

Type Description
tuple[int, int, type[uint32 | uint64]]

tuple[int, int, type[np.uint32 | np.uint64]]: Tuple of (bits_per_char, bitmask, integer_dtype).

Source code in src/seroverse/core/seq/alphabet.py
def masker(self, k: int) -> tuple[int, int, type[np.uint32 | np.uint64]]:
    r"""Calculate k-mer bit-shift parameters and word mask for sequence hashing.

    Args:
        k (int): K-mer length.

    Returns:
        tuple[int, int, type[np.uint32 | np.uint64]]: Tuple of (bits_per_char, bitmask, integer_dtype).
    """
    bps = self.bits_per_char
    total_bits = k * bps
    if total_bits <= 32:
        dtype = np.uint32
    elif total_bits <= 64:
        dtype = np.uint64
    else:
        raise ValueError(f"K={k} is too large for 64-bit word packing with {self}")
    mask = (1 << (bps * (k - 1))) - 1
    return bps, mask, dtype

encode

encode(
    seq: bytes
    | bytearray
    | memoryview
    | ndarray[tuple[int], dtype[uint8]],
) -> ndarray[tuple[int], dtype[uint8]]

Encode ASCII sequence bytes into dense integer values [0..K-1].

Parameters:

Name Type Description Default
seq bytes | bytearray | memoryview | ndarray

Input sequence bytes or 1D array.

required

Returns:

Type Description
ndarray[tuple[int], dtype[uint8]]

np.ndarray[tuple[int], np.dtype[np.uint8]]: Array of dense integer indices.

Source code in src/seroverse/core/seq/alphabet.py
def encode(
    self, seq: bytes | bytearray | memoryview | np.ndarray[tuple[int], np.dtype[np.uint8]]
) -> np.ndarray[tuple[int], np.dtype[np.uint8]]:
    r"""Encode ASCII sequence bytes into dense integer values [0..K-1].

    Args:
        seq (bytes | bytearray | memoryview | np.ndarray): Input sequence bytes or 1D array.

    Returns:
        np.ndarray[tuple[int], np.dtype[np.uint8]]: Array of dense integer indices.
    """
    arr = seq if isinstance(seq, np.ndarray) else np.frombuffer(seq, dtype=np.uint8)
    if arr.ndim == 1 and arr.dtype == np.uint8:
        if len(arr) >= 65_536:
            return _lut_map_parallel(arr, self.kmer_lut)
        return _lut_map_serial(arr, self.kmer_lut)
    return self.kmer_lut[arr]

reduce

reduce(
    seq: bytes
    | bytearray
    | memoryview
    | ndarray[tuple[int], dtype[uint8]],
) -> bytes | ndarray[tuple[int], dtype[uint8]]

Perform vectorized reduction of ASCII sequence bytes into reduced representative ASCII characters.

Parameters:

Name Type Description Default
seq bytes | bytearray | memoryview | ndarray

Input sequence bytes or 1D array.

required

Returns:

Type Description
bytes | ndarray[tuple[int], dtype[uint8]]

bytes | np.ndarray[tuple[int], np.dtype[np.uint8]]: Reduced sequence(s) in ASCII format.

Source code in src/seroverse/core/seq/alphabet.py
def reduce(
    self, seq: bytes | bytearray | memoryview | np.ndarray[tuple[int], np.dtype[np.uint8]]
) -> bytes | np.ndarray[tuple[int], np.dtype[np.uint8]]:
    r"""Perform vectorized reduction of ASCII sequence bytes into reduced representative ASCII characters.

    Args:
        seq (bytes | bytearray | memoryview | np.ndarray): Input sequence bytes or 1D array.

    Returns:
        bytes | np.ndarray[tuple[int], np.dtype[np.uint8]]: Reduced sequence(s) in ASCII format.
    """
    if isinstance(seq, np.ndarray):
        if seq.ndim == 1 and seq.dtype == np.uint8:
            if len(seq) >= 65_536:
                return _lut_map_parallel(seq, self.ascii_lut)
            return _lut_map_serial(seq, self.ascii_lut)
        return self.ascii_lut[seq]
    arr = np.frombuffer(seq, dtype=np.uint8)
    if len(arr) >= 65_536:
        return _lut_map_parallel(arr, self.ascii_lut).tobytes()
    return _lut_map_serial(arr, self.ascii_lut).tobytes()

GeneticCode dataclass

NCBI Translation Table representation for codon translation and complementation.

Attributes:

Name Type Description
table_id int

NCBI translation table integer identifier.

name bytes

Descriptive byte-string name of the genetic code.

start_codons frozenset[bytes]

Valid 3-base start codon byte triplets.

stop_codons frozenset[bytes]

Valid 3-base stop codon byte triplets.

Methods:

Name Description
from_table

Resolve a GeneticCode instance by NCBI translation table ID (1, 11) or byte-string name.

is_coding

Check if a nucleotide sequence starts with a valid start codon and ends with a stop codon.

translate

Translate a single nucleotide sequence into an ASCII protein sequence.

translate_ragged

Translate a ragged batch of nucleotide sequences in parallel.

Source code in src/seroverse/core/seq/alphabet.py
@dataclass(frozen=True, slots=True, eq=False, init=False)
class GeneticCode:
    r"""NCBI Translation Table representation for codon translation and complementation.

    Attributes:
        table_id (int): NCBI translation table integer identifier.
        name (bytes): Descriptive byte-string name of the genetic code.
        start_codons (frozenset[bytes]): Valid 3-base start codon byte triplets.
        stop_codons (frozenset[bytes]): Valid 3-base stop codon byte triplets.
    """

    table_id: int
    name: bytes
    start_codons: frozenset[bytes]
    stop_codons: frozenset[bytes]
    _char_map: np.ndarray[tuple[int], np.dtype[np.uint8]] = field(
        default_factory=lambda: np.empty(0, dtype=np.uint8), repr=False
    )
    _codon_map: np.ndarray[tuple[int], np.dtype[np.uint8]] = field(
        default_factory=lambda: np.empty(0, dtype=np.uint8), repr=False
    )
    _starts_mask: np.ndarray[tuple[int], np.dtype[np.bool_]] = field(
        default_factory=lambda: np.empty(0, dtype=np.bool_), repr=False
    )
    _stops_mask: np.ndarray[tuple[int], np.dtype[np.bool_]] = field(
        default_factory=lambda: np.empty(0, dtype=np.bool_), repr=False
    )
    start_mask_64: np.ndarray[tuple[int], np.dtype[np.uint8]] = field(
        default_factory=lambda: np.empty(0, dtype=np.uint8), repr=False
    )
    stop_mask_64: np.ndarray[tuple[int], np.dtype[np.uint8]] = field(
        default_factory=lambda: np.empty(0, dtype=np.uint8), repr=False
    )
    rev_start_mask_64: np.ndarray[tuple[int], np.dtype[np.uint8]] = field(
        default_factory=lambda: np.empty(0, dtype=np.uint8), repr=False
    )
    rev_stop_mask_64: np.ndarray[tuple[int], np.dtype[np.uint8]] = field(
        default_factory=lambda: np.empty(0, dtype=np.uint8), repr=False
    )
    start_log_priors_64: np.ndarray[tuple[int], np.dtype[np.float64]] = field(
        default_factory=lambda: np.empty(0, dtype=np.float64), repr=False
    )
    rev_start_log_priors_64: np.ndarray[tuple[int], np.dtype[np.float64]] = field(
        default_factory=lambda: np.empty(0, dtype=np.float64), repr=False
    )
    stop_log_priors_64: np.ndarray[tuple[int], np.dtype[np.float64]] = field(
        default_factory=lambda: np.empty(0, dtype=np.float64), repr=False
    )
    rev_stop_log_priors_64: np.ndarray[tuple[int], np.dtype[np.float64]] = field(
        default_factory=lambda: np.empty(0, dtype=np.float64), repr=False
    )

    _CACHE: ClassVar[dict[Any, Self]] = {}
    standard: ClassVar[GeneticCode]
    bacterial: ClassVar[GeneticCode]

    def __new__(
        cls,
        table_id: int,
        name: bytes,
        mapping: Mapping[bytes, bytes],
        start_codons: Iterable[bytes],
        stop_codons: Iterable[bytes],
    ) -> Self:
        r"""Create or return a cached singleton instance of a GeneticCode."""
        norm_starts = frozenset(s.upper() for s in start_codons)
        norm_stops = frozenset(s.upper() for s in stop_codons)
        cache_key = (table_id, name, tuple(sorted(mapping.items())), norm_starts, norm_stops)

        if cache_key in cls._CACHE:
            return cls._CACHE[cache_key]

        instance = object.__new__(cls)
        cls._CACHE[cache_key] = instance
        instance._init_code(table_id, name, mapping, norm_starts, norm_stops)
        return instance

    def _init_code(
        self,
        table_id: int,
        name: bytes,
        mapping: Mapping[bytes, bytes],
        start_codons: frozenset[bytes],
        stop_codons: frozenset[bytes],
    ) -> None:
        r"""Internal initialization for building translation tables."""
        object.__setattr__(self, "table_id", table_id)
        object.__setattr__(self, "name", name)
        object.__setattr__(self, "start_codons", start_codons)
        object.__setattr__(self, "stop_codons", stop_codons)

        # Reuse Alphabet.DNA.kmer_lut directly (A=0, C=1, G=2, T/U=3, others=4)
        char_map = Alphabet.DNA.kmer_lut
        object.__setattr__(self, "_char_map", char_map)

        # Build _codon_map: 5*5*5 = 125 entries (88 = ord('X'))
        codon_map = np.full(125, ord("X"), dtype=np.uint8)
        starts_mask = np.zeros(125, dtype=bool)
        stops_mask = np.zeros(125, dtype=bool)

        for codon, aa in mapping.items():
            idx = int(char_map[codon[0]]) * 25 + int(char_map[codon[1]]) * 5 + int(char_map[codon[2]])
            codon_map[idx] = aa[0]

        for sc in start_codons:
            idx = int(char_map[sc[0]]) * 25 + int(char_map[sc[1]]) * 5 + int(char_map[sc[2]])
            starts_mask[idx] = True

        for st in stop_codons:
            idx = int(char_map[st[0]]) * 25 + int(char_map[st[1]]) * 5 + int(char_map[st[2]])
            stops_mask[idx] = True

        # Build 64-entry (4x4x4) trinucleotide LUTs for Viterbi & gene extraction
        start_mask_64 = np.zeros(64, dtype=np.uint8)
        stop_mask_64 = np.zeros(64, dtype=np.uint8)
        rev_start_mask_64 = np.zeros(64, dtype=np.uint8)
        rev_stop_mask_64 = np.zeros(64, dtype=np.uint8)
        start_log_priors_64 = np.full(64, -1e9, dtype=np.float64)
        rev_start_log_priors_64 = np.full(64, -1e9, dtype=np.float64)
        stop_log_priors_64 = np.full(64, -1e9, dtype=np.float64)
        rev_stop_log_priors_64 = np.full(64, -1e9, dtype=np.float64)

        comp_lut = Alphabet.DNA.complement_lut
        default_start_priors = {
            b"ATG": float(np.log(0.83)),
            b"GTG": float(np.log(0.10)),
            b"TTG": float(np.log(0.07)),
        }
        default_stop_priors = {
            b"TAA": float(np.log(0.54)),
            b"TAG": float(np.log(0.16)),
            b"TGA": float(np.log(0.30)),
        }

        # Primary Viterbi start codons (ATG/GTG/TTG subset of start_codons, or all start_codons if none of those)
        primary_starts = {sc for sc in start_codons if sc in default_start_priors}
        if not primary_starts:
            primary_starts = set(start_codons)

        for sc in primary_starts:
            c0, c1, c2 = int(char_map[sc[0]]), int(char_map[sc[1]]), int(char_map[sc[2]])
            if c0 < 4 and c1 < 4 and c2 < 4:
                idx64 = c0 * 16 + c1 * 4 + c2
                start_mask_64[idx64] = 1
                lp = default_start_priors.get(sc, float(np.log(0.05)))
                start_log_priors_64[idx64] = lp

                # Reverse-complement codon on forward strand: (comp(sc[2]), comp(sc[1]), comp(sc[0]))
                rc0 = int(char_map[comp_lut[sc[2]]])
                rc1 = int(char_map[comp_lut[sc[1]]])
                rc2 = int(char_map[comp_lut[sc[0]]])
                r_idx64 = rc0 * 16 + rc1 * 4 + rc2
                rev_start_mask_64[r_idx64] = 1
                rev_start_log_priors_64[r_idx64] = lp

        for st in stop_codons:
            c0, c1, c2 = int(char_map[st[0]]), int(char_map[st[1]]), int(char_map[st[2]])
            if c0 < 4 and c1 < 4 and c2 < 4:
                idx64 = c0 * 16 + c1 * 4 + c2
                stop_mask_64[idx64] = 1
                lp = default_stop_priors.get(st, float(np.log(1.0 / max(len(stop_codons), 1))))
                stop_log_priors_64[idx64] = lp

                rc0 = int(char_map[comp_lut[st[2]]])
                rc1 = int(char_map[comp_lut[st[1]]])
                rc2 = int(char_map[comp_lut[st[0]]])
                r_idx64 = rc0 * 16 + rc1 * 4 + rc2
                rev_stop_mask_64[r_idx64] = 1
                rev_stop_log_priors_64[r_idx64] = lp

        for arr in (
            codon_map,
            starts_mask,
            stops_mask,
            start_mask_64,
            stop_mask_64,
            rev_start_mask_64,
            rev_stop_mask_64,
            start_log_priors_64,
            rev_start_log_priors_64,
            stop_log_priors_64,
            rev_stop_log_priors_64,
        ):
            arr.flags.writeable = False

        object.__setattr__(self, "_codon_map", codon_map)
        object.__setattr__(self, "_starts_mask", starts_mask)
        object.__setattr__(self, "_stops_mask", stops_mask)
        object.__setattr__(self, "start_mask_64", start_mask_64)
        object.__setattr__(self, "stop_mask_64", stop_mask_64)
        object.__setattr__(self, "rev_start_mask_64", rev_start_mask_64)
        object.__setattr__(self, "rev_stop_mask_64", rev_stop_mask_64)
        object.__setattr__(self, "start_log_priors_64", start_log_priors_64)
        object.__setattr__(self, "rev_start_log_priors_64", rev_start_log_priors_64)
        object.__setattr__(self, "stop_log_priors_64", stop_log_priors_64)
        object.__setattr__(self, "rev_stop_log_priors_64", rev_stop_log_priors_64)

    @classmethod
    def from_table(cls, table: int | bytes | GeneticCode) -> GeneticCode:
        r"""Resolve a `GeneticCode` instance by NCBI translation table ID (`1, 11`) or byte-string name."""
        if isinstance(table, GeneticCode):
            return table
        if isinstance(table, int):
            by_id = {
                1: cls.standard,
                11: cls.bacterial,
            }
            if table in by_id:
                return by_id[table]
            raise AlphabetError(f"Unknown NCBI translation table ID: {table}. Supported: {sorted(by_id)}.")
        key = table.strip().lower().replace(b"-", b"_").replace(b" ", b"_")
        by_name = {
            b"standard": cls.standard,
            b"1": cls.standard,
            b"bacterial": cls.bacterial,
            b"11": cls.bacterial,
        }
        if key in by_name:
            return by_name[key]
        raise AlphabetError(f"Unknown genetic code name: {table!r}.")

    def __repr__(self) -> str:
        r"""Return a clean string representation of the GeneticCode."""
        return f"GeneticCode(id={self.table_id}, name={self.name!r})"

    def __eq__(self, other: object) -> bool:
        r"""Check equality based on genetic code table identity or parameters."""
        if self is other:
            return True
        if not isinstance(other, GeneticCode):
            return False
        return (
            self.table_id == other.table_id
            and self.name == other.name
            and self.start_codons == other.start_codons
            and self.stop_codons == other.stop_codons
        )

    def __hash__(self) -> int:
        r"""Return hash based on table ID, name, and start/stop codons."""
        return hash((self.table_id, self.name, self.start_codons, self.stop_codons))

    def is_coding(self, seq: bytes) -> bool:
        r"""Check if a nucleotide sequence starts with a valid start codon and ends with a stop codon.

        Args:
            seq (bytes): Nucleotide sequence to check.

        Returns:
            bool: True if length >= 3 and starts with a valid start codon and ends with a stop codon.
        """
        if len(seq) < 3:
            return False
        return seq[:3].upper() in self.start_codons and seq[-3:].upper() in self.stop_codons

    def translate(
        self,
        seq: bytes | bytearray | memoryview | np.ndarray[tuple[int], np.dtype[np.uint8]],
        frame: int = 0,
        to_stop: bool = False,
    ) -> np.ndarray[tuple[int], np.dtype[np.uint8]]:
        r"""Translate a single nucleotide sequence into an ASCII protein sequence.

        Args:
            seq (bytes | bytearray | memoryview | np.ndarray): Input nucleotide sequence.
            frame (int): Reading frame offset (0, 1, or 2). Defaults to 0.
            to_stop (bool): If True, truncates translation at the first stop codon (*). Defaults to False.

        Returns:
            np.ndarray[tuple[int], np.dtype[np.uint8]]: Translated protein sequence in ASCII format.
        """
        if not isinstance(seq, np.ndarray):
            arr = np.ascontiguousarray(np.frombuffer(seq, np.uint8))
        else:
            arr = np.ascontiguousarray(seq)

        if len(arr) < 3:
            return np.empty(0, dtype=np.uint8)

        return _translate_kernel(arr, frame, self._char_map, self._codon_map, to_stop)

    def translate_ragged(
        self,
        seqs: np.ndarray[tuple[int], np.dtype[np.uint8]],
        offsets: np.ndarray[tuple[int], np.dtype[np.int32]],
        lengths: np.ndarray[tuple[int], np.dtype[np.int32]],
        frames: np.ndarray[tuple[int], np.dtype[np.int8]] | None = None,
        to_stop: bool = False,
    ) -> tuple[
        np.ndarray[tuple[int], np.dtype[np.uint8]],
        np.ndarray[tuple[int], np.dtype[np.int32]],
        np.ndarray[tuple[int], np.dtype[np.int32]],
    ]:
        r"""Translate a ragged batch of nucleotide sequences in parallel.

        Args:
            seqs (np.ndarray[tuple[int], np.dtype[np.uint8]]): Concatenated nucleotide sequence bytes.
            offsets (np.ndarray[tuple[int], np.dtype[np.int32]]): Start offsets per sequence.
            lengths (np.ndarray[tuple[int], np.dtype[np.int32]]): Sequence lengths.
            frames (np.ndarray[tuple[int], np.dtype[np.int8]] | None): Reading frame offsets (0, 1, or 2) per sequence.
            to_stop (bool): If True, truncates translation at the first stop codon (*). Defaults to False.

        Returns:
            tuple[np.ndarray, np.ndarray, np.ndarray]: Tuple of (out_seqs, out_offsets, out_lengths) arrays.
        """
        n_seqs = len(offsets)
        if n_seqs == 0:
            return (
                np.empty(0, dtype=np.uint8),
                np.empty(0, dtype=np.int32),
                np.empty(0, dtype=np.int32),
            )

        return _translate_ragged_kernel(
            seqs,
            offsets,
            lengths,
            frames,
            self._char_map,
            self._codon_map,
            to_stop,
        )

from_table classmethod

from_table(table: int | bytes | GeneticCode) -> GeneticCode

Resolve a GeneticCode instance by NCBI translation table ID (1, 11) or byte-string name.

Source code in src/seroverse/core/seq/alphabet.py
@classmethod
def from_table(cls, table: int | bytes | GeneticCode) -> GeneticCode:
    r"""Resolve a `GeneticCode` instance by NCBI translation table ID (`1, 11`) or byte-string name."""
    if isinstance(table, GeneticCode):
        return table
    if isinstance(table, int):
        by_id = {
            1: cls.standard,
            11: cls.bacterial,
        }
        if table in by_id:
            return by_id[table]
        raise AlphabetError(f"Unknown NCBI translation table ID: {table}. Supported: {sorted(by_id)}.")
    key = table.strip().lower().replace(b"-", b"_").replace(b" ", b"_")
    by_name = {
        b"standard": cls.standard,
        b"1": cls.standard,
        b"bacterial": cls.bacterial,
        b"11": cls.bacterial,
    }
    if key in by_name:
        return by_name[key]
    raise AlphabetError(f"Unknown genetic code name: {table!r}.")

is_coding

is_coding(seq: bytes) -> bool

Check if a nucleotide sequence starts with a valid start codon and ends with a stop codon.

Parameters:

Name Type Description Default
seq bytes

Nucleotide sequence to check.

required

Returns:

Name Type Description
bool bool

True if length >= 3 and starts with a valid start codon and ends with a stop codon.

Source code in src/seroverse/core/seq/alphabet.py
def is_coding(self, seq: bytes) -> bool:
    r"""Check if a nucleotide sequence starts with a valid start codon and ends with a stop codon.

    Args:
        seq (bytes): Nucleotide sequence to check.

    Returns:
        bool: True if length >= 3 and starts with a valid start codon and ends with a stop codon.
    """
    if len(seq) < 3:
        return False
    return seq[:3].upper() in self.start_codons and seq[-3:].upper() in self.stop_codons

translate

translate(
    seq: bytes
    | bytearray
    | memoryview
    | ndarray[tuple[int], dtype[uint8]],
    frame: int = 0,
    to_stop: bool = False,
) -> ndarray[tuple[int], dtype[uint8]]

Translate a single nucleotide sequence into an ASCII protein sequence.

Parameters:

Name Type Description Default
seq bytes | bytearray | memoryview | ndarray

Input nucleotide sequence.

required
frame int

Reading frame offset (0, 1, or 2). Defaults to 0.

0
to_stop bool

If True, truncates translation at the first stop codon (*). Defaults to False.

False

Returns:

Type Description
ndarray[tuple[int], dtype[uint8]]

np.ndarray[tuple[int], np.dtype[np.uint8]]: Translated protein sequence in ASCII format.

Source code in src/seroverse/core/seq/alphabet.py
def translate(
    self,
    seq: bytes | bytearray | memoryview | np.ndarray[tuple[int], np.dtype[np.uint8]],
    frame: int = 0,
    to_stop: bool = False,
) -> np.ndarray[tuple[int], np.dtype[np.uint8]]:
    r"""Translate a single nucleotide sequence into an ASCII protein sequence.

    Args:
        seq (bytes | bytearray | memoryview | np.ndarray): Input nucleotide sequence.
        frame (int): Reading frame offset (0, 1, or 2). Defaults to 0.
        to_stop (bool): If True, truncates translation at the first stop codon (*). Defaults to False.

    Returns:
        np.ndarray[tuple[int], np.dtype[np.uint8]]: Translated protein sequence in ASCII format.
    """
    if not isinstance(seq, np.ndarray):
        arr = np.ascontiguousarray(np.frombuffer(seq, np.uint8))
    else:
        arr = np.ascontiguousarray(seq)

    if len(arr) < 3:
        return np.empty(0, dtype=np.uint8)

    return _translate_kernel(arr, frame, self._char_map, self._codon_map, to_stop)

translate_ragged

translate_ragged(
    seqs: ndarray[tuple[int], dtype[uint8]],
    offsets: ndarray[tuple[int], dtype[int32]],
    lengths: ndarray[tuple[int], dtype[int32]],
    frames: ndarray[tuple[int], dtype[int8]] | None = None,
    to_stop: bool = False,
) -> tuple[
    ndarray[tuple[int], dtype[uint8]],
    ndarray[tuple[int], dtype[int32]],
    ndarray[tuple[int], dtype[int32]],
]

Translate a ragged batch of nucleotide sequences in parallel.

Parameters:

Name Type Description Default
seqs ndarray[tuple[int], dtype[uint8]]

Concatenated nucleotide sequence bytes.

required
offsets ndarray[tuple[int], dtype[int32]]

Start offsets per sequence.

required
lengths ndarray[tuple[int], dtype[int32]]

Sequence lengths.

required
frames ndarray[tuple[int], dtype[int8]] | None

Reading frame offsets (0, 1, or 2) per sequence.

None
to_stop bool

If True, truncates translation at the first stop codon (*). Defaults to False.

False

Returns:

Type Description
tuple[ndarray[tuple[int], dtype[uint8]], ndarray[tuple[int], dtype[int32]], ndarray[tuple[int], dtype[int32]]]

tuple[np.ndarray, np.ndarray, np.ndarray]: Tuple of (out_seqs, out_offsets, out_lengths) arrays.

Source code in src/seroverse/core/seq/alphabet.py
def translate_ragged(
    self,
    seqs: np.ndarray[tuple[int], np.dtype[np.uint8]],
    offsets: np.ndarray[tuple[int], np.dtype[np.int32]],
    lengths: np.ndarray[tuple[int], np.dtype[np.int32]],
    frames: np.ndarray[tuple[int], np.dtype[np.int8]] | None = None,
    to_stop: bool = False,
) -> tuple[
    np.ndarray[tuple[int], np.dtype[np.uint8]],
    np.ndarray[tuple[int], np.dtype[np.int32]],
    np.ndarray[tuple[int], np.dtype[np.int32]],
]:
    r"""Translate a ragged batch of nucleotide sequences in parallel.

    Args:
        seqs (np.ndarray[tuple[int], np.dtype[np.uint8]]): Concatenated nucleotide sequence bytes.
        offsets (np.ndarray[tuple[int], np.dtype[np.int32]]): Start offsets per sequence.
        lengths (np.ndarray[tuple[int], np.dtype[np.int32]]): Sequence lengths.
        frames (np.ndarray[tuple[int], np.dtype[np.int8]] | None): Reading frame offsets (0, 1, or 2) per sequence.
        to_stop (bool): If True, truncates translation at the first stop codon (*). Defaults to False.

    Returns:
        tuple[np.ndarray, np.ndarray, np.ndarray]: Tuple of (out_seqs, out_offsets, out_lengths) arrays.
    """
    n_seqs = len(offsets)
    if n_seqs == 0:
        return (
            np.empty(0, dtype=np.uint8),
            np.empty(0, dtype=np.int32),
            np.empty(0, dtype=np.int32),
        )

    return _translate_ragged_kernel(
        seqs,
        offsets,
        lengths,
        frames,
        self._char_map,
        self._codon_map,
        to_stop,
    )

Sequence Containers (seroverse.core.seq.seq)

seq

Biological sequence data structures, SoA containers, and translation utilities.

This module provides high-performance data structures for storing, manipulating, and translating biological sequences (DNA, RNA, protein). Sequences are stored either as individual immutable Sequence instances or as contiguous, flat arrays in Sequences Structure-of-Arrays (SoA) containers.

Key Classes
  • Sequence: Immutable container for a single sequence record.
  • Sequences: Memory-efficient, Numba-accelerated SoA container.
  • GeneticCode: NCBI genetic code translator.

Classes:

Name Description
Sequence

A simple, immutable container for a single biological sequence.

Sequences

A high-performance SoA container for biological sequences.

Sequence dataclass

Bases: ScalarView

A simple, immutable container for a single biological sequence.

Attributes:

Name Type Description
id bytes

Unique identifier or name of the sequence.

seq bytes

Raw sequence data (e.g. DNA, RNA, or protein).

Methods:

Name Description
to_fasta

Format the sequence record as a standard FASTA byte string.

translate

Translate a nucleotide sequence into a protein sequence using the specified genetic code.

Source code in src/seroverse/core/seq/seq.py
@dataclass(slots=True, frozen=True)
class Sequence(ScalarView):
    r"""A simple, immutable container for a single biological sequence.

    Attributes:
        id (bytes): Unique identifier or name of the sequence.
        seq (bytes): Raw sequence data (e.g. DNA, RNA, or protein).
    """

    id: bytes
    seq: bytes

    def __len__(self) -> int:
        r"""Return the length of the sequence in bytes.

        Returns:
            int: Sequence length in bytes.
        """
        return len(self.seq)

    @overload
    def __getitem__(self, item: int) -> int: ...

    @overload
    def __getitem__(self, item: slice) -> bytes: ...

    def __getitem__(self, item: int | slice) -> int | bytes:
        r"""Access sequence bytes by integer index or slice.

        Args:
            item (int | slice): Integer index or slice.

        Returns:
            int | bytes: Sub-sequence slice (bytes) or single byte character (int).
        """
        return self.seq[item]

    def to_fasta(self) -> bytes:
        r"""Format the sequence record as a standard FASTA byte string.

        Returns:
            bytes: FASTA formatted byte representation of the record.
        """
        return b">%s\n%s\n" % (self.id, self.seq)

    def translate(
        self,
        code: GeneticCode = GeneticCode.bacterial,
        frame: int = 0,
        to_stop: bool = False,
    ) -> Sequence:
        r"""Translate a nucleotide sequence into a protein sequence using the specified genetic code.

        Args:
            code (GeneticCode): Genetic code translation table to use. Defaults to `GeneticCode.bacterial`.
            frame (int): Reading frame offset (0, 1, or 2). Defaults to 0.
            to_stop (bool): If True, truncates translation at the first stop codon (*). Defaults to False.

        Returns:
            Sequence: New sequence record containing the translated protein sequence.
        """
        return Sequence(self.id, code.translate(self.seq, frame=frame, to_stop=to_stop).tobytes())

to_fasta

to_fasta() -> bytes

Format the sequence record as a standard FASTA byte string.

Returns:

Name Type Description
bytes bytes

FASTA formatted byte representation of the record.

Source code in src/seroverse/core/seq/seq.py
def to_fasta(self) -> bytes:
    r"""Format the sequence record as a standard FASTA byte string.

    Returns:
        bytes: FASTA formatted byte representation of the record.
    """
    return b">%s\n%s\n" % (self.id, self.seq)

translate

translate(
    code: GeneticCode = bacterial,
    frame: int = 0,
    to_stop: bool = False,
) -> Sequence

Translate a nucleotide sequence into a protein sequence using the specified genetic code.

Parameters:

Name Type Description Default
code GeneticCode

Genetic code translation table to use. Defaults to GeneticCode.bacterial.

bacterial
frame int

Reading frame offset (0, 1, or 2). Defaults to 0.

0
to_stop bool

If True, truncates translation at the first stop codon (*). Defaults to False.

False

Returns:

Name Type Description
Sequence Sequence

New sequence record containing the translated protein sequence.

Source code in src/seroverse/core/seq/seq.py
def translate(
    self,
    code: GeneticCode = GeneticCode.bacterial,
    frame: int = 0,
    to_stop: bool = False,
) -> Sequence:
    r"""Translate a nucleotide sequence into a protein sequence using the specified genetic code.

    Args:
        code (GeneticCode): Genetic code translation table to use. Defaults to `GeneticCode.bacterial`.
        frame (int): Reading frame offset (0, 1, or 2). Defaults to 0.
        to_stop (bool): If True, truncates translation at the first stop codon (*). Defaults to False.

    Returns:
        Sequence: New sequence record containing the translated protein sequence.
    """
    return Sequence(self.id, code.translate(self.seq, frame=frame, to_stop=to_stop).tobytes())

Sequences dataclass

Bases: RaggedArrayContainer[Sequence, 'Sequences']

A high-performance SoA container for biological sequences.

Stores multiple sequences in a flat, contiguous memory layout using a 1D NumPy array of unsigned 8-bit integers (np.uint8). Individual sequences are accessed via parallel arrays of offsets and lengths.

Attributes:

Name Type Description
ids BytesArray

1D byte string array of identifiers for each sequence.

seqs UInt8Array

Single flat 1D array containing all sequence byte data concatenated.

offsets Int32Array

1D array of start indices in seqs.

lengths Int32Array

1D array of sequence lengths.

Methods:

Name Description
to_fasta

Format the entire batch as a single FASTA byte string.

unique

Return a new Sequences container containing only unique sequences.

index_of

Return the integer index or 1D index array corresponding to the given identifier(s).

by_ids

Fetch sequence record(s) matching the specified identifier(s).

from_bytes

Construct Sequences from a collection of raw sequence byte strings.

from_records

Construct Sequences from a collection of Sequence objects.

extract

Vectorized sub-sequence extraction from the batch.

encode

Encode sequence bytes into dense integer values [0..K-1] according to the given Alphabet.

reduce

Vectorized reduction of sequence characters using a reduced Alphabet.

translate

Vectorized translation of nucleotide sequences into protein sequences.

random

Generate a batch of random sequences sampled uniformly from an Alphabet.

Source code in src/seroverse/core/seq/seq.py
@dataclass(frozen=True, slots=True)
class Sequences(RaggedArrayContainer[Sequence, "Sequences"]):
    r"""A high-performance SoA container for biological sequences.

    Stores multiple sequences in a flat, contiguous memory layout using a 1D NumPy array
    of unsigned 8-bit integers (`np.uint8`). Individual sequences are accessed via parallel arrays
    of offsets and lengths.

    Attributes:
        ids (BytesArray): 1D byte string array of identifiers for each sequence.
        seqs (UInt8Array): Single flat 1D array containing all sequence byte data concatenated.
        offsets (Int32Array): 1D array of start indices in `seqs`.
        lengths (Int32Array): 1D array of sequence lengths.
    """

    _ragged_fields: ClassVar[tuple[str, ...]] = ("seqs",)

    ids: BytesArray
    seqs: UInt8Array  # concatenated sequences
    offsets: Int32Array  # start index per sequence
    lengths: Int32Array  # length per sequence

    def _get_scalar(self, idx: int) -> Sequence:
        r"""Materialize the [`Sequence`][seroverse.core.seq.seq.Sequence] at index `idx`."""
        offset_val = int(self.offsets[idx])
        return Sequence(
            self.ids[idx],
            bytes(self.seqs.data[offset_val : offset_val + int(self.lengths[idx])]),
        )

    def to_fasta(self, use_indices: bool = False) -> bytes:
        r"""Format the entire batch as a single FASTA byte string.

        Args:
            use_indices (bool): If True, uses 0-based integer index as FASTA header (`>0`, `>1`, ...)
                instead of byte string `ids`. Defaults to False.

        Returns:
            bytes: Complete FASTA-formatted byte string containing all sequences.
        """
        if len(self.ids) == 0:
            return b""

        mv = self.seqs.data
        if use_indices:
            return b"".join(
                b">%d\n%s\n" % (i, bytes(mv[o : o + length_val]))
                for i, (o, length_val) in enumerate(zip(self.offsets, self.lengths, strict=False))
            )
        return b"".join(
            b">%s\n%s\n" % (name, bytes(mv[o : o + length_val]))
            for name, o, length_val in zip(self.ids, self.offsets, self.lengths, strict=False)
        )

    @property
    def internal_stops(self) -> BoolArray:
        r"""Vectorized check for internal stop codons in protein sequences.

        Scans each sequence for the stop codon character ('*') before the final position using a Numba kernel.

        Returns:
            BoolArray: A 1D boolean array where True indicates presence of an internal stop.
        """
        return _internal_stops_kernel(self.seqs, self.offsets, self.lengths)

    def unique(self) -> Sequences:
        r"""Return a new Sequences container containing only unique sequences.

        Uses a Numba-accelerated 64-bit FNV-1a hash to identify unique sequences while preserving
        first-occurrence order.

        Returns:
            Sequences: A new container with duplicate sequences removed.
        """
        if len(self) <= 1:
            return self

        hashes = _hash_sequences_kernel(self.seqs, self.offsets, self.lengths)
        _, unique_indices = np.unique(hashes, return_index=True)
        unique_indices.sort()

        return self[unique_indices]

    def index_of(
        self,
        query_ids: np.ndarray[tuple[int], np.dtype[np.bytes_]] | bytes | np.bytes_ | Iterable[bytes],
    ) -> np.ndarray[tuple[int], np.dtype[np.int32]] | int:
        r"""Return the integer index or 1D index array corresponding to the given identifier(s).

        Args:
            query_ids (np.ndarray[np.bytes_] | bytes | np.bytes_ | Iterable[bytes]): Single sequence ID
                or array/iterable of IDs.

        Returns:
            np.ndarray | int: Integer index for scalar queries or 1D array of indices for batch queries.

        Raises:
            KeyError: If any requested sequence identifier is not found.
        """
        if isinstance(query_ids, (bytes, np.bytes_)):
            target = bytes(query_ids)
            matches = np.nonzero(self.ids == target)[0]
            if len(matches) == 0:
                raise KeyError(f"Sequence identifier {target!r} not found")
            return int(matches[0])

        if not isinstance(query_ids, np.ndarray) or query_ids.dtype.kind not in ("S", "a"):
            query_arr = np.array(list(query_ids), dtype="S")
        else:
            query_arr = query_ids

        if len(query_arr) == 0:
            return np.empty(0, dtype=np.int32)

        # Vectorized binary search on sorted index map
        sorter = np.argsort(self.ids)
        insert_idx = np.searchsorted(self.ids, query_arr, sorter=sorter)
        valid = insert_idx < len(self.ids)
        matched_indices = sorter[np.clip(insert_idx, 0, len(self.ids) - 1)]
        is_exact = valid & (self.ids[matched_indices] == query_arr)

        if not np.all(is_exact):
            missing = query_arr[~is_exact]
            raise KeyError(f"Sequence identifiers not found: {missing.tolist()[:5]!r}")

        return matched_indices.astype(np.int32)

    def by_ids(
        self,
        query_ids: np.ndarray[tuple[int], np.dtype[np.bytes_]] | bytes | np.bytes_ | Iterable[bytes],
    ) -> Sequence | Sequences:
        r"""Fetch sequence record(s) matching the specified identifier(s).

        Args:
            query_ids (np.ndarray[np.bytes_] | bytes | np.bytes_ | Iterable[bytes]): Single sequence ID
                or 1D array/iterable of IDs.

        Returns:
            Sequence | Sequences: A scalar [`Sequence`][seroverse.core.seq.seq.Sequence] if a single byte identifier
                is passed, or a sub-[`Sequences`][seroverse.core.seq.seq.Sequences] batch matching the query IDs in order.

        Raises:
            KeyError: If any requested sequence identifier is not found in the container.
        """
        idx = self.index_of(query_ids)
        if isinstance(idx, (int, np.integer)):
            return self[int(idx)]
        return self[idx]

    @overload
    def __getitem__(self, item: int | np.integer[Any] | bytes | np.bytes_) -> Sequence: ...

    @overload
    def __getitem__(self, item: slice | np.ndarray[Any, Any] | list[Any]) -> Sequences: ...

    @overload
    def __getitem__(
        self,
        item: int | np.integer[Any] | slice | np.ndarray[Any, Any] | list[Any] | bytes | np.bytes_,
    ) -> Sequence | Sequences: ...

    def __getitem__(
        self,
        item: int | np.integer[Any] | slice | np.ndarray[Any, Any] | list[Any] | bytes | np.bytes_,
    ) -> Sequence | Sequences:
        r"""Access sequences by index, slice, boolean mask, or byte identifier(s)."""
        if isinstance(item, (bytes, np.bytes_)):
            return self.by_ids(item)
        if isinstance(item, np.ndarray) and item.dtype.kind in ("S", "a"):
            return self.by_ids(item)
        if isinstance(item, list) and len(item) > 0 and isinstance(item[0], (bytes, np.bytes_)):
            return self.by_ids(cast(Iterable[bytes], item))
        return super().__getitem__(item)

    @classmethod
    def from_bytes(
        cls,
        seqs: list[bytes] | tuple[bytes, ...] | Iterable[bytes],
        ids: tuple[bytes, ...] | np.ndarray[tuple[int], np.dtype[np.bytes_]] | list[bytes] | None = None,
    ) -> Sequences:
        r"""Construct Sequences from a collection of raw sequence byte strings.

        Args:
            seqs (Iterable[bytes]): Sequence byte strings.
            ids (tuple[bytes, ...] | np.ndarray[np.bytes_] | list[bytes] | None): Sequence identifiers.
                Defaults to integer byte strings (b"0", b"1", ...).

        Returns:
            Sequences: Newly constructed sequences container.
        """
        seqs_list = list(seqs) if not isinstance(seqs, list) else seqs
        if not seqs_list:
            return cls.empty()

        if ids is None:
            ids_arr = np.array([b"%d" % i for i in range(len(seqs_list))], dtype="S")
        elif isinstance(ids, np.ndarray) and ids.dtype.kind in ("S", "a"):
            ids_arr = ids
        else:
            ids_arr = np.array(ids, dtype="S")

        # Fast C-level concatenation of raw byte buffers
        joined_bytes = b"".join(seqs_list)
        out_seqs = np.frombuffer(joined_bytes, dtype=np.uint8).copy()
        lengths = np.fromiter((len(s) for s in seqs_list), dtype=np.int32, count=len(seqs_list))
        offsets = cls.lengths_to_offsets(lengths)
        return cls(ids_arr, out_seqs, offsets, lengths)

    @classmethod
    def from_records(cls, records: list[Sequence] | tuple[Sequence, ...] | Iterable[Sequence]) -> Sequences:
        r"""Construct Sequences from a collection of Sequence objects.

        Args:
            records (Iterable[Sequence]): Sequence objects.

        Returns:
            Sequences: Newly constructed sequences container.
        """
        records_list = list(records) if not isinstance(records, list) else records
        if not records_list:
            return cls.empty()

        ids = np.array([r.id for r in records_list], dtype="S")
        joined_bytes = b"".join(r.seq for r in records_list)
        out_seqs = np.frombuffer(joined_bytes, dtype=np.uint8).copy()
        lengths = np.fromiter((len(r.seq) for r in records_list), dtype=np.int32, count=len(records_list))
        offsets = cls.lengths_to_offsets(lengths)
        return cls(ids, out_seqs, offsets, lengths)

    def extract(
        self,
        indices: np.ndarray[tuple[int], np.dtype[np.int32]],
        starts: np.ndarray[tuple[int], np.dtype[np.int32]],
        ends: np.ndarray[tuple[int], np.dtype[np.int32]],
        strands: np.ndarray[tuple[int], np.dtype[np.int8]],
        new_ids: tuple[bytes, ...] | np.ndarray[tuple[int], np.dtype[np.bytes_]] | list[bytes] | None = None,
    ) -> Sequences:
        r"""Vectorized sub-sequence extraction from the batch.

        Handles coordinates and reverse-complementation across multiple sequences in parallel using Numba.

        Args:
            indices (np.ndarray): 1D array specifying parent sequence index for each extraction.
            starts (np.ndarray): 0-based start coordinates relative to parent sequence.
            ends (np.ndarray): 0-based end coordinates relative to parent sequence.
            strands (np.ndarray): Strand orientations (1 for forward, -1 for reverse-complement).
            new_ids (tuple[bytes, ...] | np.ndarray | list[bytes] | None): Identifiers for extracted sequences.
                If None, auto-generates names.

        Returns:
            Sequences: New collection of extracted sub-sequences.
        """
        if len(indices) == 0:
            return self.empty()
        if new_ids is None:
            new_ids_arr = np.array(
                [
                    b"%s_%d_%d_%d" % (self.ids[i], x, y, z)
                    for i, x, y, z in zip(indices, starts, ends, strands, strict=False)
                ],
                dtype="S",
            )
        elif isinstance(new_ids, np.ndarray) and new_ids.dtype.kind in ("S", "a"):
            new_ids_arr = new_ids
        else:
            new_ids_arr = np.array(new_ids, dtype="S")

        out_seqs, offsets, lengths = _extract_ragged_kernel(
            self.seqs,
            self.offsets,
            indices,
            starts,
            ends,
            strands,
            Alphabet.DNA.complement_lut,
        )
        return Sequences(new_ids_arr, out_seqs, offsets, lengths)

    def encode(self, alphabet: Alphabet) -> np.ndarray[tuple[int], np.dtype[np.uint8]]:
        r"""Encode sequence bytes into dense integer values [0..K-1] according to the given Alphabet.

        Args:
            alphabet (Alphabet): Alphabet containing precomputed kmer lookup table.

        Returns:
            np.ndarray[tuple[int], np.dtype[np.uint8]]: Array of dense integer indices.
        """
        return alphabet.encode(self.seqs)

    def reduce(self, alphabet: Alphabet) -> Sequences:
        r"""Vectorized reduction of sequence characters using a reduced Alphabet.

        Args:
            alphabet (Alphabet): Reduced alphabet containing precomputed lookup tables (e.g. `Alphabet.MMSEQS12`).

        Returns:
            Sequences: New collection containing reduced ASCII sequence bytes.
        """
        if len(self) == 0:
            return self.empty()
        reduced_seqs = alphabet.reduce(self.seqs)
        return Sequences(self.ids, reduced_seqs, self.offsets.copy(), self.lengths.copy())  # type: ignore

    def translate(
        self,
        code: GeneticCode = GeneticCode.bacterial,
        frames: np.ndarray[tuple[int], np.dtype[np.int8]] | None = None,
        to_stop: bool = False,
    ) -> Sequences:
        r"""Vectorized translation of nucleotide sequences into protein sequences.

        Translates sequences according to the provided [`GeneticCode`][seroverse.core.seq.alphabet.GeneticCode]
        (defaults to NCBI Translation Table 11: `GeneticCode.bacterial`).

        Args:
            code (GeneticCode): Genetic code translation table to use. Defaults to `GeneticCode.bacterial`.
            frames (np.ndarray[tuple[int], np.dtype[np.int8]] | None): Reading frame offsets (0, 1, or 2) per sequence.
                Defaults to frame 0 for all.
            to_stop (bool): If True, truncates translation at the first stop codon (*). Defaults to False.

        Returns:
            Sequences: New collection containing translated protein sequences.
        """
        if len(self) == 0:
            return self.empty()
        out_seqs, offsets, lengths = code.translate_ragged(
            self.seqs, self.offsets, self.lengths, frames=frames, to_stop=to_stop
        )
        return Sequences(self.ids, out_seqs, offsets, lengths)

    @classmethod
    def random(
        cls,
        alphabet: Alphabet = Alphabet.DNA,
        n_seqs: int = 10,
        length: int | tuple[int, int] = (50, 250),
        rng: np.random.Generator | int | None = None,
    ) -> Self:
        r"""Generate a batch of random sequences sampled uniformly from an [`Alphabet`][seroverse.core.seq.alphabet.Alphabet].

        Args:
            alphabet (Alphabet): Alphabet whose primary symbols are sampled uniformly. Defaults to `Alphabet.DNA`.
            n_seqs (int): Number of sequences to generate (`n_seqs >= 0`). Defaults to `10`.
            length (int | tuple[int, int]): Either a fixed integer sequence length or an inclusive
                `(min_len, max_len)` 2-tuple sampled uniformly per sequence. Defaults to `(50, 250)`.
            rng (np.random.Generator | int | None): Optional random generator, integer seed, or `None`
                passed to `np.random.default_rng`.

        Returns:
            Self: A newly generated [`Sequences`][seroverse.core.seq.seq.Sequences] container.
        """
        if n_seqs < 0:
            raise ValueError(f"n_seqs must be >= 0; got {n_seqs}.")
        if n_seqs == 0:
            return cls.empty()

        gen = np.random.default_rng(rng)
        if isinstance(length, (int, np.integer)):
            if int(length) < 0:
                raise ValueError(f"length must be >= 0; got {length}.")
            lengths = np.full(n_seqs, int(length), dtype=np.int32)
        else:
            min_len, max_len = int(length[0]), int(length[1])
            if min_len < 0 or max_len < min_len:
                raise ValueError(f"Invalid length range ({min_len}, {max_len}); require 0 <= min_len <= max_len.")
            lengths = gen.integers(min_len, max_len + 1, size=n_seqs, dtype=np.int32)

        offsets = cls.lengths_to_offsets(lengths)
        total_len = int(offsets[-1] + lengths[-1])
        sym_bytes = np.frombuffer(alphabet.symbols, dtype=np.uint8)
        seqs = sym_bytes[gen.integers(0, len(sym_bytes), size=total_len)]
        ids = np.array([b"%d" % i for i in range(n_seqs)], dtype="S")
        return cls(ids, seqs, offsets, lengths)

internal_stops property

internal_stops: BoolArray

Vectorized check for internal stop codons in protein sequences.

Scans each sequence for the stop codon character ('*') before the final position using a Numba kernel.

Returns:

Name Type Description
BoolArray BoolArray

A 1D boolean array where True indicates presence of an internal stop.

to_fasta

to_fasta(use_indices: bool = False) -> bytes

Format the entire batch as a single FASTA byte string.

Parameters:

Name Type Description Default
use_indices bool

If True, uses 0-based integer index as FASTA header (>0, >1, ...) instead of byte string ids. Defaults to False.

False

Returns:

Name Type Description
bytes bytes

Complete FASTA-formatted byte string containing all sequences.

Source code in src/seroverse/core/seq/seq.py
def to_fasta(self, use_indices: bool = False) -> bytes:
    r"""Format the entire batch as a single FASTA byte string.

    Args:
        use_indices (bool): If True, uses 0-based integer index as FASTA header (`>0`, `>1`, ...)
            instead of byte string `ids`. Defaults to False.

    Returns:
        bytes: Complete FASTA-formatted byte string containing all sequences.
    """
    if len(self.ids) == 0:
        return b""

    mv = self.seqs.data
    if use_indices:
        return b"".join(
            b">%d\n%s\n" % (i, bytes(mv[o : o + length_val]))
            for i, (o, length_val) in enumerate(zip(self.offsets, self.lengths, strict=False))
        )
    return b"".join(
        b">%s\n%s\n" % (name, bytes(mv[o : o + length_val]))
        for name, o, length_val in zip(self.ids, self.offsets, self.lengths, strict=False)
    )

unique

unique() -> Sequences

Return a new Sequences container containing only unique sequences.

Uses a Numba-accelerated 64-bit FNV-1a hash to identify unique sequences while preserving first-occurrence order.

Returns:

Name Type Description
Sequences Sequences

A new container with duplicate sequences removed.

Source code in src/seroverse/core/seq/seq.py
def unique(self) -> Sequences:
    r"""Return a new Sequences container containing only unique sequences.

    Uses a Numba-accelerated 64-bit FNV-1a hash to identify unique sequences while preserving
    first-occurrence order.

    Returns:
        Sequences: A new container with duplicate sequences removed.
    """
    if len(self) <= 1:
        return self

    hashes = _hash_sequences_kernel(self.seqs, self.offsets, self.lengths)
    _, unique_indices = np.unique(hashes, return_index=True)
    unique_indices.sort()

    return self[unique_indices]

index_of

index_of(
    query_ids: ndarray[tuple[int], dtype[bytes_]]
    | bytes
    | bytes_
    | Iterable[bytes],
) -> ndarray[tuple[int], dtype[int32]] | int

Return the integer index or 1D index array corresponding to the given identifier(s).

Parameters:

Name Type Description Default
query_ids ndarray[bytes_] | bytes | bytes_ | Iterable[bytes]

Single sequence ID or array/iterable of IDs.

required

Returns:

Type Description
ndarray[tuple[int], dtype[int32]] | int

np.ndarray | int: Integer index for scalar queries or 1D array of indices for batch queries.

Raises:

Type Description
KeyError

If any requested sequence identifier is not found.

Source code in src/seroverse/core/seq/seq.py
def index_of(
    self,
    query_ids: np.ndarray[tuple[int], np.dtype[np.bytes_]] | bytes | np.bytes_ | Iterable[bytes],
) -> np.ndarray[tuple[int], np.dtype[np.int32]] | int:
    r"""Return the integer index or 1D index array corresponding to the given identifier(s).

    Args:
        query_ids (np.ndarray[np.bytes_] | bytes | np.bytes_ | Iterable[bytes]): Single sequence ID
            or array/iterable of IDs.

    Returns:
        np.ndarray | int: Integer index for scalar queries or 1D array of indices for batch queries.

    Raises:
        KeyError: If any requested sequence identifier is not found.
    """
    if isinstance(query_ids, (bytes, np.bytes_)):
        target = bytes(query_ids)
        matches = np.nonzero(self.ids == target)[0]
        if len(matches) == 0:
            raise KeyError(f"Sequence identifier {target!r} not found")
        return int(matches[0])

    if not isinstance(query_ids, np.ndarray) or query_ids.dtype.kind not in ("S", "a"):
        query_arr = np.array(list(query_ids), dtype="S")
    else:
        query_arr = query_ids

    if len(query_arr) == 0:
        return np.empty(0, dtype=np.int32)

    # Vectorized binary search on sorted index map
    sorter = np.argsort(self.ids)
    insert_idx = np.searchsorted(self.ids, query_arr, sorter=sorter)
    valid = insert_idx < len(self.ids)
    matched_indices = sorter[np.clip(insert_idx, 0, len(self.ids) - 1)]
    is_exact = valid & (self.ids[matched_indices] == query_arr)

    if not np.all(is_exact):
        missing = query_arr[~is_exact]
        raise KeyError(f"Sequence identifiers not found: {missing.tolist()[:5]!r}")

    return matched_indices.astype(np.int32)

by_ids

by_ids(
    query_ids: ndarray[tuple[int], dtype[bytes_]]
    | bytes
    | bytes_
    | Iterable[bytes],
) -> Sequence | Sequences

Fetch sequence record(s) matching the specified identifier(s).

Parameters:

Name Type Description Default
query_ids ndarray[bytes_] | bytes | bytes_ | Iterable[bytes]

Single sequence ID or 1D array/iterable of IDs.

required

Returns:

Type Description
Sequence | Sequences

Sequence | Sequences: A scalar Sequence if a single byte identifier is passed, or a sub-Sequences batch matching the query IDs in order.

Raises:

Type Description
KeyError

If any requested sequence identifier is not found in the container.

Source code in src/seroverse/core/seq/seq.py
def by_ids(
    self,
    query_ids: np.ndarray[tuple[int], np.dtype[np.bytes_]] | bytes | np.bytes_ | Iterable[bytes],
) -> Sequence | Sequences:
    r"""Fetch sequence record(s) matching the specified identifier(s).

    Args:
        query_ids (np.ndarray[np.bytes_] | bytes | np.bytes_ | Iterable[bytes]): Single sequence ID
            or 1D array/iterable of IDs.

    Returns:
        Sequence | Sequences: A scalar [`Sequence`][seroverse.core.seq.seq.Sequence] if a single byte identifier
            is passed, or a sub-[`Sequences`][seroverse.core.seq.seq.Sequences] batch matching the query IDs in order.

    Raises:
        KeyError: If any requested sequence identifier is not found in the container.
    """
    idx = self.index_of(query_ids)
    if isinstance(idx, (int, np.integer)):
        return self[int(idx)]
    return self[idx]

from_bytes classmethod

from_bytes(
    seqs: list[bytes] | tuple[bytes, ...] | Iterable[bytes],
    ids: tuple[bytes, ...]
    | ndarray[tuple[int], dtype[bytes_]]
    | list[bytes]
    | None = None,
) -> Sequences

Construct Sequences from a collection of raw sequence byte strings.

Parameters:

Name Type Description Default
seqs Iterable[bytes]

Sequence byte strings.

required
ids tuple[bytes, ...] | ndarray[bytes_] | list[bytes] | None

Sequence identifiers. Defaults to integer byte strings (b"0", b"1", ...).

None

Returns:

Name Type Description
Sequences Sequences

Newly constructed sequences container.

Source code in src/seroverse/core/seq/seq.py
@classmethod
def from_bytes(
    cls,
    seqs: list[bytes] | tuple[bytes, ...] | Iterable[bytes],
    ids: tuple[bytes, ...] | np.ndarray[tuple[int], np.dtype[np.bytes_]] | list[bytes] | None = None,
) -> Sequences:
    r"""Construct Sequences from a collection of raw sequence byte strings.

    Args:
        seqs (Iterable[bytes]): Sequence byte strings.
        ids (tuple[bytes, ...] | np.ndarray[np.bytes_] | list[bytes] | None): Sequence identifiers.
            Defaults to integer byte strings (b"0", b"1", ...).

    Returns:
        Sequences: Newly constructed sequences container.
    """
    seqs_list = list(seqs) if not isinstance(seqs, list) else seqs
    if not seqs_list:
        return cls.empty()

    if ids is None:
        ids_arr = np.array([b"%d" % i for i in range(len(seqs_list))], dtype="S")
    elif isinstance(ids, np.ndarray) and ids.dtype.kind in ("S", "a"):
        ids_arr = ids
    else:
        ids_arr = np.array(ids, dtype="S")

    # Fast C-level concatenation of raw byte buffers
    joined_bytes = b"".join(seqs_list)
    out_seqs = np.frombuffer(joined_bytes, dtype=np.uint8).copy()
    lengths = np.fromiter((len(s) for s in seqs_list), dtype=np.int32, count=len(seqs_list))
    offsets = cls.lengths_to_offsets(lengths)
    return cls(ids_arr, out_seqs, offsets, lengths)

from_records classmethod

from_records(
    records: list[Sequence]
    | tuple[Sequence, ...]
    | Iterable[Sequence],
) -> Sequences

Construct Sequences from a collection of Sequence objects.

Parameters:

Name Type Description Default
records Iterable[Sequence]

Sequence objects.

required

Returns:

Name Type Description
Sequences Sequences

Newly constructed sequences container.

Source code in src/seroverse/core/seq/seq.py
@classmethod
def from_records(cls, records: list[Sequence] | tuple[Sequence, ...] | Iterable[Sequence]) -> Sequences:
    r"""Construct Sequences from a collection of Sequence objects.

    Args:
        records (Iterable[Sequence]): Sequence objects.

    Returns:
        Sequences: Newly constructed sequences container.
    """
    records_list = list(records) if not isinstance(records, list) else records
    if not records_list:
        return cls.empty()

    ids = np.array([r.id for r in records_list], dtype="S")
    joined_bytes = b"".join(r.seq for r in records_list)
    out_seqs = np.frombuffer(joined_bytes, dtype=np.uint8).copy()
    lengths = np.fromiter((len(r.seq) for r in records_list), dtype=np.int32, count=len(records_list))
    offsets = cls.lengths_to_offsets(lengths)
    return cls(ids, out_seqs, offsets, lengths)

extract

extract(
    indices: ndarray[tuple[int], dtype[int32]],
    starts: ndarray[tuple[int], dtype[int32]],
    ends: ndarray[tuple[int], dtype[int32]],
    strands: ndarray[tuple[int], dtype[int8]],
    new_ids: tuple[bytes, ...]
    | ndarray[tuple[int], dtype[bytes_]]
    | list[bytes]
    | None = None,
) -> Sequences

Vectorized sub-sequence extraction from the batch.

Handles coordinates and reverse-complementation across multiple sequences in parallel using Numba.

Parameters:

Name Type Description Default
indices ndarray

1D array specifying parent sequence index for each extraction.

required
starts ndarray

0-based start coordinates relative to parent sequence.

required
ends ndarray

0-based end coordinates relative to parent sequence.

required
strands ndarray

Strand orientations (1 for forward, -1 for reverse-complement).

required
new_ids tuple[bytes, ...] | ndarray | list[bytes] | None

Identifiers for extracted sequences. If None, auto-generates names.

None

Returns:

Name Type Description
Sequences Sequences

New collection of extracted sub-sequences.

Source code in src/seroverse/core/seq/seq.py
def extract(
    self,
    indices: np.ndarray[tuple[int], np.dtype[np.int32]],
    starts: np.ndarray[tuple[int], np.dtype[np.int32]],
    ends: np.ndarray[tuple[int], np.dtype[np.int32]],
    strands: np.ndarray[tuple[int], np.dtype[np.int8]],
    new_ids: tuple[bytes, ...] | np.ndarray[tuple[int], np.dtype[np.bytes_]] | list[bytes] | None = None,
) -> Sequences:
    r"""Vectorized sub-sequence extraction from the batch.

    Handles coordinates and reverse-complementation across multiple sequences in parallel using Numba.

    Args:
        indices (np.ndarray): 1D array specifying parent sequence index for each extraction.
        starts (np.ndarray): 0-based start coordinates relative to parent sequence.
        ends (np.ndarray): 0-based end coordinates relative to parent sequence.
        strands (np.ndarray): Strand orientations (1 for forward, -1 for reverse-complement).
        new_ids (tuple[bytes, ...] | np.ndarray | list[bytes] | None): Identifiers for extracted sequences.
            If None, auto-generates names.

    Returns:
        Sequences: New collection of extracted sub-sequences.
    """
    if len(indices) == 0:
        return self.empty()
    if new_ids is None:
        new_ids_arr = np.array(
            [
                b"%s_%d_%d_%d" % (self.ids[i], x, y, z)
                for i, x, y, z in zip(indices, starts, ends, strands, strict=False)
            ],
            dtype="S",
        )
    elif isinstance(new_ids, np.ndarray) and new_ids.dtype.kind in ("S", "a"):
        new_ids_arr = new_ids
    else:
        new_ids_arr = np.array(new_ids, dtype="S")

    out_seqs, offsets, lengths = _extract_ragged_kernel(
        self.seqs,
        self.offsets,
        indices,
        starts,
        ends,
        strands,
        Alphabet.DNA.complement_lut,
    )
    return Sequences(new_ids_arr, out_seqs, offsets, lengths)

encode

encode(
    alphabet: Alphabet,
) -> ndarray[tuple[int], dtype[uint8]]

Encode sequence bytes into dense integer values [0..K-1] according to the given Alphabet.

Parameters:

Name Type Description Default
alphabet Alphabet

Alphabet containing precomputed kmer lookup table.

required

Returns:

Type Description
ndarray[tuple[int], dtype[uint8]]

np.ndarray[tuple[int], np.dtype[np.uint8]]: Array of dense integer indices.

Source code in src/seroverse/core/seq/seq.py
def encode(self, alphabet: Alphabet) -> np.ndarray[tuple[int], np.dtype[np.uint8]]:
    r"""Encode sequence bytes into dense integer values [0..K-1] according to the given Alphabet.

    Args:
        alphabet (Alphabet): Alphabet containing precomputed kmer lookup table.

    Returns:
        np.ndarray[tuple[int], np.dtype[np.uint8]]: Array of dense integer indices.
    """
    return alphabet.encode(self.seqs)

reduce

reduce(alphabet: Alphabet) -> Sequences

Vectorized reduction of sequence characters using a reduced Alphabet.

Parameters:

Name Type Description Default
alphabet Alphabet

Reduced alphabet containing precomputed lookup tables (e.g. Alphabet.MMSEQS12).

required

Returns:

Name Type Description
Sequences Sequences

New collection containing reduced ASCII sequence bytes.

Source code in src/seroverse/core/seq/seq.py
def reduce(self, alphabet: Alphabet) -> Sequences:
    r"""Vectorized reduction of sequence characters using a reduced Alphabet.

    Args:
        alphabet (Alphabet): Reduced alphabet containing precomputed lookup tables (e.g. `Alphabet.MMSEQS12`).

    Returns:
        Sequences: New collection containing reduced ASCII sequence bytes.
    """
    if len(self) == 0:
        return self.empty()
    reduced_seqs = alphabet.reduce(self.seqs)
    return Sequences(self.ids, reduced_seqs, self.offsets.copy(), self.lengths.copy())  # type: ignore

translate

translate(
    code: GeneticCode = bacterial,
    frames: ndarray[tuple[int], dtype[int8]] | None = None,
    to_stop: bool = False,
) -> Sequences

Vectorized translation of nucleotide sequences into protein sequences.

Translates sequences according to the provided GeneticCode (defaults to NCBI Translation Table 11: GeneticCode.bacterial).

Parameters:

Name Type Description Default
code GeneticCode

Genetic code translation table to use. Defaults to GeneticCode.bacterial.

bacterial
frames ndarray[tuple[int], dtype[int8]] | None

Reading frame offsets (0, 1, or 2) per sequence. Defaults to frame 0 for all.

None
to_stop bool

If True, truncates translation at the first stop codon (*). Defaults to False.

False

Returns:

Name Type Description
Sequences Sequences

New collection containing translated protein sequences.

Source code in src/seroverse/core/seq/seq.py
def translate(
    self,
    code: GeneticCode = GeneticCode.bacterial,
    frames: np.ndarray[tuple[int], np.dtype[np.int8]] | None = None,
    to_stop: bool = False,
) -> Sequences:
    r"""Vectorized translation of nucleotide sequences into protein sequences.

    Translates sequences according to the provided [`GeneticCode`][seroverse.core.seq.alphabet.GeneticCode]
    (defaults to NCBI Translation Table 11: `GeneticCode.bacterial`).

    Args:
        code (GeneticCode): Genetic code translation table to use. Defaults to `GeneticCode.bacterial`.
        frames (np.ndarray[tuple[int], np.dtype[np.int8]] | None): Reading frame offsets (0, 1, or 2) per sequence.
            Defaults to frame 0 for all.
        to_stop (bool): If True, truncates translation at the first stop codon (*). Defaults to False.

    Returns:
        Sequences: New collection containing translated protein sequences.
    """
    if len(self) == 0:
        return self.empty()
    out_seqs, offsets, lengths = code.translate_ragged(
        self.seqs, self.offsets, self.lengths, frames=frames, to_stop=to_stop
    )
    return Sequences(self.ids, out_seqs, offsets, lengths)

random classmethod

random(
    alphabet: Alphabet = DNA,
    n_seqs: int = 10,
    length: int | tuple[int, int] = (50, 250),
    rng: Generator | int | None = None,
) -> Self

Generate a batch of random sequences sampled uniformly from an Alphabet.

Parameters:

Name Type Description Default
alphabet Alphabet

Alphabet whose primary symbols are sampled uniformly. Defaults to Alphabet.DNA.

DNA
n_seqs int

Number of sequences to generate (n_seqs >= 0). Defaults to 10.

10
length int | tuple[int, int]

Either a fixed integer sequence length or an inclusive (min_len, max_len) 2-tuple sampled uniformly per sequence. Defaults to (50, 250).

(50, 250)
rng Generator | int | None

Optional random generator, integer seed, or None passed to np.random.default_rng.

None

Returns:

Name Type Description
Self Self

A newly generated Sequences container.

Source code in src/seroverse/core/seq/seq.py
@classmethod
def random(
    cls,
    alphabet: Alphabet = Alphabet.DNA,
    n_seqs: int = 10,
    length: int | tuple[int, int] = (50, 250),
    rng: np.random.Generator | int | None = None,
) -> Self:
    r"""Generate a batch of random sequences sampled uniformly from an [`Alphabet`][seroverse.core.seq.alphabet.Alphabet].

    Args:
        alphabet (Alphabet): Alphabet whose primary symbols are sampled uniformly. Defaults to `Alphabet.DNA`.
        n_seqs (int): Number of sequences to generate (`n_seqs >= 0`). Defaults to `10`.
        length (int | tuple[int, int]): Either a fixed integer sequence length or an inclusive
            `(min_len, max_len)` 2-tuple sampled uniformly per sequence. Defaults to `(50, 250)`.
        rng (np.random.Generator | int | None): Optional random generator, integer seed, or `None`
            passed to `np.random.default_rng`.

    Returns:
        Self: A newly generated [`Sequences`][seroverse.core.seq.seq.Sequences] container.
    """
    if n_seqs < 0:
        raise ValueError(f"n_seqs must be >= 0; got {n_seqs}.")
    if n_seqs == 0:
        return cls.empty()

    gen = np.random.default_rng(rng)
    if isinstance(length, (int, np.integer)):
        if int(length) < 0:
            raise ValueError(f"length must be >= 0; got {length}.")
        lengths = np.full(n_seqs, int(length), dtype=np.int32)
    else:
        min_len, max_len = int(length[0]), int(length[1])
        if min_len < 0 or max_len < min_len:
            raise ValueError(f"Invalid length range ({min_len}, {max_len}); require 0 <= min_len <= max_len.")
        lengths = gen.integers(min_len, max_len + 1, size=n_seqs, dtype=np.int32)

    offsets = cls.lengths_to_offsets(lengths)
    total_len = int(offsets[-1] + lengths[-1])
    sym_bytes = np.frombuffer(alphabet.symbols, dtype=np.uint8)
    seqs = sym_bytes[gen.integers(0, len(sym_bytes), size=total_len)]
    ids = np.array([b"%d" % i for i in range(n_seqs)], dtype="S")
    return cls(ids, seqs, offsets, lengths)

K-mer & Randstrobe Indices (seroverse.core.seq.kmers)

kmers

K-mer indexing, FracMinHash, and Randstrobe sketch construction and search.

This module provides sequence sketch indices (BaseKmerIndex, FracMinHashIndex, RandstrobeIndex) and Numba JIT parallel search kernels emitting Seeds.

Classes:

Name Description
BaseKmerIndex

Data-Oriented Structure-of-Arrays (SoA) container for sequence k-mer sketches.

FracMinHashIndex

Specialized index for fast sequence comparisons using FracMinHash.

RandstrobeIndex

Specialized index for fast sequence comparisons using syncmer-linked randstrobes.

BaseKmerIndex dataclass

Data-Oriented Structure-of-Arrays (SoA) container for sequence k-mer sketches.

Attributes:

Name Type Description
hashes ndarray[tuple[int], dtype[uint64]]

1D array of 64-bit sketch hashes.

seq_indices ndarray[tuple[int], dtype[uint32]]

1D array of source sequence indices.

positions ndarray[tuple[int], dtype[uint32]]

1D array of start positions in sequences.

offsets ndarray[tuple[int], dtype[int32]]

1D array of sequence record start offsets.

lengths ndarray[tuple[int], dtype[int32]]

1D array of record counts per sequence.

n_seqs int

Total number of indexed sequences.

is_sorted bool

True if records are sorted by hash.

k int

K-mer length parameter.

alphabet Alphabet

Alphabet used for sequence encoding.

reduce bool

True if using a reduced alphabet mapping.

lut ndarray[tuple[int], dtype[uint8]] | None

Optional custom lookup table.

Methods:

Name Description
empty

Create an empty index instance using subclass field defaults.

to_sorted

Return a copy of the index with records sorted by hash.

top_hits

Find the single best-matching target sequence for each query sequence.

hits

Find all matching target sequences or individual anchors for each query sequence.

Source code in src/seroverse/core/seq/kmers.py
@dataclass(frozen=True, slots=True, kw_only=True)
class BaseKmerIndex:
    r"""Data-Oriented Structure-of-Arrays (SoA) container for sequence k-mer sketches.

    Attributes:
        hashes (np.ndarray[tuple[int], np.dtype[np.uint64]]): 1D array of 64-bit sketch hashes.
        seq_indices (np.ndarray[tuple[int], np.dtype[np.uint32]]): 1D array of source sequence indices.
        positions (np.ndarray[tuple[int], np.dtype[np.uint32]]): 1D array of start positions in sequences.
        offsets (np.ndarray[tuple[int], np.dtype[np.int32]]): 1D array of sequence record start offsets.
        lengths (np.ndarray[tuple[int], np.dtype[np.int32]]): 1D array of record counts per sequence.
        n_seqs (int): Total number of indexed sequences.
        is_sorted (bool): True if records are sorted by hash.
        k (int): K-mer length parameter.
        alphabet (Alphabet): Alphabet used for sequence encoding.
        reduce (bool): True if using a reduced alphabet mapping.
        lut (np.ndarray[tuple[int], np.dtype[np.uint8]] | None): Optional custom lookup table.
    """

    hashes: np.ndarray[tuple[int], np.dtype[np.uint64]]
    seq_indices: np.ndarray[tuple[int], np.dtype[np.uint32]]
    positions: np.ndarray[tuple[int], np.dtype[np.uint32]]
    offsets: np.ndarray[tuple[int], np.dtype[np.int32]]
    lengths: np.ndarray[tuple[int], np.dtype[np.int32]]
    n_seqs: int = 0
    is_sorted: bool = False
    k: int = 10
    alphabet: Alphabet = Alphabet.DNA
    reduce: bool = False
    lut: np.ndarray[tuple[int], np.dtype[np.uint8]] | None = None

    def __len__(self) -> int:
        r"""Return the number of indexed sequences."""
        return self.n_seqs

    @property
    def total_records(self) -> int:
        r"""Total number of k-mer / sketch records across all sequences."""
        return len(self.hashes)

    @property
    def indptr(self) -> np.ndarray[tuple[int], np.dtype[np.uint32]]:
        r"""CSR-compatible $(N + 1)$-length index pointer array (`[0, *cumsum(lengths)]`)."""
        n = len(self.offsets)
        if n == 0:
            return np.zeros(1, dtype=np.uint32)
        out = np.empty(n + 1, dtype=np.uint32)
        out[:n] = self.offsets
        out[n] = self.offsets[-1] + self.lengths[-1]
        return out

    @classmethod
    def empty(cls) -> Self:
        r"""Create an empty index instance using subclass field defaults."""
        return cls(
            hashes=np.empty(0, dtype=np.uint64),
            seq_indices=np.empty(0, dtype=np.uint32),
            positions=np.empty(0, dtype=np.uint32),
            offsets=np.empty(0, dtype=np.int32),
            lengths=np.empty(0, dtype=np.int32),
        )

    def _extract_soa(self, batch: Sequences) -> _SketchSoA:
        r"""Extract `(offsets, lengths, hashes, seq_indices, positions)` SoA arrays from `batch`."""
        raise NotImplementedError

    def _populate(self, batch: Sequences, *, sort_by_hash: bool = False) -> Self:
        r"""Populate a new index instance from `batch` using this instance's configured parameters."""
        if len(batch) == 0:
            return replace(
                self,
                hashes=np.empty(0, dtype=np.uint64),
                seq_indices=np.empty(0, dtype=np.uint32),
                positions=np.empty(0, dtype=np.uint32),
                offsets=np.empty(0, dtype=np.int32),
                lengths=np.empty(0, dtype=np.int32),
                n_seqs=0,
                is_sorted=False,
            )
        out_offsets, counts, out_hashes, out_seq_indices, out_positions = self._extract_soa(batch)
        if len(out_hashes) == 0:
            return replace(
                self,
                hashes=out_hashes,
                seq_indices=out_seq_indices,
                positions=out_positions,
                offsets=out_offsets,
                lengths=counts,
                n_seqs=0,
                is_sorted=False,
            )
        if sort_by_hash:
            out_hashes, out_seq_indices, out_positions = _radix_sort_soa(out_hashes, out_seq_indices, out_positions)
        return replace(
            self,
            hashes=out_hashes,
            seq_indices=out_seq_indices,
            positions=out_positions,
            offsets=out_offsets,
            lengths=counts,
            n_seqs=len(batch),
            is_sorted=sort_by_hash,
        )

    def to_sorted(self) -> Self:
        r"""Return a copy of the index with records sorted by hash."""
        if self.is_sorted:
            return self
        sorted_h, sorted_s, sorted_p = _radix_sort_soa(self.hashes, self.seq_indices, self.positions)
        return replace(self, hashes=sorted_h, seq_indices=sorted_s, positions=sorted_p, is_sorted=True)

    def _build_queries(self, queries: Sequences) -> Self:
        r"""Build unsorted query index with parameters matching `self`."""
        return self._populate(queries, sort_by_hash=False)

    def _prep_queries(self, queries: BaseKmerIndex | Sequences) -> BaseKmerIndex:
        r"""Prepare and validate query index against target index state."""
        if not self.is_sorted:
            raise ValueError("Target index must be sorted by hash for binary search. Build it with sort_by_hash=True.")
        if isinstance(queries, Sequences):
            queries = self._build_queries(queries)
        if queries.is_sorted:
            raise ValueError("Query index must NOT be sorted by hash. Build it with sort_by_hash=False.")
        return queries

    def _prep_self_hits(
        self,
    ) -> tuple[
        np.ndarray[tuple[int], np.dtype[np.uint64]],
        np.ndarray[tuple[int], np.dtype[np.uint32]],
        np.ndarray[tuple[int], np.dtype[np.uint64]],
        np.ndarray[tuple[int], np.dtype[np.uint32]],
        np.ndarray[tuple[int], np.dtype[np.uint32]],
        np.ndarray[tuple[int], np.dtype[np.uint32]],
    ]:
        r"""Prepare query and target records for self-hits."""
        if self.is_sorted:
            order = np.argsort(self.seq_indices, kind="stable")
            q_offsets = _compute_query_offsets(self.seq_indices[order], self.n_seqs)
            return (
                self.hashes[order],
                self.positions[order],
                self.hashes,
                self.seq_indices,
                self.positions,
                q_offsets,
            )
        t_hashes, t_seq_indices, t_positions = _radix_sort_soa(self.hashes, self.seq_indices, self.positions)
        return self.hashes, self.positions, t_hashes, t_seq_indices, t_positions, self.indptr

    def _default_matrix(self) -> SubstitutionMatrix:
        r"""Return the default SubstitutionMatrix matching this index's alphabet."""
        return SubstitutionMatrix.DNA if self.alphabet.supports_canonical else SubstitutionMatrix.BLOSUM62

    def _search(
        self,
        queries: BaseKmerIndex | Sequences | None,
        min_score: int,
        mode: int,
        query_seqs: Sequences | None,
        target_seqs: Sequences | None,
        matrix: SubstitutionMatrix | None,
    ) -> Seeds:
        r"""Execute hash-range intersection and seed extraction (`mode`: 0=top_hits, 1=aggregated, 2=raw anchors)."""
        if (queries is not None and len(queries) == 0) or len(self.hashes) == 0:
            return Seeds.empty()

        q_seqs_eff = queries if isinstance(queries, Sequences) else query_seqs
        if queries is None:
            q_hashes, q_positions, t_hashes, t_seq_indices, t_positions, q_offsets = self._prep_self_hits()
            num_queries = self.n_seqs
            if q_seqs_eff is None and target_seqs is not None:
                q_seqs_eff = target_seqs
        else:
            queries_idx = self._prep_queries(queries)
            q_hashes, q_positions = queries_idx.hashes, queries_idx.positions
            t_hashes, t_seq_indices, t_positions = self.hashes, self.seq_indices, self.positions
            num_queries = queries_idx.n_seqs
            q_offsets = queries_idx.indptr

        has_seqs = q_seqs_eff is not None and target_seqs is not None
        mat = matrix if matrix is not None else self._default_matrix()
        empty_u8 = np.empty(0, dtype=np.uint8)
        empty_i32 = np.empty(0, dtype=np.int32)

        match_starts, match_ends = _find_hash_ranges_kernel(q_hashes, t_hashes)
        out_q, out_t, out_s, out_o, out_qp, out_tp = _search_seeds_kernel(
            q_positions,
            q_offsets,
            num_queries,
            match_starts,
            match_ends,
            t_seq_indices,
            t_positions,
            min_score,
            mode,
            has_seqs,
            q_seqs_eff.seqs if q_seqs_eff is not None else empty_u8,
            q_seqs_eff.offsets if q_seqs_eff is not None else empty_i32,
            q_seqs_eff.lengths if q_seqs_eff is not None else empty_i32,
            target_seqs.seqs if target_seqs is not None else empty_u8,
            target_seqs.offsets if target_seqs is not None else empty_i32,
            target_seqs.lengths if target_seqs is not None else empty_i32,
            mat.lut,
        )
        if len(out_q) == 0:
            return Seeds.empty()
        return Seeds(out_q, out_t, out_s, out_o, out_qp, out_tp, np.full(len(out_q), self.k, dtype=np.uint16))

    def top_hits(
        self,
        queries: BaseKmerIndex | Sequences | None = None,
        min_score: int = 1,
        *,
        query_seqs: Sequences | None = None,
        target_seqs: Sequences | None = None,
        matrix: SubstitutionMatrix | None = None,
    ) -> Seeds:
        r"""Find the single best-matching target sequence for each query sequence.

        Args:
            queries (BaseKmerIndex | Sequences | None): Query sequences or index. If None, queries itself.
            min_score (int): Minimum match score threshold. Defaults to 1.
            query_seqs (Sequences | None): Optional query sequences for ungapped diagonal tie-breaking.
            target_seqs (Sequences | None): Optional target sequences for ungapped diagonal tie-breaking.
            matrix (SubstitutionMatrix | None): Substitution matrix for ungapped diagonal scoring.

        Returns:
            Seeds: Matched [`Seeds`][seroverse.core.align.seeds.Seeds] collection.
        """
        return self._search(queries, min_score, 0, query_seqs, target_seqs, matrix)

    def hits(
        self,
        queries: BaseKmerIndex | Sequences | None = None,
        min_score: int = 1,
        *,
        aggregate: bool = True,
        query_seqs: Sequences | None = None,
        target_seqs: Sequences | None = None,
        matrix: SubstitutionMatrix | None = None,
    ) -> Seeds:
        r"""Find all matching target sequences or individual anchors for each query sequence.

        When `aggregate=True` (default), uses single-pass binary search over `self.hashes` followed
        by per-query sorted run-length grouping and modal/consensus diagonal voting to return one
        consensus [`Seed`][seroverse.core.align.seeds.Seed] per qualifying `(query, target)` pair.
        When `aggregate=False`, emits every individual k-mer / strobe anchor hit for qualifying
        `(query, target)` pairs sorted by `(query_index, target_index, t_pos, q_pos)` for direct
        consumption by [`Chainer`][seroverse.core.align.chaining.Chainer].

        Args:
            queries (BaseKmerIndex | Sequences | None): Query sequence index or raw sequences. If None, queries itself.
            min_score (int): Minimum shared k-mer count required for a `(query, target)` pair. Defaults to 1.
            aggregate (bool): If True, aggregate hits per `(query, target)` pair into a consensus diagonal seed.
                If False, return all individual `(q_pos, t_pos, span)` anchor occurrences sorted for collinear
                chaining. Defaults to True.
            query_seqs (Sequences | None): Optional query sequences for ungapped diagonal tie-breaking.
            target_seqs (Sequences | None): Optional target sequences for ungapped diagonal tie-breaking.
            matrix (SubstitutionMatrix | None): Substitution matrix for ungapped diagonal scoring.

        Returns:
            Seeds: Matched [`Seeds`][seroverse.core.align.seeds.Seeds] collection.
        """
        return self._search(queries, min_score, 1 if aggregate else 2, query_seqs, target_seqs, matrix)

total_records property

total_records: int

Total number of k-mer / sketch records across all sequences.

indptr property

indptr: ndarray[tuple[int], dtype[uint32]]

CSR-compatible \((N + 1)\)-length index pointer array ([0, *cumsum(lengths)]).

empty classmethod

empty() -> Self

Create an empty index instance using subclass field defaults.

Source code in src/seroverse/core/seq/kmers.py
@classmethod
def empty(cls) -> Self:
    r"""Create an empty index instance using subclass field defaults."""
    return cls(
        hashes=np.empty(0, dtype=np.uint64),
        seq_indices=np.empty(0, dtype=np.uint32),
        positions=np.empty(0, dtype=np.uint32),
        offsets=np.empty(0, dtype=np.int32),
        lengths=np.empty(0, dtype=np.int32),
    )

to_sorted

to_sorted() -> Self

Return a copy of the index with records sorted by hash.

Source code in src/seroverse/core/seq/kmers.py
def to_sorted(self) -> Self:
    r"""Return a copy of the index with records sorted by hash."""
    if self.is_sorted:
        return self
    sorted_h, sorted_s, sorted_p = _radix_sort_soa(self.hashes, self.seq_indices, self.positions)
    return replace(self, hashes=sorted_h, seq_indices=sorted_s, positions=sorted_p, is_sorted=True)

top_hits

top_hits(
    queries: BaseKmerIndex | Sequences | None = None,
    min_score: int = 1,
    *,
    query_seqs: Sequences | None = None,
    target_seqs: Sequences | None = None,
    matrix: SubstitutionMatrix | None = None,
) -> Seeds

Find the single best-matching target sequence for each query sequence.

Parameters:

Name Type Description Default
queries BaseKmerIndex | Sequences | None

Query sequences or index. If None, queries itself.

None
min_score int

Minimum match score threshold. Defaults to 1.

1
query_seqs Sequences | None

Optional query sequences for ungapped diagonal tie-breaking.

None
target_seqs Sequences | None

Optional target sequences for ungapped diagonal tie-breaking.

None
matrix SubstitutionMatrix | None

Substitution matrix for ungapped diagonal scoring.

None

Returns:

Name Type Description
Seeds Seeds

Matched Seeds collection.

Source code in src/seroverse/core/seq/kmers.py
def top_hits(
    self,
    queries: BaseKmerIndex | Sequences | None = None,
    min_score: int = 1,
    *,
    query_seqs: Sequences | None = None,
    target_seqs: Sequences | None = None,
    matrix: SubstitutionMatrix | None = None,
) -> Seeds:
    r"""Find the single best-matching target sequence for each query sequence.

    Args:
        queries (BaseKmerIndex | Sequences | None): Query sequences or index. If None, queries itself.
        min_score (int): Minimum match score threshold. Defaults to 1.
        query_seqs (Sequences | None): Optional query sequences for ungapped diagonal tie-breaking.
        target_seqs (Sequences | None): Optional target sequences for ungapped diagonal tie-breaking.
        matrix (SubstitutionMatrix | None): Substitution matrix for ungapped diagonal scoring.

    Returns:
        Seeds: Matched [`Seeds`][seroverse.core.align.seeds.Seeds] collection.
    """
    return self._search(queries, min_score, 0, query_seqs, target_seqs, matrix)

hits

hits(
    queries: BaseKmerIndex | Sequences | None = None,
    min_score: int = 1,
    *,
    aggregate: bool = True,
    query_seqs: Sequences | None = None,
    target_seqs: Sequences | None = None,
    matrix: SubstitutionMatrix | None = None,
) -> Seeds

Find all matching target sequences or individual anchors for each query sequence.

When aggregate=True (default), uses single-pass binary search over self.hashes followed by per-query sorted run-length grouping and modal/consensus diagonal voting to return one consensus Seed per qualifying (query, target) pair. When aggregate=False, emits every individual k-mer / strobe anchor hit for qualifying (query, target) pairs sorted by (query_index, target_index, t_pos, q_pos) for direct consumption by Chainer.

Parameters:

Name Type Description Default
queries BaseKmerIndex | Sequences | None

Query sequence index or raw sequences. If None, queries itself.

None
min_score int

Minimum shared k-mer count required for a (query, target) pair. Defaults to 1.

1
aggregate bool

If True, aggregate hits per (query, target) pair into a consensus diagonal seed. If False, return all individual (q_pos, t_pos, span) anchor occurrences sorted for collinear chaining. Defaults to True.

True
query_seqs Sequences | None

Optional query sequences for ungapped diagonal tie-breaking.

None
target_seqs Sequences | None

Optional target sequences for ungapped diagonal tie-breaking.

None
matrix SubstitutionMatrix | None

Substitution matrix for ungapped diagonal scoring.

None

Returns:

Name Type Description
Seeds Seeds

Matched Seeds collection.

Source code in src/seroverse/core/seq/kmers.py
def hits(
    self,
    queries: BaseKmerIndex | Sequences | None = None,
    min_score: int = 1,
    *,
    aggregate: bool = True,
    query_seqs: Sequences | None = None,
    target_seqs: Sequences | None = None,
    matrix: SubstitutionMatrix | None = None,
) -> Seeds:
    r"""Find all matching target sequences or individual anchors for each query sequence.

    When `aggregate=True` (default), uses single-pass binary search over `self.hashes` followed
    by per-query sorted run-length grouping and modal/consensus diagonal voting to return one
    consensus [`Seed`][seroverse.core.align.seeds.Seed] per qualifying `(query, target)` pair.
    When `aggregate=False`, emits every individual k-mer / strobe anchor hit for qualifying
    `(query, target)` pairs sorted by `(query_index, target_index, t_pos, q_pos)` for direct
    consumption by [`Chainer`][seroverse.core.align.chaining.Chainer].

    Args:
        queries (BaseKmerIndex | Sequences | None): Query sequence index or raw sequences. If None, queries itself.
        min_score (int): Minimum shared k-mer count required for a `(query, target)` pair. Defaults to 1.
        aggregate (bool): If True, aggregate hits per `(query, target)` pair into a consensus diagonal seed.
            If False, return all individual `(q_pos, t_pos, span)` anchor occurrences sorted for collinear
            chaining. Defaults to True.
        query_seqs (Sequences | None): Optional query sequences for ungapped diagonal tie-breaking.
        target_seqs (Sequences | None): Optional target sequences for ungapped diagonal tie-breaking.
        matrix (SubstitutionMatrix | None): Substitution matrix for ungapped diagonal scoring.

    Returns:
        Seeds: Matched [`Seeds`][seroverse.core.align.seeds.Seeds] collection.
    """
    return self._search(queries, min_score, 1 if aggregate else 2, query_seqs, target_seqs, matrix)

FracMinHashIndex dataclass

Bases: BaseKmerIndex

Specialized index for fast sequence comparisons using FracMinHash.

Attributes:

Name Type Description
k int

K-mer length parameter. Defaults to 21.

scaled int

FracMinHash scale factor (e.g. 100).

canonical bool

True if canonical k-mers (min of fwd/rev) are hashed.

bits_per_char int

Alphabet bits per character.

Methods:

Name Description
build

Build a FracMinHashIndex from sequence batch.

Source code in src/seroverse/core/seq/kmers.py
@dataclass(frozen=True, slots=True, kw_only=True)
class FracMinHashIndex(BaseKmerIndex):
    r"""Specialized index for fast sequence comparisons using FracMinHash.

    Attributes:
        k (int): K-mer length parameter. Defaults to 21.
        scaled (int): FracMinHash scale factor (e.g. 100).
        canonical (bool): True if canonical k-mers (min of fwd/rev) are hashed.
        bits_per_char (int): Alphabet bits per character.
    """

    k: int = 21
    scaled: int = 100
    canonical: bool = True
    bits_per_char: int = 2

    def _extract_soa(self, batch: Sequences) -> _SketchSoA:
        r"""Extract FracMinHash SoA buffers from `batch`."""
        kernel_lut = self.lut if self.lut is not None else self.alphabet.kmer_lut
        return _extract_fracminhash_kernel(
            batch.seqs,
            batch.offsets,
            batch.lengths,
            kernel_lut,
            self.k,
            self.scaled,
            self.canonical,
            self.bits_per_char,
        )

    @classmethod
    def build(
        cls,
        batch: Sequences,
        k: int | None = None,
        alphabet: Alphabet = Alphabet.DNA,
        scaled: int = 100,
        canonical: bool | None = None,
        seed: int = 42,
        sort_by_hash: bool = False,
        lut: np.ndarray[tuple[int], np.dtype[np.uint8]] | None = None,
        bits_per_char: int | None = None,
        reduce: bool | None = None,
        **kwargs: object,
    ) -> FracMinHashIndex:
        r"""Build a FracMinHashIndex from sequence batch.

        Args:
            batch (Sequences): Input sequence collection.
            k (int | None): K-mer length. Defaults to alphabet default_k.
            alphabet (Alphabet): Alphabet instance. Defaults to `Alphabet.DNA`.
            scaled (int): Sampling scale factor. Defaults to 100.
            canonical (bool | None): True if using canonical k-mers. Defaults to alphabet.supports_canonical.
            seed (int): Seed for hashing. Defaults to 42.
            sort_by_hash (bool): True to sort output records by hash. Defaults to False.
            lut (np.ndarray | None): Optional custom lookup table.
            bits_per_char (int | None): Bits per character. Defaults to alphabet.bits_per_char.
            reduce (bool | None): True to reduce alphabet.
            **kwargs (object): Additional parameters.

        Returns:
            FracMinHashIndex: Built [`FracMinHashIndex`][seroverse.core.seq.kmers.FracMinHashIndex] instance.
        """
        if canonical is True and not alphabet.supports_canonical:
            raise ValueError(f"Canonical k-mers are not supported for alphabet {alphabet}.")

        resolved_canonical = alphabet.supports_canonical if canonical is None else canonical
        resolved_k = alphabet.default_k if k is None else k
        resolved_bits = alphabet.bits_per_char if bits_per_char is None else bits_per_char
        resolved_reduce = alphabet is Alphabet.MMSEQS12 if reduce is None else reduce

        if resolved_k * resolved_bits > 64:
            raise ValueError(
                f"k-mer size ({resolved_k}) with {resolved_bits} bits/char exceeds 64-bit integer capacity."
            )

        template = replace(
            cls.empty(),
            k=resolved_k,
            alphabet=alphabet,
            scaled=scaled,
            canonical=resolved_canonical,
            bits_per_char=resolved_bits,
            reduce=resolved_reduce,
            lut=lut,
        )
        return template._populate(batch, sort_by_hash=sort_by_hash)

build classmethod

build(
    batch: Sequences,
    k: int | None = None,
    alphabet: Alphabet = DNA,
    scaled: int = 100,
    canonical: bool | None = None,
    seed: int = 42,
    sort_by_hash: bool = False,
    lut: ndarray[tuple[int], dtype[uint8]] | None = None,
    bits_per_char: int | None = None,
    reduce: bool | None = None,
    **kwargs: object,
) -> FracMinHashIndex

Build a FracMinHashIndex from sequence batch.

Parameters:

Name Type Description Default
batch Sequences

Input sequence collection.

required
k int | None

K-mer length. Defaults to alphabet default_k.

None
alphabet Alphabet

Alphabet instance. Defaults to Alphabet.DNA.

DNA
scaled int

Sampling scale factor. Defaults to 100.

100
canonical bool | None

True if using canonical k-mers. Defaults to alphabet.supports_canonical.

None
seed int

Seed for hashing. Defaults to 42.

42
sort_by_hash bool

True to sort output records by hash. Defaults to False.

False
lut ndarray | None

Optional custom lookup table.

None
bits_per_char int | None

Bits per character. Defaults to alphabet.bits_per_char.

None
reduce bool | None

True to reduce alphabet.

None
**kwargs object

Additional parameters.

{}

Returns:

Name Type Description
FracMinHashIndex FracMinHashIndex

Built FracMinHashIndex instance.

Source code in src/seroverse/core/seq/kmers.py
@classmethod
def build(
    cls,
    batch: Sequences,
    k: int | None = None,
    alphabet: Alphabet = Alphabet.DNA,
    scaled: int = 100,
    canonical: bool | None = None,
    seed: int = 42,
    sort_by_hash: bool = False,
    lut: np.ndarray[tuple[int], np.dtype[np.uint8]] | None = None,
    bits_per_char: int | None = None,
    reduce: bool | None = None,
    **kwargs: object,
) -> FracMinHashIndex:
    r"""Build a FracMinHashIndex from sequence batch.

    Args:
        batch (Sequences): Input sequence collection.
        k (int | None): K-mer length. Defaults to alphabet default_k.
        alphabet (Alphabet): Alphabet instance. Defaults to `Alphabet.DNA`.
        scaled (int): Sampling scale factor. Defaults to 100.
        canonical (bool | None): True if using canonical k-mers. Defaults to alphabet.supports_canonical.
        seed (int): Seed for hashing. Defaults to 42.
        sort_by_hash (bool): True to sort output records by hash. Defaults to False.
        lut (np.ndarray | None): Optional custom lookup table.
        bits_per_char (int | None): Bits per character. Defaults to alphabet.bits_per_char.
        reduce (bool | None): True to reduce alphabet.
        **kwargs (object): Additional parameters.

    Returns:
        FracMinHashIndex: Built [`FracMinHashIndex`][seroverse.core.seq.kmers.FracMinHashIndex] instance.
    """
    if canonical is True and not alphabet.supports_canonical:
        raise ValueError(f"Canonical k-mers are not supported for alphabet {alphabet}.")

    resolved_canonical = alphabet.supports_canonical if canonical is None else canonical
    resolved_k = alphabet.default_k if k is None else k
    resolved_bits = alphabet.bits_per_char if bits_per_char is None else bits_per_char
    resolved_reduce = alphabet is Alphabet.MMSEQS12 if reduce is None else reduce

    if resolved_k * resolved_bits > 64:
        raise ValueError(
            f"k-mer size ({resolved_k}) with {resolved_bits} bits/char exceeds 64-bit integer capacity."
        )

    template = replace(
        cls.empty(),
        k=resolved_k,
        alphabet=alphabet,
        scaled=scaled,
        canonical=resolved_canonical,
        bits_per_char=resolved_bits,
        reduce=resolved_reduce,
        lut=lut,
    )
    return template._populate(batch, sort_by_hash=sort_by_hash)

RandstrobeIndex dataclass

Bases: BaseKmerIndex

Specialized index for fast sequence comparisons using syncmer-linked randstrobes.

Attributes:

Name Type Description
alphabet Alphabet

Alphabet used for encoding. Defaults to Alphabet.MMSEQS12.

s int

Sub-k-mer size for open syncmer evaluation. Defaults to 5.

w_min int

Minimum syncmer offset window bound. Defaults to 1.

w_max int

Maximum syncmer offset window bound. Defaults to 5.

reduce bool

True if using a reduced alphabet mapping. Defaults to True.

Methods:

Name Description
build

Build a RandstrobeIndex from sequence batch.

Source code in src/seroverse/core/seq/kmers.py
@dataclass(frozen=True, slots=True, kw_only=True)
class RandstrobeIndex(BaseKmerIndex):
    r"""Specialized index for fast sequence comparisons using syncmer-linked randstrobes.

    Attributes:
        alphabet (Alphabet): Alphabet used for encoding. Defaults to `Alphabet.MMSEQS12`.
        s (int): Sub-k-mer size for open syncmer evaluation. Defaults to 5.
        w_min (int): Minimum syncmer offset window bound. Defaults to 1.
        w_max (int): Maximum syncmer offset window bound. Defaults to 5.
        reduce (bool): True if using a reduced alphabet mapping. Defaults to True.
    """

    alphabet: Alphabet = Alphabet.MMSEQS12
    s: int = 5
    w_min: int = 1
    w_max: int = 5
    reduce: bool = True

    def _extract_soa(self, batch: Sequences) -> _SketchSoA:
        r"""Extract Randstrobe SoA buffers from `batch`."""
        kernel_lut = self.lut if self.lut is not None else self.alphabet.kmer_lut
        return _extract_randstrobes_kernel(
            batch.seqs, batch.offsets, batch.lengths, kernel_lut, self.k, self.s, self.w_min, self.w_max
        )

    @classmethod
    def build(
        cls,
        batch: Sequences,
        k: int = 10,
        s: int = 5,
        w_min: int = 1,
        w_max: int = 5,
        alphabet: Alphabet = Alphabet.MMSEQS12,
        seed: int = 42,
        sort_by_hash: bool = False,
        lut: np.ndarray[tuple[int], np.dtype[np.uint8]] | None = None,
        reduce: bool | None = None,
        **kwargs: object,
    ) -> RandstrobeIndex:
        r"""Build a RandstrobeIndex from sequence batch.

        Args:
            batch (Sequences): Input sequence collection.
            k (int): K-mer length. Defaults to 10.
            s (int): Sub-k-mer size. Defaults to 5.
            w_min (int): Min syncmer window bound. Defaults to 1.
            w_max (int): Max syncmer window bound. Defaults to 5.
            alphabet (Alphabet): Alphabet instance. Defaults to `Alphabet.MMSEQS12`.
            seed (int): Random seed. Defaults to 42.
            sort_by_hash (bool): True to sort records by hash. Defaults to False.
            lut (np.ndarray | None): Optional alphabet lookup table.
            reduce (bool | None): True to reduce alphabet.
            **kwargs (object): Additional parameters.

        Returns:
            RandstrobeIndex: Constructed [`RandstrobeIndex`][seroverse.core.seq.kmers.RandstrobeIndex].

        Raises:
            ValueError: If `s >= k`.
        """
        if s >= k:
            raise ValueError("Sub-k-mer size (s) must be strictly less than k-mer size (k).")

        resolved_reduce = alphabet is Alphabet.MMSEQS12 if reduce is None else reduce
        template = replace(
            cls.empty(), k=k, s=s, w_min=w_min, w_max=w_max, alphabet=alphabet, reduce=resolved_reduce, lut=lut
        )
        return template._populate(batch, sort_by_hash=sort_by_hash)

build classmethod

build(
    batch: Sequences,
    k: int = 10,
    s: int = 5,
    w_min: int = 1,
    w_max: int = 5,
    alphabet: Alphabet = MMSEQS12,
    seed: int = 42,
    sort_by_hash: bool = False,
    lut: ndarray[tuple[int], dtype[uint8]] | None = None,
    reduce: bool | None = None,
    **kwargs: object,
) -> RandstrobeIndex

Build a RandstrobeIndex from sequence batch.

Parameters:

Name Type Description Default
batch Sequences

Input sequence collection.

required
k int

K-mer length. Defaults to 10.

10
s int

Sub-k-mer size. Defaults to 5.

5
w_min int

Min syncmer window bound. Defaults to 1.

1
w_max int

Max syncmer window bound. Defaults to 5.

5
alphabet Alphabet

Alphabet instance. Defaults to Alphabet.MMSEQS12.

MMSEQS12
seed int

Random seed. Defaults to 42.

42
sort_by_hash bool

True to sort records by hash. Defaults to False.

False
lut ndarray | None

Optional alphabet lookup table.

None
reduce bool | None

True to reduce alphabet.

None
**kwargs object

Additional parameters.

{}

Returns:

Name Type Description
RandstrobeIndex RandstrobeIndex

Constructed RandstrobeIndex.

Raises:

Type Description
ValueError

If s >= k.

Source code in src/seroverse/core/seq/kmers.py
@classmethod
def build(
    cls,
    batch: Sequences,
    k: int = 10,
    s: int = 5,
    w_min: int = 1,
    w_max: int = 5,
    alphabet: Alphabet = Alphabet.MMSEQS12,
    seed: int = 42,
    sort_by_hash: bool = False,
    lut: np.ndarray[tuple[int], np.dtype[np.uint8]] | None = None,
    reduce: bool | None = None,
    **kwargs: object,
) -> RandstrobeIndex:
    r"""Build a RandstrobeIndex from sequence batch.

    Args:
        batch (Sequences): Input sequence collection.
        k (int): K-mer length. Defaults to 10.
        s (int): Sub-k-mer size. Defaults to 5.
        w_min (int): Min syncmer window bound. Defaults to 1.
        w_max (int): Max syncmer window bound. Defaults to 5.
        alphabet (Alphabet): Alphabet instance. Defaults to `Alphabet.MMSEQS12`.
        seed (int): Random seed. Defaults to 42.
        sort_by_hash (bool): True to sort records by hash. Defaults to False.
        lut (np.ndarray | None): Optional alphabet lookup table.
        reduce (bool | None): True to reduce alphabet.
        **kwargs (object): Additional parameters.

    Returns:
        RandstrobeIndex: Constructed [`RandstrobeIndex`][seroverse.core.seq.kmers.RandstrobeIndex].

    Raises:
        ValueError: If `s >= k`.
    """
    if s >= k:
        raise ValueError("Sub-k-mer size (s) must be strictly less than k-mer size (k).")

    resolved_reduce = alphabet is Alphabet.MMSEQS12 if reduce is None else reduce
    template = replace(
        cls.empty(), k=k, s=s, w_min=w_min, w_max=w_max, alphabet=alphabet, reduce=resolved_reduce, lut=lut
    )
    return template._populate(batch, sort_by_hash=sort_by_hash)

Substitution Matrices, Seeds, Pairwise Alignment & Collinear Chaining (seroverse.core.align)

score

Biological sequence substitution matrices and Numba-accelerated ungapped scoring kernels.

Provides SubstitutionMatrix with singleton caching, read-only \(256 \times 256\) ASCII lookup tables (int8), Karlin-Altschul statistical parameters (\(\lambda, K\)), and parallel Numba kernels for ungapped diagonal scoring and pair prefiltering.

Classes:

Name Description
SubstitutionMatrix

Immutable biological substitution matrix with a precomputed \(256 \times 256\) int8 ASCII lookup table.

SubstitutionMatrix dataclass

Immutable biological substitution matrix with a precomputed \(256 \times 256\) int8 ASCII lookup table.

Maps any pair of ASCII sequence bytes (a, b) (including lowercase bytes, alphabet aliases, stop codons *, and unknown characters) to an int8 substitution score in \(O(1)\) time.

Attributes:

Name Type Description
alphabet Alphabet

Underlying biological Alphabet.

lut ndarray

Read-only (256, 256) int8 substitution score lookup table.

lambda_ float

Karlin-Altschul ungapped/gapped scale parameter \(\lambda\).

k float

Karlin-Altschul parameter \(K\).

ln_k float

Precomputed natural logarithm \(\ln(K)\).

name bytes | None

Canonical byte-string identifier (e.g. b"BLOSUM62").

Methods:

Name Description
from_match_mismatch

Construct a SubstitutionMatrix from uniform match and mismatch scores over alphabet.

bitscore

Compute Karlin-Altschul bitscore(s) \(B = (\lambda S - \ln K) / \ln 2\).

Source code in src/seroverse/core/align/score.py
@dataclass(frozen=True, slots=True, eq=False, init=False)
class SubstitutionMatrix:
    r"""Immutable biological substitution matrix with a precomputed $256 \times 256$ `int8` ASCII lookup table.

    Maps any pair of ASCII sequence bytes `(a, b)` (including lowercase bytes, alphabet aliases,
    stop codons `*`, and unknown characters) to an `int8` substitution score in $O(1)$ time.

    Attributes:
        alphabet (Alphabet): Underlying biological [`Alphabet`][seroverse.core.seq.alphabet.Alphabet].
        lut (np.ndarray): Read-only `(256, 256)` `int8` substitution score lookup table.
        lambda_ (float): Karlin-Altschul ungapped/gapped scale parameter $\lambda$.
        k (float): Karlin-Altschul parameter $K$.
        ln_k (float): Precomputed natural logarithm $\ln(K)$.
        name (bytes | None): Canonical byte-string identifier (e.g. `b"BLOSUM62"`).
    """

    alphabet: Alphabet
    lut: np.ndarray[tuple[int, int], np.dtype[np.int8]] = field(
        default_factory=lambda: np.empty((0, 0), dtype=np.int8), repr=False
    )
    lambda_: float = 0.267
    k: float = 0.041
    ln_k: float = float(np.log(0.041))
    name: bytes | None = None

    _CACHE: ClassVar[dict[Any, Self]] = {}

    BLOSUM62: ClassVar[SubstitutionMatrix]
    BLOSUM80: ClassVar[SubstitutionMatrix]
    BLOSUM45: ClassVar[SubstitutionMatrix]
    DNA: ClassVar[SubstitutionMatrix]

    def __new__(
        cls,
        alphabet: Alphabet,
        scores: np.ndarray[tuple[int, int], np.dtype[np.int8]] | Mapping[tuple[bytes, bytes], int],
        symbols: bytes | None = None,
        *,
        default_score: int = -128,
        lambda_: float = 0.267,
        k: float = 0.041,
        name: bytes | None = None,
    ) -> Self:
        r"""Create or return a cached singleton instance of a `SubstitutionMatrix`."""
        sym_bytes = (symbols if symbols is not None else alphabet.symbols).upper()
        if isinstance(scores, np.ndarray):
            score_arr = np.asarray(scores, dtype=np.int8)
            if score_arr.shape != (len(sym_bytes), len(sym_bytes)):
                raise ValueError(
                    f"Score matrix shape {score_arr.shape} must match symbol count ({len(sym_bytes)}, {len(sym_bytes)})."
                )
            score_bytes = score_arr.tobytes()
        else:
            n_s = len(sym_bytes)
            score_arr = np.full((n_s, n_s), default_score, dtype=np.int8)
            sym_idx = {b: idx for idx, b in enumerate(sym_bytes)}
            for (a_b, b_b), val in scores.items():
                ia = sym_idx[a_b.upper()[0]]
                ib = sym_idx[b_b.upper()[0]]
                score_arr[ia, ib] = val
            score_bytes = score_arr.tobytes()

        cache_key = (
            alphabet,
            sym_bytes,
            score_bytes,
            int(default_score),
            float(lambda_),
            float(k),
        )
        if cache_key in cls._CACHE:
            existing = cls._CACHE[cache_key]
            if name and not existing.name:
                object.__setattr__(existing, "name", name)
            return existing

        instance = object.__new__(cls)
        cls._CACHE[cache_key] = instance
        instance._init_matrix(
            alphabet=alphabet,
            sym_bytes=sym_bytes,
            score_arr=score_arr,
            default_score=int(default_score),
            lambda_=float(lambda_),
            k=float(k),
            name=name,
        )
        return instance

    def _init_matrix(
        self,
        alphabet: Alphabet,
        sym_bytes: bytes,
        score_arr: np.ndarray[tuple[int, int], np.dtype[np.int8]],
        default_score: int,
        lambda_: float,
        k: float,
        name: bytes | None,
    ) -> None:
        r"""Populate and lock the $256 \times 256$ ASCII lookup table."""
        lut = np.full((256, 256), default_score, dtype=np.int8)

        # Populate primary uppercase & lowercase pairs
        for ia, ba in enumerate(sym_bytes):
            ba_low = ba | 0x20
            for ib, bb in enumerate(sym_bytes):
                bb_low = bb | 0x20
                sc = score_arr[ia, ib]
                lut[ba, bb] = sc
                lut[ba_low, bb] = sc
                lut[ba, bb_low] = sc
                lut[ba_low, bb_low] = sc

        # Propagate alphabet aliases for any symbol not already explicitly in sym_bytes
        explicit = set(sym_bytes)
        if alphabet.aliases:
            for src, dst in alphabet.aliases.items():
                src_b = src[0]
                if src_b in explicit:
                    continue
                src_low = src_b | 0x20
                dst_b = dst[0]
                lut[src_b, :] = lut[dst_b, :]
                lut[:, src_b] = lut[:, dst_b]
                lut[src_b, src_b] = lut[dst_b, dst_b]
                lut[src_low, :] = lut[dst_b, :]
                lut[:, src_low] = lut[:, dst_b]
                lut[src_low, src_low] = lut[dst_b, dst_b]

        lut.flags.writeable = False

        object.__setattr__(self, "alphabet", alphabet)
        object.__setattr__(self, "lut", lut)
        object.__setattr__(self, "lambda_", lambda_)
        object.__setattr__(self, "k", k)
        object.__setattr__(self, "ln_k", float(np.log(k)))
        object.__setattr__(self, "name", name)

    @classmethod
    def from_match_mismatch(
        cls,
        alphabet: Alphabet = Alphabet.DNA,
        match: int = 2,
        mismatch: int = -3,
        *,
        unknown_score: int = -1,
        default_score: int = -128,
        lambda_: float = 0.625,
        k: float = 0.41,
        name: bytes | None = None,
    ) -> Self:
        r"""Construct a `SubstitutionMatrix` from uniform match and mismatch scores over `alphabet`.

        Args:
            alphabet (Alphabet): Target biological alphabet. Defaults to `Alphabet.DNA`.
            match (int): Score for identical characters (`a == b`). Defaults to `2`.
            mismatch (int): Score for distinct characters (`a != b`). Defaults to `-3`.
            unknown_score (int): Score for ambiguous/wildcard characters (`N` or `X`). Defaults to `-1`.
            default_score (int): Score for out-of-alphabet characters. Defaults to `-128`.
            lambda_ (float): Karlin-Altschul $\lambda$ parameter. Defaults to `0.625`.
            k (float): Karlin-Altschul $K$ parameter. Defaults to `0.41`.
            name (bytes | None): Optional byte-string name.

        Returns:
            Self: Cached `SubstitutionMatrix` instance.
        """
        wildcard = b"N" if alphabet.supports_canonical else b"X"
        sym_bytes = alphabet.symbols + (wildcard if wildcard[0] not in alphabet.symbols else b"")
        n = len(sym_bytes)
        mat = np.full((n, n), mismatch, dtype=np.int8)
        np.fill_diagonal(mat, match)
        if wildcard[0] in sym_bytes:
            w_idx = sym_bytes.index(wildcard[0])
            mat[w_idx, :] = unknown_score
            mat[:, w_idx] = unknown_score
        return cls(
            alphabet=alphabet,
            scores=mat,
            symbols=sym_bytes,
            default_score=default_score,
            lambda_=lambda_,
            k=k,
            name=name,
        )

    def __repr__(self) -> str:
        r"""Return a clean string representation of the SubstitutionMatrix."""
        if self.name:
            return f"SubstitutionMatrix.{self.name.decode('ascii', errors='replace')}"
        return f"SubstitutionMatrix(alphabet={self.alphabet!r}, lambda_={self.lambda_}, k={self.k})"

    def __eq__(self, other: object) -> bool:
        r"""Check equality based on matrix identity or lookup table values."""
        if self is other:
            return True
        if not isinstance(other, SubstitutionMatrix):
            return False
        return (
            self.alphabet == other.alphabet
            and self.lambda_ == other.lambda_
            and self.k == other.k
            and bool(np.array_equal(self.lut, other.lut))
        )

    def __hash__(self) -> int:
        r"""Return hash of the SubstitutionMatrix."""
        return hash((self.alphabet, self.lambda_, self.k, self.name))

    @overload
    def bitscore(self, scores: float) -> float: ...

    @overload
    def bitscore(
        self, scores: np.ndarray[tuple[int], np.dtype[Any]]
    ) -> np.ndarray[tuple[int], np.dtype[np.float32]]: ...

    def bitscore(
        self, scores: float | np.ndarray[tuple[int], np.dtype[Any]]
    ) -> float | np.ndarray[tuple[int], np.dtype[np.float32]]:
        r"""Compute Karlin-Altschul bitscore(s) $B = (\lambda S - \ln K) / \ln 2$.

        Args:
            scores (float | np.ndarray): Raw alignment score(s).

        Returns:
            float | np.ndarray[tuple[int], np.dtype[np.float32]]: Non-negative bitscore(s).
        """
        if isinstance(scores, np.ndarray):
            raw = (self.lambda_ * scores.astype(np.float64) - self.ln_k) / _LN_2
            return np.where(scores > 0, np.maximum(0.0, raw), 0.0).astype(np.float32)
        if scores <= 0:
            return 0.0
        return max(0.0, (self.lambda_ * float(scores) - self.ln_k) / _LN_2)

from_match_mismatch classmethod

from_match_mismatch(
    alphabet: Alphabet = DNA,
    match: int = 2,
    mismatch: int = -3,
    *,
    unknown_score: int = -1,
    default_score: int = -128,
    lambda_: float = 0.625,
    k: float = 0.41,
    name: bytes | None = None,
) -> Self

Construct a SubstitutionMatrix from uniform match and mismatch scores over alphabet.

Parameters:

Name Type Description Default
alphabet Alphabet

Target biological alphabet. Defaults to Alphabet.DNA.

DNA
match int

Score for identical characters (a == b). Defaults to 2.

2
mismatch int

Score for distinct characters (a != b). Defaults to -3.

-3
unknown_score int

Score for ambiguous/wildcard characters (N or X). Defaults to -1.

-1
default_score int

Score for out-of-alphabet characters. Defaults to -128.

-128
lambda_ float

Karlin-Altschul \(\lambda\) parameter. Defaults to 0.625.

0.625
k float

Karlin-Altschul \(K\) parameter. Defaults to 0.41.

0.41
name bytes | None

Optional byte-string name.

None

Returns:

Name Type Description
Self Self

Cached SubstitutionMatrix instance.

Source code in src/seroverse/core/align/score.py
@classmethod
def from_match_mismatch(
    cls,
    alphabet: Alphabet = Alphabet.DNA,
    match: int = 2,
    mismatch: int = -3,
    *,
    unknown_score: int = -1,
    default_score: int = -128,
    lambda_: float = 0.625,
    k: float = 0.41,
    name: bytes | None = None,
) -> Self:
    r"""Construct a `SubstitutionMatrix` from uniform match and mismatch scores over `alphabet`.

    Args:
        alphabet (Alphabet): Target biological alphabet. Defaults to `Alphabet.DNA`.
        match (int): Score for identical characters (`a == b`). Defaults to `2`.
        mismatch (int): Score for distinct characters (`a != b`). Defaults to `-3`.
        unknown_score (int): Score for ambiguous/wildcard characters (`N` or `X`). Defaults to `-1`.
        default_score (int): Score for out-of-alphabet characters. Defaults to `-128`.
        lambda_ (float): Karlin-Altschul $\lambda$ parameter. Defaults to `0.625`.
        k (float): Karlin-Altschul $K$ parameter. Defaults to `0.41`.
        name (bytes | None): Optional byte-string name.

    Returns:
        Self: Cached `SubstitutionMatrix` instance.
    """
    wildcard = b"N" if alphabet.supports_canonical else b"X"
    sym_bytes = alphabet.symbols + (wildcard if wildcard[0] not in alphabet.symbols else b"")
    n = len(sym_bytes)
    mat = np.full((n, n), mismatch, dtype=np.int8)
    np.fill_diagonal(mat, match)
    if wildcard[0] in sym_bytes:
        w_idx = sym_bytes.index(wildcard[0])
        mat[w_idx, :] = unknown_score
        mat[:, w_idx] = unknown_score
    return cls(
        alphabet=alphabet,
        scores=mat,
        symbols=sym_bytes,
        default_score=default_score,
        lambda_=lambda_,
        k=k,
        name=name,
    )

bitscore

bitscore(scores: float) -> float
bitscore(
    scores: ndarray[tuple[int], dtype[Any]],
) -> ndarray[tuple[int], dtype[float32]]
bitscore(
    scores: float | ndarray[tuple[int], dtype[Any]],
) -> float | ndarray[tuple[int], dtype[float32]]

Compute Karlin-Altschul bitscore(s) \(B = (\lambda S - \ln K) / \ln 2\).

Parameters:

Name Type Description Default
scores float | ndarray

Raw alignment score(s).

required

Returns:

Type Description
float | ndarray[tuple[int], dtype[float32]]

float | np.ndarray[tuple[int], np.dtype[np.float32]]: Non-negative bitscore(s).

Source code in src/seroverse/core/align/score.py
def bitscore(
    self, scores: float | np.ndarray[tuple[int], np.dtype[Any]]
) -> float | np.ndarray[tuple[int], np.dtype[np.float32]]:
    r"""Compute Karlin-Altschul bitscore(s) $B = (\lambda S - \ln K) / \ln 2$.

    Args:
        scores (float | np.ndarray): Raw alignment score(s).

    Returns:
        float | np.ndarray[tuple[int], np.dtype[np.float32]]: Non-negative bitscore(s).
    """
    if isinstance(scores, np.ndarray):
        raw = (self.lambda_ * scores.astype(np.float64) - self.ln_k) / _LN_2
        return np.where(scores > 0, np.maximum(0.0, raw), 0.0).astype(np.float32)
    if scores <= 0:
        return 0.0
    return max(0.0, (self.lambda_ * float(scores) - self.ln_k) / _LN_2)

seeds

Alignment seed and chaining anchor Structure-of-Arrays (SoA) batch containers.

This module provides Seed and Seeds for representing both pair-level consensus diagonal seeds and unaggregated multi-anchor hits, along with Numba-accelerated local ungapped diagonal refinement.

Classes:

Name Description
Seed

Alignment seed / anchor representing a matching region between query and target.

Seeds

Structure-of-Arrays (SoA) batch container for alignment seeds and chaining anchors.

Seed dataclass

Bases: ScalarView

Alignment seed / anchor representing a matching region between query and target.

Attributes:

Name Type Description
query_index int

Index of the query sequence.

target_index int

Index of the target sequence.

score int

Match score (shared k-mer count when aggregated, or anchor weight k when unaggregated).

offset int

Diagonal offset calculated as q_pos - t_pos.

q_pos int

0-based start coordinate on the query sequence. Defaults to 0.

t_pos int

0-based start coordinate on the target sequence. Defaults to 0.

span int

Anchor match span in residues/bases. Defaults to 1.

Source code in src/seroverse/core/align/seeds.py
@dataclass(frozen=True, slots=True)
class Seed(ScalarView):
    r"""Alignment seed / anchor representing a matching region between query and target.

    Attributes:
        query_index (int): Index of the query sequence.
        target_index (int): Index of the target sequence.
        score (int): Match score (shared k-mer count when aggregated, or anchor weight `k` when unaggregated).
        offset (int): Diagonal offset calculated as `q_pos - t_pos`.
        q_pos (int): 0-based start coordinate on the query sequence. Defaults to `0`.
        t_pos (int): 0-based start coordinate on the target sequence. Defaults to `0`.
        span (int): Anchor match span in residues/bases. Defaults to `1`.
    """

    query_index: int
    target_index: int
    score: int
    offset: int
    q_pos: int = 0
    t_pos: int = 0
    span: int = 1

Seeds dataclass

Bases: BatchedContainer[Seed, 'Seeds']

Structure-of-Arrays (SoA) batch container for alignment seeds and chaining anchors.

Unifies aggregated pair-level seeds (1 consensus diagonal per (query, target) pair) and unaggregated multi-anchor hits (all (q_pos, t_pos, span) seed occurrences per pair) in a single container without redundancy.

Attributes:

Name Type Description
query_indices UInt32Array

1D array of query sequence indices.

target_indices UInt32Array

1D array of target sequence indices.

scores UInt32Array

1D array of seed/anchor scores.

offsets Int32Array

1D array of diagonal offsets (q_pos - t_pos).

q_pos Int32Array

1D array of 0-based query start positions.

t_pos Int32Array

1D array of 0-based target start positions.

spans UInt16Array

1D array of anchor match lengths (k).

Methods:

Name Description
top_hits

Reduce the batch to only the highest-scoring seed for each query.

extract_sequences

Extract parallel batches of query and target sequences mapped by this seed batch.

rescore_diagonals

Refine diagonal offsets by scoring local ungapped homology within [offset - window, offset + window].

Source code in src/seroverse/core/align/seeds.py
@dataclass(frozen=True, slots=True)
class Seeds(BatchedContainer[Seed, "Seeds"]):
    r"""Structure-of-Arrays (SoA) batch container for alignment seeds and chaining anchors.

    Unifies aggregated pair-level seeds (1 consensus diagonal per `(query, target)` pair)
    and unaggregated multi-anchor hits (all `(q_pos, t_pos, span)` seed occurrences per pair)
    in a single container without redundancy.

    Attributes:
        query_indices (UInt32Array): 1D array of query sequence indices.
        target_indices (UInt32Array): 1D array of target sequence indices.
        scores (UInt32Array): 1D array of seed/anchor scores.
        offsets (Int32Array): 1D array of diagonal offsets (`q_pos - t_pos`).
        q_pos (Int32Array): 1D array of 0-based query start positions.
        t_pos (Int32Array): 1D array of 0-based target start positions.
        spans (UInt16Array): 1D array of anchor match lengths (`k`).
    """

    query_indices: UInt32Array
    target_indices: UInt32Array
    scores: UInt32Array
    offsets: Int32Array
    q_pos: Int32Array = field(default_factory=lambda: np.empty(0, dtype=np.int32))
    t_pos: Int32Array = field(default_factory=lambda: np.empty(0, dtype=np.int32))
    spans: UInt16Array = field(default_factory=lambda: np.empty(0, dtype=np.uint16))

    def __post_init__(self) -> None:
        r"""Populate default anchor coordinates if constructed with 4-argument legacy signature."""
        n = len(self.query_indices)
        if n > 0 and len(self.q_pos) == 0:
            object.__setattr__(self, "q_pos", np.maximum(0, self.offsets).astype(np.int32))
            object.__setattr__(self, "t_pos", np.maximum(0, -self.offsets).astype(np.int32))
        if n > 0 and len(self.spans) == 0:
            object.__setattr__(self, "spans", np.ones(n, dtype=np.uint16))

    def top_hits(self, min_score: int = 1) -> Seeds:
        r"""Reduce the batch to only the highest-scoring seed for each query.

        Args:
            min_score (int): Minimum match score threshold. Defaults to 1.

        Returns:
            Seeds: Filtered [`Seeds`][seroverse.core.align.seeds.Seeds] collection containing top hit per query.
        """
        if len(self) == 0:
            return self

        order = np.lexsort((~self.scores, self.query_indices))
        _, unique_idx = np.unique(self.query_indices[order], return_index=True)
        best_idx = order[unique_idx]
        best_idx.sort()

        best_batch = self[best_idx]
        if min_score > 0:
            return best_batch.filter(best_batch.scores >= min_score)

        return best_batch

    def extract_sequences(self, queries: Sequences, targets: Sequences) -> tuple[Sequences, Sequences]:
        r"""Extract parallel batches of query and target sequences mapped by this seed batch.

        Args:
            queries (Sequences): Source collection of query sequences.
            targets (Sequences): Source collection of target sequences.

        Returns:
            tuple[Sequences, Sequences]: Paired query and target sequence collections.
        """
        return queries[self.query_indices], targets[self.target_indices]

    def rescore_diagonals(
        self,
        queries: Sequences,
        targets: Sequences,
        matrix: SubstitutionMatrix = SubstitutionMatrix.BLOSUM62,
        window: int = 16,
    ) -> Seeds:
        r"""Refine diagonal `offsets` by scoring local ungapped homology within `[offset - window, offset + window]`.

        Args:
            queries (Sequences): Query sequences collection.
            targets (Sequences): Target sequences collection.
            matrix (SubstitutionMatrix): Substitution matrix for ungapped diagonal scoring.
                Defaults to `SubstitutionMatrix.BLOSUM62`.
            window (int): Half-window search radius around each seed's initial diagonal offset.
                Defaults to 16.

        Returns:
            Seeds: New [`Seeds`][seroverse.core.align.seeds.Seeds] batch with refined `offsets`.
        """
        if len(self) == 0 or window <= 0:
            return self

        refined_offsets = _rescore_seed_diagonals_kernel(
            self.query_indices,
            self.target_indices,
            self.offsets,
            queries.seqs,
            queries.offsets,
            queries.lengths,
            targets.seqs,
            targets.offsets,
            targets.lengths,
            matrix.lut,
            np.int32(window),
        )
        return Seeds(
            self.query_indices,
            self.target_indices,
            self.scores,
            refined_offsets,
            self.q_pos,
            self.t_pos,
            self.spans,
        )

top_hits

top_hits(min_score: int = 1) -> Seeds

Reduce the batch to only the highest-scoring seed for each query.

Parameters:

Name Type Description Default
min_score int

Minimum match score threshold. Defaults to 1.

1

Returns:

Name Type Description
Seeds Seeds

Filtered Seeds collection containing top hit per query.

Source code in src/seroverse/core/align/seeds.py
def top_hits(self, min_score: int = 1) -> Seeds:
    r"""Reduce the batch to only the highest-scoring seed for each query.

    Args:
        min_score (int): Minimum match score threshold. Defaults to 1.

    Returns:
        Seeds: Filtered [`Seeds`][seroverse.core.align.seeds.Seeds] collection containing top hit per query.
    """
    if len(self) == 0:
        return self

    order = np.lexsort((~self.scores, self.query_indices))
    _, unique_idx = np.unique(self.query_indices[order], return_index=True)
    best_idx = order[unique_idx]
    best_idx.sort()

    best_batch = self[best_idx]
    if min_score > 0:
        return best_batch.filter(best_batch.scores >= min_score)

    return best_batch

extract_sequences

extract_sequences(
    queries: Sequences, targets: Sequences
) -> tuple[Sequences, Sequences]

Extract parallel batches of query and target sequences mapped by this seed batch.

Parameters:

Name Type Description Default
queries Sequences

Source collection of query sequences.

required
targets Sequences

Source collection of target sequences.

required

Returns:

Type Description
tuple[Sequences, Sequences]

tuple[Sequences, Sequences]: Paired query and target sequence collections.

Source code in src/seroverse/core/align/seeds.py
def extract_sequences(self, queries: Sequences, targets: Sequences) -> tuple[Sequences, Sequences]:
    r"""Extract parallel batches of query and target sequences mapped by this seed batch.

    Args:
        queries (Sequences): Source collection of query sequences.
        targets (Sequences): Source collection of target sequences.

    Returns:
        tuple[Sequences, Sequences]: Paired query and target sequence collections.
    """
    return queries[self.query_indices], targets[self.target_indices]

rescore_diagonals

rescore_diagonals(
    queries: Sequences,
    targets: Sequences,
    matrix: SubstitutionMatrix = BLOSUM62,
    window: int = 16,
) -> Seeds

Refine diagonal offsets by scoring local ungapped homology within [offset - window, offset + window].

Parameters:

Name Type Description Default
queries Sequences

Query sequences collection.

required
targets Sequences

Target sequences collection.

required
matrix SubstitutionMatrix

Substitution matrix for ungapped diagonal scoring. Defaults to SubstitutionMatrix.BLOSUM62.

BLOSUM62
window int

Half-window search radius around each seed's initial diagonal offset. Defaults to 16.

16

Returns:

Name Type Description
Seeds Seeds

New Seeds batch with refined offsets.

Source code in src/seroverse/core/align/seeds.py
def rescore_diagonals(
    self,
    queries: Sequences,
    targets: Sequences,
    matrix: SubstitutionMatrix = SubstitutionMatrix.BLOSUM62,
    window: int = 16,
) -> Seeds:
    r"""Refine diagonal `offsets` by scoring local ungapped homology within `[offset - window, offset + window]`.

    Args:
        queries (Sequences): Query sequences collection.
        targets (Sequences): Target sequences collection.
        matrix (SubstitutionMatrix): Substitution matrix for ungapped diagonal scoring.
            Defaults to `SubstitutionMatrix.BLOSUM62`.
        window (int): Half-window search radius around each seed's initial diagonal offset.
            Defaults to 16.

    Returns:
        Seeds: New [`Seeds`][seroverse.core.align.seeds.Seeds] batch with refined `offsets`.
    """
    if len(self) == 0 or window <= 0:
        return self

    refined_offsets = _rescore_seed_diagonals_kernel(
        self.query_indices,
        self.target_indices,
        self.offsets,
        queries.seqs,
        queries.offsets,
        queries.lengths,
        targets.seqs,
        targets.offsets,
        targets.lengths,
        matrix.lut,
        np.int32(window),
    )
    return Seeds(
        self.query_indices,
        self.target_indices,
        self.scores,
        refined_offsets,
        self.q_pos,
        self.t_pos,
        self.spans,
    )

pairwise

Pairwise sequence alignment algorithms, prefiltering engines, and SoA containers.

This module provides high-performance pairwise sequence alignment capabilities supporting three alignment engines: - "block" (default): Adaptive block-shifting Smith-Waterman-Gotoh (block-aligner style) with dynamic per-row window steering and X-drop termination. - "wfa": Affine Wavefront Alignment (WFA2, Marco-Sola et al.) with greedy exact-match extension and adaptive wavefront pruning. - "banded": Classic static-band Smith-Waterman-Gotoh with rolling-row bit-packed traceback.

It also provides MMseqs2-style reduced-alphabet 4-mer CSR + ungapped diagonal prefiltering and piecewise anchor-guided chain alignment.

Key Classes

Classes:

Name Description
PairwiseAlignment

A lightweight, immutable container for the results of a single pairwise sequence alignment.

PairwiseAlignments

A high-performance SoA container for the results of multiple pairwise alignments.

PairwiseAligner

A high-performance, batched pairwise sequence aligner and MMseqs2-style prefilter engine.

PairwiseAlignment dataclass

Bases: ScalarView

A lightweight, immutable container for the results of a single pairwise sequence alignment.

This class holds summary statistics and coordinates for an alignment between a single query and target sequence. It is typically produced by indexing into a PairwiseAlignments collection.

Attributes:

Name Type Description
score int

Final alignment score calculated using the substitution matrix and gap penalties.

matches int

Total number of matching residues/bases.

mismatches int

Total number of mismatched residues/bases.

gaps int

Total number of gap characters (insertions or deletions).

q_start int

0-based start coordinate on query sequence (inclusive).

q_end int

0-based end coordinate on query sequence (exclusive).

t_start int

0-based start coordinate on target sequence (inclusive).

t_end int

0-based end coordinate on target sequence (exclusive).

Methods:

Name Description
bitscore

Calculate the Karlin-Altschul bitscore of the alignment using matrix.

Source code in src/seroverse/core/align/pairwise.py
@dataclass(frozen=True, slots=True)
class PairwiseAlignment(ScalarView):
    r"""A lightweight, immutable container for the results of a single pairwise sequence alignment.

    This class holds summary statistics and coordinates for an alignment between a single query
    and target sequence. It is typically produced by indexing into a
    [`PairwiseAlignments`][seroverse.core.align.pairwise.PairwiseAlignments] collection.

    Attributes:
        score (int): Final alignment score calculated using the substitution matrix and gap penalties.
        matches (int): Total number of matching residues/bases.
        mismatches (int): Total number of mismatched residues/bases.
        gaps (int): Total number of gap characters (insertions or deletions).
        q_start (int): 0-based start coordinate on query sequence (inclusive).
        q_end (int): 0-based end coordinate on query sequence (exclusive).
        t_start (int): 0-based start coordinate on target sequence (inclusive).
        t_end (int): 0-based end coordinate on target sequence (exclusive).
    """

    score: int
    matches: int
    mismatches: int
    gaps: int
    q_start: int
    q_end: int
    t_start: int
    t_end: int

    @property
    def pident(self) -> float:
        r"""Calculate the percent identity of the aligned region.

        Percent identity is defined as `(matches / (matches + mismatches + gaps)) * 100.0`.

        Returns:
            float: Percent identity value between 0.0 and 100.0, or 0.0 if alignment length is zero.
        """
        total = self.matches + self.mismatches + self.gaps
        return (self.matches / total) * 100.0 if total > 0 else 0.0

    def bitscore(self, matrix: SubstitutionMatrix = SubstitutionMatrix.BLOSUM62) -> float:
        r"""Calculate the Karlin-Altschul bitscore of the alignment using `matrix`.

        Uses the substitution matrix parameters ($\lambda, K$):
        $B = (\lambda S - \ln K) / \ln 2$ for $S > 0$, else $0.0$.
        """
        return matrix.bitscore(self.score)

pident property

pident: float

Calculate the percent identity of the aligned region.

Percent identity is defined as (matches / (matches + mismatches + gaps)) * 100.0.

Returns:

Name Type Description
float float

Percent identity value between 0.0 and 100.0, or 0.0 if alignment length is zero.

bitscore

bitscore(matrix: SubstitutionMatrix = BLOSUM62) -> float

Calculate the Karlin-Altschul bitscore of the alignment using matrix.

Uses the substitution matrix parameters (\(\lambda, K\)): \(B = (\lambda S - \ln K) / \ln 2\) for \(S > 0\), else \(0.0\).

Source code in src/seroverse/core/align/pairwise.py
def bitscore(self, matrix: SubstitutionMatrix = SubstitutionMatrix.BLOSUM62) -> float:
    r"""Calculate the Karlin-Altschul bitscore of the alignment using `matrix`.

    Uses the substitution matrix parameters ($\lambda, K$):
    $B = (\lambda S - \ln K) / \ln 2$ for $S > 0$, else $0.0$.
    """
    return matrix.bitscore(self.score)

PairwiseAlignments dataclass

Bases: BatchedContainer[PairwiseAlignment, 'PairwiseAlignments']

A high-performance SoA container for the results of multiple pairwise alignments.

This class stores alignment statistics in a Structure-of-Arrays (SoA) layout using 1D NumPy arrays.

Attributes:

Name Type Description
scores Int32Array

1D array of alignment scores.

matches Int32Array

1D array of match counts.

mismatches Int32Array

1D array of mismatch counts.

gaps Int32Array

1D array of gap counts.

q_starts Int32Array

1D array of query start coordinates.

q_ends Int32Array

1D array of query end coordinates.

t_starts Int32Array

1D array of target start coordinates.

t_ends Int32Array

1D array of target end coordinates.

Methods:

Name Description
bitscores

Calculate Karlin-Altschul bitscores for all alignments in the batch using matrix.

Source code in src/seroverse/core/align/pairwise.py
@dataclass(frozen=True, slots=True)
class PairwiseAlignments(BatchedContainer[PairwiseAlignment, "PairwiseAlignments"]):
    r"""A high-performance SoA container for the results of multiple pairwise alignments.

    This class stores alignment statistics in a Structure-of-Arrays (SoA) layout using 1D NumPy arrays.

    Attributes:
        scores (Int32Array): 1D array of alignment scores.
        matches (Int32Array): 1D array of match counts.
        mismatches (Int32Array): 1D array of mismatch counts.
        gaps (Int32Array): 1D array of gap counts.
        q_starts (Int32Array): 1D array of query start coordinates.
        q_ends (Int32Array): 1D array of query end coordinates.
        t_starts (Int32Array): 1D array of target start coordinates.
        t_ends (Int32Array): 1D array of target end coordinates.
    """

    scores: Int32Array
    matches: Int32Array
    mismatches: Int32Array
    gaps: Int32Array
    q_starts: Int32Array
    q_ends: Int32Array
    t_starts: Int32Array
    t_ends: Int32Array

    @property
    def pidents(self) -> Float32Array:
        r"""Calculate percent identity for all alignments in the batch in a vectorized manner.

        Returns:
            Float32Array: 1D array of percent identity values.
        """
        total = self.matches + self.mismatches + self.gaps
        return np.divide(self.matches * 100.0, total, out=np.zeros(len(self), dtype=np.float32), where=total > 0)

    def bitscores(self, matrix: SubstitutionMatrix = SubstitutionMatrix.BLOSUM62) -> Float32Array:
        r"""Calculate Karlin-Altschul bitscores for all alignments in the batch using `matrix`.

        Args:
            matrix (SubstitutionMatrix): Substitution matrix used for $(\lambda, K)$ parameters.
                Defaults to `SubstitutionMatrix.BLOSUM62`.

        Returns:
            Float32Array: 1D array of bitscores ($\ge 0.0$).
        """
        return matrix.bitscore(self.scores)

pidents property

pidents: Float32Array

Calculate percent identity for all alignments in the batch in a vectorized manner.

Returns:

Name Type Description
Float32Array Float32Array

1D array of percent identity values.

bitscores

bitscores(
    matrix: SubstitutionMatrix = BLOSUM62,
) -> Float32Array

Calculate Karlin-Altschul bitscores for all alignments in the batch using matrix.

Parameters:

Name Type Description Default
matrix SubstitutionMatrix

Substitution matrix used for \((\lambda, K)\) parameters. Defaults to SubstitutionMatrix.BLOSUM62.

BLOSUM62

Returns:

Name Type Description
Float32Array Float32Array

1D array of bitscores (\(\ge 0.0\)).

Source code in src/seroverse/core/align/pairwise.py
def bitscores(self, matrix: SubstitutionMatrix = SubstitutionMatrix.BLOSUM62) -> Float32Array:
    r"""Calculate Karlin-Altschul bitscores for all alignments in the batch using `matrix`.

    Args:
        matrix (SubstitutionMatrix): Substitution matrix used for $(\lambda, K)$ parameters.
            Defaults to `SubstitutionMatrix.BLOSUM62`.

    Returns:
        Float32Array: 1D array of bitscores ($\ge 0.0$).
    """
    return matrix.bitscore(self.scores)

PairwiseAligner dataclass

A high-performance, batched pairwise sequence aligner and MMseqs2-style prefilter engine.

Supports three execution modes: - "block" (default): Adaptive block-shifting Smith-Waterman-Gotoh (block-aligner style) that dynamically shifts the active diagonal block per row to track arbitrary indels without falling out of a rigid static band, with x_drop early termination. - "wfa": Affine Wavefront Alignment (WFA2, Marco-Sola et al.) with greedy exact-match slides and adaptive wavefront pruning in \(O(N + s^2)\) time. - "banded": Classic static-band Smith-Waterman-Gotoh within a fixed 2*k + 1 diagonal band.

Attributes:

Name Type Description
matrix SubstitutionMatrix

Substitution matrix for scoring. Defaults to SubstitutionMatrix.BLOSUM62.

gap_open int

Penalty for opening a new gap. Defaults to 11.

gap_extend int

Penalty for extending an existing gap. Defaults to 1.

k int

Bandwidth parameter (2*k+1 diagonals). Defaults to 20.

mode Literal['block', 'banded', 'wfa']

Alignment algorithm mode. Defaults to "block".

block_size int

Half-block width parameter for "block" and "wfa" pruning. Defaults to 32.

x_drop int

X-drop score threshold for early termination and block shifting. Defaults to 50.

Methods:

Name Description
align_seeds

Convenience method to extract and align specific sequence pairs mapped by seeds.

align_chains

Perform piecewise anchor-guided base alignment for a batch of collinear Chains.

prefilter

Run MMseqs2-style 4-mer CSR + ungapped diagonal prefiltering to discover candidate homologous pairs.

search

Execute a two-stage MMseqs2-style prefilter followed by Smith-Waterman-Gotoh / WFA alignment.

Source code in src/seroverse/core/align/pairwise.py
@dataclass(frozen=True, slots=True)
class PairwiseAligner:
    r"""A high-performance, batched pairwise sequence aligner and MMseqs2-style prefilter engine.

    Supports three execution modes:
    - `"block"` (default): Adaptive block-shifting Smith-Waterman-Gotoh (`block-aligner` style)
      that dynamically shifts the active diagonal block per row to track arbitrary indels without
      falling out of a rigid static band, with `x_drop` early termination.
    - `"wfa"`: Affine Wavefront Alignment (WFA2, Marco-Sola et al.) with greedy exact-match slides
      and adaptive wavefront pruning in $O(N + s^2)$ time.
    - `"banded"`: Classic static-band Smith-Waterman-Gotoh within a fixed `2*k + 1` diagonal band.

    Attributes:
        matrix (SubstitutionMatrix): Substitution matrix for scoring. Defaults to `SubstitutionMatrix.BLOSUM62`.
        gap_open (int): Penalty for opening a new gap. Defaults to 11.
        gap_extend (int): Penalty for extending an existing gap. Defaults to 1.
        k (int): Bandwidth parameter (`2*k+1` diagonals). Defaults to 20.
        mode (Literal["block", "banded", "wfa"]): Alignment algorithm mode. Defaults to `"block"`.
        block_size (int): Half-block width parameter for `"block"` and `"wfa"` pruning. Defaults to 32.
        x_drop (int): X-drop score threshold for early termination and block shifting. Defaults to 50.
    """

    matrix: SubstitutionMatrix = SubstitutionMatrix.BLOSUM62
    gap_open: int = 11
    gap_extend: int = 1
    k: int = 20
    mode: Literal["block", "banded", "wfa"] = "block"
    block_size: int = 32
    x_drop: int = 50

    def __call__(self, queries: Sequences, targets: Sequences, seeds: Seeds | None = None) -> PairwiseAlignments:
        r"""Perform pairwise alignment of query and target sequences.

        Args:
            queries (Sequences): Collection of query sequences.
            targets (Sequences): Collection of target sequences (must match query batch size).
            seeds (Seeds | None): Optional alignment seeds guiding diagonal alignment.

        Returns:
            PairwiseAlignments: Alignment scores, statistics, and coordinates for each sequence pair.

        Raises:
            ValueError: If query and target batches have different numbers of sequences or `mode` is invalid.
        """
        if len(queries.offsets) != len(targets.offsets):
            raise ValueError("Query and target batches must have the same number of sequences.")

        n = len(queries.offsets)
        if n == 0:
            return PairwiseAlignments.empty()

        if seeds is not None:
            is_seeded = True
            offsets_arr = seeds.offsets
        else:
            is_seeded = False
            offsets_arr = np.zeros(n, dtype=np.int32)

        out_scores = np.empty(n, dtype=np.int32)
        out_matches = np.empty(n, dtype=np.int32)
        out_mismatches = np.empty(n, dtype=np.int32)
        out_gaps = np.empty(n, dtype=np.int32)
        out_q_starts = np.empty(n, dtype=np.int32)
        out_q_ends = np.empty(n, dtype=np.int32)
        out_t_starts = np.empty(n, dtype=np.int32)
        out_t_ends = np.empty(n, dtype=np.int32)

        if self.mode == "banded":
            _batched_banded_gotoh(
                queries.seqs,
                queries.offsets,
                queries.lengths,
                targets.seqs,
                targets.offsets,
                targets.lengths,
                self.matrix.lut,
                self.k,
                self.gap_open,
                self.gap_extend,
                is_seeded,
                offsets_arr,
                out_scores,
                out_matches,
                out_mismatches,
                out_gaps,
                out_q_starts,
                out_q_ends,
                out_t_starts,
                out_t_ends,
            )
        elif self.mode == "block":
            _batched_block_gotoh(
                queries.seqs,
                queries.offsets,
                queries.lengths,
                targets.seqs,
                targets.offsets,
                targets.lengths,
                self.matrix.lut,
                self.k,
                self.block_size,
                self.gap_open,
                self.gap_extend,
                self.x_drop,
                is_seeded,
                offsets_arr,
                out_scores,
                out_matches,
                out_mismatches,
                out_gaps,
                out_q_starts,
                out_q_ends,
                out_t_starts,
                out_t_ends,
            )
        elif self.mode == "wfa":
            _batched_wfa(
                queries.seqs,
                queries.offsets,
                queries.lengths,
                targets.seqs,
                targets.offsets,
                targets.lengths,
                self.matrix.lut,
                self.k,
                self.block_size,
                self.gap_open,
                self.gap_extend,
                self.x_drop,
                is_seeded,
                offsets_arr,
                out_scores,
                out_matches,
                out_mismatches,
                out_gaps,
                out_q_starts,
                out_q_ends,
                out_t_starts,
                out_t_ends,
            )
        else:
            raise ValueError(f"Unsupported PairwiseAligner mode: {self.mode!r}. Expected 'block', 'banded', or 'wfa'.")

        return PairwiseAlignments(
            out_scores,
            out_matches,
            out_mismatches,
            out_gaps,
            out_q_starts,
            out_q_ends,
            out_t_starts,
            out_t_ends,
        )

    def align_seeds(self, queries: Sequences, targets: Sequences, seeds: Seeds) -> PairwiseAlignments:
        r"""Convenience method to extract and align specific sequence pairs mapped by seeds.

        Args:
            queries (Sequences): Full collection of query sequences.
            targets (Sequences): Full collection of target sequences.
            seeds (Seeds): Seed collection mapping specific query sequences to target sequences.

        Returns:
            PairwiseAlignments: Alignment results parallel to the provided seeds.
        """
        if len(seeds) == 0:
            return PairwiseAlignments.empty()
        paired_queries, paired_targets = seeds.extract_sequences(queries, targets)
        return self(paired_queries, paired_targets, seeds)

    def align_chains(self, queries: Sequences, targets: Sequences, chains: Chains) -> PairwiseAlignments:
        r"""Perform piecewise anchor-guided base alignment for a batch of collinear [`Chains`][seroverse.core.align.chaining.Chains].

        Aligns inter-anchor gaps using pinned Affine Wavefront Alignment (WFA2) and extends
        left and right chain ends using adaptive block X-drop Smith-Waterman-Gotoh.

        Args:
            queries (Sequences): Full collection of query sequences.
            targets (Sequences): Full collection of target sequences.
            chains (Chains): Collinear anchor chains mapping query sequences to target sequences.

        Returns:
            PairwiseAlignments: Base-level alignment results parallel to `chains`.
        """
        n_chains = len(chains)
        if n_chains == 0:
            return PairwiseAlignments.empty()

        out_scores = np.empty(n_chains, dtype=np.int32)
        out_matches = np.empty(n_chains, dtype=np.int32)
        out_mismatches = np.empty(n_chains, dtype=np.int32)
        out_gaps = np.empty(n_chains, dtype=np.int32)
        out_q_starts = np.empty(n_chains, dtype=np.int32)
        out_q_ends = np.empty(n_chains, dtype=np.int32)
        out_t_starts = np.empty(n_chains, dtype=np.int32)
        out_t_ends = np.empty(n_chains, dtype=np.int32)

        _align_chains_kernel(
            queries.seqs,
            queries.offsets,
            queries.lengths,
            targets.seqs,
            targets.offsets,
            targets.lengths,
            chains.query_indices,
            chains.target_indices,
            chains.offsets,
            chains.lengths,
            chains.anchor_q_pos,
            chains.anchor_t_pos,
            chains.anchor_spans,
            self.matrix.lut,
            max(self.k, self.block_size),
            self.gap_open,
            self.gap_extend,
            self.x_drop,
            out_scores,
            out_matches,
            out_mismatches,
            out_gaps,
            out_q_starts,
            out_q_ends,
            out_t_starts,
            out_t_ends,
        )

        return PairwiseAlignments(
            out_scores,
            out_matches,
            out_mismatches,
            out_gaps,
            out_q_starts,
            out_q_ends,
            out_t_starts,
            out_t_ends,
        )

    def prefilter(
        self,
        queries: Sequences,
        targets: Sequences | None = None,
        *,
        min_ungapped_score: int = 42,
        min_coverage: float = 0.0,
        upper_triangle: bool | None = None,
    ) -> Seeds:
        r"""Run MMseqs2-style 4-mer CSR + ungapped diagonal prefiltering to discover candidate homologous pairs.

        Builds a direct $12^4 = 20,736$-bucket CSR inverted index of 4-mers (`Alphabet.MMSEQS12` for
        proteins or `Alphabet.DNA` for nucleotides) on `targets` (or `queries` for self-search) and
        evaluates ungapped diagonal scores using `self.matrix` on shared 4-mer diagonals.

        Args:
            queries (Sequences): Query sequences.
            targets (Sequences | None): Target sequences. If `None`, performs self-search on `queries`.
            min_ungapped_score (int): Minimum local ungapped diagonal score to retain a pair. Defaults to `42`.
            min_coverage (float): Minimum length ratio $\min(L_q, L_t) / \max(L_q, L_t)$. Defaults to `0.0`.
            upper_triangle (bool | None): If `True`, restricts output to pairs with `q_idx < t_idx`.
                Defaults to `True` when `targets is None` and `False` otherwise.

        Returns:
            Seeds: Candidate [`Seeds`][seroverse.core.align.seeds.Seeds] containing `(query_indices, target_indices, scores, offsets, q_pos, t_pos, spans)`.
        """
        is_self = targets is None or targets is queries
        t_seqs = queries if targets is None else targets
        if len(queries) == 0 or len(t_seqs) == 0:
            return Seeds.empty()

        use_upper = is_self if upper_triangle is None else bool(upper_triangle)
        kmer_lut = Alphabet.DNA.kmer_lut if self.matrix.alphabet.supports_canonical else Alphabet.MMSEQS12.kmer_lut

        out_q, out_t, out_scores, out_diags, out_qp, out_tp = _mmseqs12_k4_ungapped_prefilter(
            queries.seqs,
            queries.offsets,
            queries.lengths,
            t_seqs.seqs,
            t_seqs.offsets,
            t_seqs.lengths,
            float(min_coverage),
            int(min_ungapped_score),
            kmer_lut,
            self.matrix.lut,
            use_upper,
        )
        out_sp = np.full(len(out_q), 4, dtype=np.uint16)
        return Seeds(out_q, out_t, out_scores, out_diags, out_qp, out_tp, out_sp)

    def search(
        self,
        queries: Sequences,
        targets: Sequences | None = None,
        *,
        min_ungapped_score: int = 42,
        min_coverage: float = 0.0,
        upper_triangle: bool | None = None,
        banded: bool = True,
    ) -> tuple[Seeds, PairwiseAlignments]:
        r"""Execute a two-stage MMseqs2-style prefilter followed by Smith-Waterman-Gotoh / WFA alignment.

        Args:
            queries (Sequences): Query sequences.
            targets (Sequences | None): Target sequences. If `None`, performs self-search on `queries`.
            min_ungapped_score (int): Minimum ungapped diagonal score threshold for Stage 1. Defaults to `42`.
            min_coverage (float): Minimum sequence length ratio filter. Defaults to `0.0`.
            upper_triangle (bool | None): Restrict to `q_idx < t_idx` (defaults to `True` when `targets is None`).
            banded (bool): If `True` (default), seeds the aligner with the Stage 1 diagonal offsets.
                If `False`, runs unseeded alignment on the prefiltered pairs.

        Returns:
            tuple[Seeds, PairwiseAlignments]: Parallel `(seeds, alignments)` for all candidate pairs passing Stage 1.
        """
        t_seqs = queries if targets is None else targets
        seeds = self.prefilter(
            queries,
            targets,
            min_ungapped_score=min_ungapped_score,
            min_coverage=min_coverage,
            upper_triangle=upper_triangle,
        )
        if len(seeds) == 0:
            return seeds, PairwiseAlignments.empty()
        if banded:
            alignments = self.align_seeds(queries, t_seqs, seeds)
        else:
            paired_q, paired_t = seeds.extract_sequences(queries, t_seqs)
            alignments = self(paired_q, paired_t, seeds=None)
        return seeds, alignments

align_seeds

align_seeds(
    queries: Sequences, targets: Sequences, seeds: Seeds
) -> PairwiseAlignments

Convenience method to extract and align specific sequence pairs mapped by seeds.

Parameters:

Name Type Description Default
queries Sequences

Full collection of query sequences.

required
targets Sequences

Full collection of target sequences.

required
seeds Seeds

Seed collection mapping specific query sequences to target sequences.

required

Returns:

Name Type Description
PairwiseAlignments PairwiseAlignments

Alignment results parallel to the provided seeds.

Source code in src/seroverse/core/align/pairwise.py
def align_seeds(self, queries: Sequences, targets: Sequences, seeds: Seeds) -> PairwiseAlignments:
    r"""Convenience method to extract and align specific sequence pairs mapped by seeds.

    Args:
        queries (Sequences): Full collection of query sequences.
        targets (Sequences): Full collection of target sequences.
        seeds (Seeds): Seed collection mapping specific query sequences to target sequences.

    Returns:
        PairwiseAlignments: Alignment results parallel to the provided seeds.
    """
    if len(seeds) == 0:
        return PairwiseAlignments.empty()
    paired_queries, paired_targets = seeds.extract_sequences(queries, targets)
    return self(paired_queries, paired_targets, seeds)

align_chains

align_chains(
    queries: Sequences, targets: Sequences, chains: Chains
) -> PairwiseAlignments

Perform piecewise anchor-guided base alignment for a batch of collinear Chains.

Aligns inter-anchor gaps using pinned Affine Wavefront Alignment (WFA2) and extends left and right chain ends using adaptive block X-drop Smith-Waterman-Gotoh.

Parameters:

Name Type Description Default
queries Sequences

Full collection of query sequences.

required
targets Sequences

Full collection of target sequences.

required
chains Chains

Collinear anchor chains mapping query sequences to target sequences.

required

Returns:

Name Type Description
PairwiseAlignments PairwiseAlignments

Base-level alignment results parallel to chains.

Source code in src/seroverse/core/align/pairwise.py
def align_chains(self, queries: Sequences, targets: Sequences, chains: Chains) -> PairwiseAlignments:
    r"""Perform piecewise anchor-guided base alignment for a batch of collinear [`Chains`][seroverse.core.align.chaining.Chains].

    Aligns inter-anchor gaps using pinned Affine Wavefront Alignment (WFA2) and extends
    left and right chain ends using adaptive block X-drop Smith-Waterman-Gotoh.

    Args:
        queries (Sequences): Full collection of query sequences.
        targets (Sequences): Full collection of target sequences.
        chains (Chains): Collinear anchor chains mapping query sequences to target sequences.

    Returns:
        PairwiseAlignments: Base-level alignment results parallel to `chains`.
    """
    n_chains = len(chains)
    if n_chains == 0:
        return PairwiseAlignments.empty()

    out_scores = np.empty(n_chains, dtype=np.int32)
    out_matches = np.empty(n_chains, dtype=np.int32)
    out_mismatches = np.empty(n_chains, dtype=np.int32)
    out_gaps = np.empty(n_chains, dtype=np.int32)
    out_q_starts = np.empty(n_chains, dtype=np.int32)
    out_q_ends = np.empty(n_chains, dtype=np.int32)
    out_t_starts = np.empty(n_chains, dtype=np.int32)
    out_t_ends = np.empty(n_chains, dtype=np.int32)

    _align_chains_kernel(
        queries.seqs,
        queries.offsets,
        queries.lengths,
        targets.seqs,
        targets.offsets,
        targets.lengths,
        chains.query_indices,
        chains.target_indices,
        chains.offsets,
        chains.lengths,
        chains.anchor_q_pos,
        chains.anchor_t_pos,
        chains.anchor_spans,
        self.matrix.lut,
        max(self.k, self.block_size),
        self.gap_open,
        self.gap_extend,
        self.x_drop,
        out_scores,
        out_matches,
        out_mismatches,
        out_gaps,
        out_q_starts,
        out_q_ends,
        out_t_starts,
        out_t_ends,
    )

    return PairwiseAlignments(
        out_scores,
        out_matches,
        out_mismatches,
        out_gaps,
        out_q_starts,
        out_q_ends,
        out_t_starts,
        out_t_ends,
    )

prefilter

prefilter(
    queries: Sequences,
    targets: Sequences | None = None,
    *,
    min_ungapped_score: int = 42,
    min_coverage: float = 0.0,
    upper_triangle: bool | None = None,
) -> Seeds

Run MMseqs2-style 4-mer CSR + ungapped diagonal prefiltering to discover candidate homologous pairs.

Builds a direct \(12^4 = 20,736\)-bucket CSR inverted index of 4-mers (Alphabet.MMSEQS12 for proteins or Alphabet.DNA for nucleotides) on targets (or queries for self-search) and evaluates ungapped diagonal scores using self.matrix on shared 4-mer diagonals.

Parameters:

Name Type Description Default
queries Sequences

Query sequences.

required
targets Sequences | None

Target sequences. If None, performs self-search on queries.

None
min_ungapped_score int

Minimum local ungapped diagonal score to retain a pair. Defaults to 42.

42
min_coverage float

Minimum length ratio \(\min(L_q, L_t) / \max(L_q, L_t)\). Defaults to 0.0.

0.0
upper_triangle bool | None

If True, restricts output to pairs with q_idx < t_idx. Defaults to True when targets is None and False otherwise.

None

Returns:

Name Type Description
Seeds Seeds

Candidate Seeds containing (query_indices, target_indices, scores, offsets, q_pos, t_pos, spans).

Source code in src/seroverse/core/align/pairwise.py
def prefilter(
    self,
    queries: Sequences,
    targets: Sequences | None = None,
    *,
    min_ungapped_score: int = 42,
    min_coverage: float = 0.0,
    upper_triangle: bool | None = None,
) -> Seeds:
    r"""Run MMseqs2-style 4-mer CSR + ungapped diagonal prefiltering to discover candidate homologous pairs.

    Builds a direct $12^4 = 20,736$-bucket CSR inverted index of 4-mers (`Alphabet.MMSEQS12` for
    proteins or `Alphabet.DNA` for nucleotides) on `targets` (or `queries` for self-search) and
    evaluates ungapped diagonal scores using `self.matrix` on shared 4-mer diagonals.

    Args:
        queries (Sequences): Query sequences.
        targets (Sequences | None): Target sequences. If `None`, performs self-search on `queries`.
        min_ungapped_score (int): Minimum local ungapped diagonal score to retain a pair. Defaults to `42`.
        min_coverage (float): Minimum length ratio $\min(L_q, L_t) / \max(L_q, L_t)$. Defaults to `0.0`.
        upper_triangle (bool | None): If `True`, restricts output to pairs with `q_idx < t_idx`.
            Defaults to `True` when `targets is None` and `False` otherwise.

    Returns:
        Seeds: Candidate [`Seeds`][seroverse.core.align.seeds.Seeds] containing `(query_indices, target_indices, scores, offsets, q_pos, t_pos, spans)`.
    """
    is_self = targets is None or targets is queries
    t_seqs = queries if targets is None else targets
    if len(queries) == 0 or len(t_seqs) == 0:
        return Seeds.empty()

    use_upper = is_self if upper_triangle is None else bool(upper_triangle)
    kmer_lut = Alphabet.DNA.kmer_lut if self.matrix.alphabet.supports_canonical else Alphabet.MMSEQS12.kmer_lut

    out_q, out_t, out_scores, out_diags, out_qp, out_tp = _mmseqs12_k4_ungapped_prefilter(
        queries.seqs,
        queries.offsets,
        queries.lengths,
        t_seqs.seqs,
        t_seqs.offsets,
        t_seqs.lengths,
        float(min_coverage),
        int(min_ungapped_score),
        kmer_lut,
        self.matrix.lut,
        use_upper,
    )
    out_sp = np.full(len(out_q), 4, dtype=np.uint16)
    return Seeds(out_q, out_t, out_scores, out_diags, out_qp, out_tp, out_sp)

search

search(
    queries: Sequences,
    targets: Sequences | None = None,
    *,
    min_ungapped_score: int = 42,
    min_coverage: float = 0.0,
    upper_triangle: bool | None = None,
    banded: bool = True,
) -> tuple[Seeds, PairwiseAlignments]

Execute a two-stage MMseqs2-style prefilter followed by Smith-Waterman-Gotoh / WFA alignment.

Parameters:

Name Type Description Default
queries Sequences

Query sequences.

required
targets Sequences | None

Target sequences. If None, performs self-search on queries.

None
min_ungapped_score int

Minimum ungapped diagonal score threshold for Stage 1. Defaults to 42.

42
min_coverage float

Minimum sequence length ratio filter. Defaults to 0.0.

0.0
upper_triangle bool | None

Restrict to q_idx < t_idx (defaults to True when targets is None).

None
banded bool

If True (default), seeds the aligner with the Stage 1 diagonal offsets. If False, runs unseeded alignment on the prefiltered pairs.

True

Returns:

Type Description
tuple[Seeds, PairwiseAlignments]

tuple[Seeds, PairwiseAlignments]: Parallel (seeds, alignments) for all candidate pairs passing Stage 1.

Source code in src/seroverse/core/align/pairwise.py
def search(
    self,
    queries: Sequences,
    targets: Sequences | None = None,
    *,
    min_ungapped_score: int = 42,
    min_coverage: float = 0.0,
    upper_triangle: bool | None = None,
    banded: bool = True,
) -> tuple[Seeds, PairwiseAlignments]:
    r"""Execute a two-stage MMseqs2-style prefilter followed by Smith-Waterman-Gotoh / WFA alignment.

    Args:
        queries (Sequences): Query sequences.
        targets (Sequences | None): Target sequences. If `None`, performs self-search on `queries`.
        min_ungapped_score (int): Minimum ungapped diagonal score threshold for Stage 1. Defaults to `42`.
        min_coverage (float): Minimum sequence length ratio filter. Defaults to `0.0`.
        upper_triangle (bool | None): Restrict to `q_idx < t_idx` (defaults to `True` when `targets is None`).
        banded (bool): If `True` (default), seeds the aligner with the Stage 1 diagonal offsets.
            If `False`, runs unseeded alignment on the prefiltered pairs.

    Returns:
        tuple[Seeds, PairwiseAlignments]: Parallel `(seeds, alignments)` for all candidate pairs passing Stage 1.
    """
    t_seqs = queries if targets is None else targets
    seeds = self.prefilter(
        queries,
        targets,
        min_ungapped_score=min_ungapped_score,
        min_coverage=min_coverage,
        upper_triangle=upper_triangle,
    )
    if len(seeds) == 0:
        return seeds, PairwiseAlignments.empty()
    if banded:
        alignments = self.align_seeds(queries, t_seqs, seeds)
    else:
        paired_q, paired_t = seeds.extract_sequences(queries, t_seqs)
        alignments = self(paired_q, paired_t, seeds=None)
    return seeds, alignments

chaining

Collinear anchor chaining (minimap2-style concave-gap DP) and ragged SoA containers.

This module provides an Object-Oriented and Data-Oriented Structure-of-Arrays (SoA) collinear anchor chaining system inspired by minimap2 (Li, 2018): - Chain: Immutable scalar view of a single collinear chain. - Chains: Ragged SoA batch container (RaggedArrayContainer) storing variable-length anchor chains across sequence pairs. - Chainer: Parallel Numba JIT collinear chaining and piecewise WFA2 + adaptive block X-drop base-alignment engine.

Classes:

Name Description
Chain

Immutable scalar view of a single collinear anchor chain between a query and target sequence.

Chains

Ragged Structure-of-Arrays (SoA) batch container for collinear anchor chains.

Chainer

Collinear anchor chaining and piecewise alignment engine (minimap2-style concave-gap DP).

Chain dataclass

Bases: ScalarView

Immutable scalar view of a single collinear anchor chain between a query and target sequence.

Attributes:

Name Type Description
query_index int

Index of the query sequence.

target_index int

Index of the target sequence.

score int

Collinear chaining dynamic programming score.

q_start int

0-based start coordinate of the first anchor on the query sequence.

q_end int

0-based exclusive end coordinate of the last anchor on the query sequence.

t_start int

0-based start coordinate of the first anchor on the target sequence.

t_end int

0-based exclusive end coordinate of the last anchor on the target sequence.

is_primary bool

True if classified as a primary chain on the query sequence.

q_pos Int32Array

1D array of anchor query start coordinates.

t_pos Int32Array

1D array of anchor target start coordinates.

spans UInt16Array

1D array of anchor match spans (k).

Source code in src/seroverse/core/align/chaining.py
@dataclass(frozen=True, slots=True)
class Chain(ScalarView):
    r"""Immutable scalar view of a single collinear anchor chain between a query and target sequence.

    Attributes:
        query_index (int): Index of the query sequence.
        target_index (int): Index of the target sequence.
        score (int): Collinear chaining dynamic programming score.
        q_start (int): 0-based start coordinate of the first anchor on the query sequence.
        q_end (int): 0-based exclusive end coordinate of the last anchor on the query sequence.
        t_start (int): 0-based start coordinate of the first anchor on the target sequence.
        t_end (int): 0-based exclusive end coordinate of the last anchor on the target sequence.
        is_primary (bool): True if classified as a primary chain on the query sequence.
        q_pos (Int32Array): 1D array of anchor query start coordinates.
        t_pos (Int32Array): 1D array of anchor target start coordinates.
        spans (UInt16Array): 1D array of anchor match spans (`k`).
    """

    query_index: int
    target_index: int
    score: int
    q_start: int
    q_end: int
    t_start: int
    t_end: int
    is_primary: bool
    q_pos: Int32Array
    t_pos: Int32Array
    spans: UInt16Array

    def __len__(self) -> int:
        r"""Return the number of anchors in this chain."""
        return len(self.q_pos)

    @property
    def n_anchors(self) -> int:
        r"""Return the number of anchors in this chain."""
        return len(self.q_pos)

    @property
    def offset(self) -> int:
        r"""Return the initial diagonal offset `q_start - t_start` of this chain."""
        return int(self.q_start - self.t_start)

    @property
    def seeds(self) -> Seeds:
        r"""Return the anchors belonging to this chain as a [`Seeds`][seroverse.core.align.seeds.Seeds] batch."""
        n = len(self.q_pos)
        return Seeds(
            query_indices=np.full(n, self.query_index, dtype=np.uint32),
            target_indices=np.full(n, self.target_index, dtype=np.uint32),
            scores=self.spans.astype(np.uint32),
            offsets=(self.q_pos - self.t_pos).astype(np.int32),
            q_pos=self.q_pos,
            t_pos=self.t_pos,
            spans=self.spans,
        )

n_anchors property

n_anchors: int

Return the number of anchors in this chain.

offset property

offset: int

Return the initial diagonal offset q_start - t_start of this chain.

seeds property

seeds: Seeds

Return the anchors belonging to this chain as a Seeds batch.

Chains dataclass

Bases: RaggedArrayContainer[Chain, 'Chains']

Ragged Structure-of-Arrays (SoA) batch container for collinear anchor chains.

Stores per-chain summary arrays (query_indices, target_indices, scores, q_starts, q_ends, t_starts, t_ends, is_primary) alongside CSR-style offsets and lengths indexing into flat concatenated anchor arrays (anchor_q_pos, anchor_t_pos, anchor_spans).

Attributes:

Name Type Description
query_indices UInt32Array

1D array of query sequence indices.

target_indices UInt32Array

1D array of target sequence indices.

scores Int32Array

1D array of chaining DP scores.

q_starts Int32Array

1D array of chain query start coordinates.

q_ends Int32Array

1D array of chain query end coordinates.

t_starts Int32Array

1D array of chain target start coordinates.

t_ends Int32Array

1D array of chain target end coordinates.

is_primary BoolArray

1D boolean array of primary chain flags.

offsets Int32Array

1D array of start indices into anchor arrays.

lengths Int32Array

1D array of anchor counts per chain.

anchor_q_pos Int32Array

Concatenated 1D array of anchor query positions.

anchor_t_pos Int32Array

Concatenated 1D array of anchor target positions.

anchor_spans UInt16Array

Concatenated 1D array of anchor spans.

Methods:

Name Description
primary

Return only primary chains (self[self.is_primary]).

top_chains

Select the single highest-scoring chain for each unique query sequence.

to_seeds

Convert chains into pair-level consensus Seeds.

extract_sequences

Extract parallel batches of query and target sequences mapped by this Chains batch.

Source code in src/seroverse/core/align/chaining.py
@dataclass(frozen=True, slots=True, kw_only=True)
class Chains(RaggedArrayContainer[Chain, "Chains"]):
    r"""Ragged Structure-of-Arrays (SoA) batch container for collinear anchor chains.

    Stores per-chain summary arrays (`query_indices`, `target_indices`, `scores`, `q_starts`,
    `q_ends`, `t_starts`, `t_ends`, `is_primary`) alongside CSR-style `offsets` and `lengths`
    indexing into flat concatenated anchor arrays (`anchor_q_pos`, `anchor_t_pos`, `anchor_spans`).

    Attributes:
        query_indices (UInt32Array): 1D array of query sequence indices.
        target_indices (UInt32Array): 1D array of target sequence indices.
        scores (Int32Array): 1D array of chaining DP scores.
        q_starts (Int32Array): 1D array of chain query start coordinates.
        q_ends (Int32Array): 1D array of chain query end coordinates.
        t_starts (Int32Array): 1D array of chain target start coordinates.
        t_ends (Int32Array): 1D array of chain target end coordinates.
        is_primary (BoolArray): 1D boolean array of primary chain flags.
        offsets (Int32Array): 1D array of start indices into anchor arrays.
        lengths (Int32Array): 1D array of anchor counts per chain.
        anchor_q_pos (Int32Array): Concatenated 1D array of anchor query positions.
        anchor_t_pos (Int32Array): Concatenated 1D array of anchor target positions.
        anchor_spans (UInt16Array): Concatenated 1D array of anchor spans.
    """

    _ragged_fields: ClassVar[tuple[str, ...]] = ("anchor_q_pos", "anchor_t_pos", "anchor_spans")

    query_indices: UInt32Array
    target_indices: UInt32Array
    scores: Int32Array
    q_starts: Int32Array
    q_ends: Int32Array
    t_starts: Int32Array
    t_ends: Int32Array
    is_primary: BoolArray
    offsets: Int32Array
    lengths: Int32Array
    anchor_q_pos: Int32Array
    anchor_t_pos: Int32Array
    anchor_spans: UInt16Array

    @property
    def total_anchors(self) -> int:
        r"""Return the total number of anchors across all chains."""
        return len(self.anchor_q_pos)

    @property
    def diagonal_offsets(self) -> Int32Array:
        r"""Return the 1D array of initial diagonal offsets `q_starts - t_starts` for each chain."""
        return (self.q_starts - self.t_starts).astype(np.int32)

    def primary(self) -> Chains:
        r"""Return only primary chains (`self[self.is_primary]`)."""
        return self[self.is_primary]

    def top_chains(self) -> Chains:
        r"""Select the single highest-scoring chain for each unique query sequence."""
        if len(self) == 0:
            return self
        sort_order = np.lexsort((-self.scores.astype(np.int64), self.query_indices))
        sorted_queries = self.query_indices[sort_order]
        unique_mask = np.empty(len(sorted_queries), dtype=np.bool_)
        unique_mask[0] = True
        unique_mask[1:] = sorted_queries[1:] != sorted_queries[:-1]
        best_indices = np.sort(sort_order[unique_mask])
        return self[best_indices]

    def to_seeds(self) -> Seeds:
        r"""Convert chains into pair-level consensus [`Seeds`][seroverse.core.align.seeds.Seeds]."""
        if len(self) == 0:
            return Seeds.empty()
        spans = np.minimum(65535, np.maximum(1, self.q_ends - self.q_starts)).astype(np.uint16)
        return Seeds(
            query_indices=self.query_indices,
            target_indices=self.target_indices,
            scores=np.maximum(0, self.scores).astype(np.uint32),
            offsets=(self.q_starts - self.t_starts).astype(np.int32),
            q_pos=self.q_starts,
            t_pos=self.t_starts,
            spans=spans,
        )

    def extract_sequences(self, queries: Sequences, targets: Sequences) -> tuple[Sequences, Sequences]:
        r"""Extract parallel batches of query and target sequences mapped by this `Chains` batch."""
        return queries[self.query_indices], targets[self.target_indices]

total_anchors property

total_anchors: int

Return the total number of anchors across all chains.

diagonal_offsets property

diagonal_offsets: Int32Array

Return the 1D array of initial diagonal offsets q_starts - t_starts for each chain.

primary

primary() -> Chains

Return only primary chains (self[self.is_primary]).

Source code in src/seroverse/core/align/chaining.py
def primary(self) -> Chains:
    r"""Return only primary chains (`self[self.is_primary]`)."""
    return self[self.is_primary]

top_chains

top_chains() -> Chains

Select the single highest-scoring chain for each unique query sequence.

Source code in src/seroverse/core/align/chaining.py
def top_chains(self) -> Chains:
    r"""Select the single highest-scoring chain for each unique query sequence."""
    if len(self) == 0:
        return self
    sort_order = np.lexsort((-self.scores.astype(np.int64), self.query_indices))
    sorted_queries = self.query_indices[sort_order]
    unique_mask = np.empty(len(sorted_queries), dtype=np.bool_)
    unique_mask[0] = True
    unique_mask[1:] = sorted_queries[1:] != sorted_queries[:-1]
    best_indices = np.sort(sort_order[unique_mask])
    return self[best_indices]

to_seeds

to_seeds() -> Seeds

Convert chains into pair-level consensus Seeds.

Source code in src/seroverse/core/align/chaining.py
def to_seeds(self) -> Seeds:
    r"""Convert chains into pair-level consensus [`Seeds`][seroverse.core.align.seeds.Seeds]."""
    if len(self) == 0:
        return Seeds.empty()
    spans = np.minimum(65535, np.maximum(1, self.q_ends - self.q_starts)).astype(np.uint16)
    return Seeds(
        query_indices=self.query_indices,
        target_indices=self.target_indices,
        scores=np.maximum(0, self.scores).astype(np.uint32),
        offsets=(self.q_starts - self.t_starts).astype(np.int32),
        q_pos=self.q_starts,
        t_pos=self.t_starts,
        spans=spans,
    )

extract_sequences

extract_sequences(
    queries: Sequences, targets: Sequences
) -> tuple[Sequences, Sequences]

Extract parallel batches of query and target sequences mapped by this Chains batch.

Source code in src/seroverse/core/align/chaining.py
def extract_sequences(self, queries: Sequences, targets: Sequences) -> tuple[Sequences, Sequences]:
    r"""Extract parallel batches of query and target sequences mapped by this `Chains` batch."""
    return queries[self.query_indices], targets[self.target_indices]

Chainer dataclass

Collinear anchor chaining and piecewise alignment engine (minimap2-style concave-gap DP).

Chains unaggregated Seeds (produced by index.hits(queries, aggregate=False)) into collinear Chains using a concave gap penalty function and max_skip predecessor pruning, classifies primary vs secondary chains, and performs piecewise base-level alignment via WFA2 + adaptive block X-drop.

Attributes:

Name Type Description
max_dist_x int

Maximum target distance between consecutive anchors in a chain. Defaults to 5000.

max_dist_y int

Maximum query distance between consecutive anchors in a chain. Defaults to 5000.

max_band int

Maximum diagonal shift \(|(q_i - q_j) - (t_i - t_j)|\) between chained anchors. Defaults to 500.

min_cnt int

Minimum number of anchors required to form a valid chain. Defaults to 2.

min_score int

Minimum chaining DP score required to emit a chain. Defaults to 20.

gap_scale float

Concave gap penalty scaling multiplier. Defaults to 1.0.

max_skip int

Heuristic predecessor pruning threshold. Defaults to 25.

mask_level float

Minimum query overlap ratio to mask a lower-scoring chain as secondary. Defaults to 0.5.

Methods:

Name Description
align

Perform piecewise anchor-guided base alignment for each chain in chains.

search

Execute a full minimap2-style seed-chain-align search pipeline.

Source code in src/seroverse/core/align/chaining.py
@dataclass(frozen=True, slots=True)
class Chainer:
    r"""Collinear anchor chaining and piecewise alignment engine (`minimap2`-style concave-gap DP).

    Chains unaggregated [`Seeds`][seroverse.core.align.seeds.Seeds] (produced by
    `index.hits(queries, aggregate=False)`) into collinear [`Chains`][seroverse.core.align.chaining.Chains]
    using a concave gap penalty function and `max_skip` predecessor pruning, classifies primary vs
    secondary chains, and performs piecewise base-level alignment via WFA2 + adaptive block X-drop.

    Attributes:
        max_dist_x (int): Maximum target distance between consecutive anchors in a chain. Defaults to 5000.
        max_dist_y (int): Maximum query distance between consecutive anchors in a chain. Defaults to 5000.
        max_band (int): Maximum diagonal shift $|(q_i - q_j) - (t_i - t_j)|$ between chained anchors. Defaults to 500.
        min_cnt (int): Minimum number of anchors required to form a valid chain. Defaults to 2.
        min_score (int): Minimum chaining DP score required to emit a chain. Defaults to 20.
        gap_scale (float): Concave gap penalty scaling multiplier. Defaults to 1.0.
        max_skip (int): Heuristic predecessor pruning threshold. Defaults to 25.
        mask_level (float): Minimum query overlap ratio to mask a lower-scoring chain as secondary. Defaults to 0.5.
    """

    max_dist_x: int = 5000
    max_dist_y: int = 5000
    max_band: int = 500
    min_cnt: int = 2
    min_score: int = 20
    gap_scale: float = 1.0
    max_skip: int = 25
    mask_level: float = 0.5

    def __call__(self, seeds: Seeds) -> Chains:
        r"""Chain unaggregated anchor [`Seeds`][seroverse.core.align.seeds.Seeds] into collinear [`Chains`][seroverse.core.align.chaining.Chains].

        Args:
            seeds (Seeds): Unaggregated anchor hits from `index.hits(queries, aggregate=False)` or custom anchors.

        Returns:
            Chains: Ragged SoA container of collinear chains sorted by `(query_index, -score)`.
        """
        n_anchors = len(seeds)
        if n_anchors == 0:
            return Chains.empty()

        if not _is_sorted_anchors(seeds.query_indices, seeds.target_indices, seeds.t_pos, seeds.q_pos):
            order = np.lexsort((seeds.q_pos, seeds.t_pos, seeds.target_indices, seeds.query_indices))
            seeds = seeds[order]

        pair_starts, pair_ends = _find_pair_runs(seeds.query_indices, seeds.target_indices)
        n_pairs = len(pair_starts)
        if n_pairs == 0:
            return Chains.empty()

        tmp_c_score = np.empty(n_anchors, dtype=np.int32)
        tmp_c_len = np.empty(n_anchors, dtype=np.int32)
        tmp_c_a_start = np.empty(n_anchors, dtype=np.int32)
        tmp_anchor_idx = np.empty(n_anchors, dtype=np.int32)
        pair_chain_counts = np.zeros(n_pairs, dtype=np.int32)
        pair_anchor_counts = np.zeros(n_pairs, dtype=np.int32)

        _chain_pairs_kernel(
            seeds.q_pos,
            seeds.t_pos,
            seeds.spans,
            pair_starts,
            pair_ends,
            int(self.max_dist_x),
            int(self.max_dist_y),
            int(self.max_band),
            int(self.min_cnt),
            int(self.min_score),
            float(self.gap_scale),
            int(self.max_skip),
            tmp_c_score,
            tmp_c_len,
            tmp_c_a_start,
            tmp_anchor_idx,
            pair_chain_counts,
            pair_anchor_counts,
        )

        total_chains = int(np.sum(pair_chain_counts))
        total_chained_anchors = int(np.sum(pair_anchor_counts))
        if total_chains == 0:
            return Chains.empty()

        chain_indptr = lengths_to_offsets(pair_chain_counts)
        anchor_indptr = lengths_to_offsets(pair_anchor_counts)

        out_q = np.empty(total_chains, dtype=np.uint32)
        out_t = np.empty(total_chains, dtype=np.uint32)
        out_scores = np.empty(total_chains, dtype=np.int32)
        out_q_starts = np.empty(total_chains, dtype=np.int32)
        out_q_ends = np.empty(total_chains, dtype=np.int32)
        out_t_starts = np.empty(total_chains, dtype=np.int32)
        out_t_ends = np.empty(total_chains, dtype=np.int32)
        out_offsets = np.empty(total_chains, dtype=np.int32)
        out_lengths = np.empty(total_chains, dtype=np.int32)
        out_aq = np.empty(total_chained_anchors, dtype=np.int32)
        out_at = np.empty(total_chained_anchors, dtype=np.int32)
        out_asp = np.empty(total_chained_anchors, dtype=np.uint16)

        _compact_chains_kernel(
            seeds.query_indices,
            seeds.target_indices,
            seeds.q_pos,
            seeds.t_pos,
            seeds.spans,
            pair_starts,
            pair_chain_counts,
            chain_indptr,
            anchor_indptr,
            tmp_c_score,
            tmp_c_len,
            tmp_c_a_start,
            tmp_anchor_idx,
            out_q,
            out_t,
            out_scores,
            out_q_starts,
            out_q_ends,
            out_t_starts,
            out_t_ends,
            out_offsets,
            out_lengths,
            out_aq,
            out_at,
            out_asp,
        )

        out_primary = _classify_primary_chains_kernel(
            out_q,
            out_scores,
            out_q_starts,
            out_q_ends,
            float(self.mask_level),
        )

        return Chains(
            query_indices=out_q,
            target_indices=out_t,
            scores=out_scores,
            q_starts=out_q_starts,
            q_ends=out_q_ends,
            t_starts=out_t_starts,
            t_ends=out_t_ends,
            is_primary=out_primary,
            offsets=out_offsets,
            lengths=out_lengths,
            anchor_q_pos=out_aq,
            anchor_t_pos=out_at,
            anchor_spans=out_asp,
        )

    def align(
        self,
        queries: Sequences,
        targets: Sequences,
        chains: Chains,
        aligner: PairwiseAligner | None = None,
    ) -> PairwiseAlignments:
        r"""Perform piecewise anchor-guided base alignment for each chain in `chains`.

        Uses WFA2 for inter-anchor gaps and adaptive block X-drop extension at chain ends.

        Args:
            queries (Sequences): Query sequences collection.
            targets (Sequences): Target sequences collection.
            chains (Chains): Collinear chains to align at the base/residue level.
            aligner (PairwiseAligner | None): Optional `PairwiseAligner` providing scoring matrix and gap penalties.

        Returns:
            PairwiseAlignments: Base-level alignment statistics parallel to `chains`.
        """
        eff_aligner = aligner if aligner is not None else PairwiseAligner()
        return eff_aligner.align_chains(queries, targets, chains)

    def search(
        self,
        queries: Sequences,
        index: BaseKmerIndex,
        targets: Sequences,
        aligner: PairwiseAligner | None = None,
        *,
        min_seed_score: int = 1,
        primary_only: bool = True,
    ) -> tuple[Chains, PairwiseAlignments]:
        r"""Execute a full `minimap2`-style seed-chain-align search pipeline.

        Extracts unaggregated anchors via `index.hits(queries, min_score=min_seed_score, aggregate=False)`,
        chains them with concave-gap DP, optionally filters to primary chains, and performs piecewise
        WFA2 + adaptive block X-drop base alignment.

        Args:
            queries (Sequences): Query sequences.
            index (BaseKmerIndex): Sorted k-mer / syncmer / randstrobe index built on `targets`.
            targets (Sequences): Target sequences.
            aligner (PairwiseAligner | None): Optional `PairwiseAligner` for scoring parameters.
                Defaults to `PairwiseAligner(matrix=index._default_matrix())`.
            min_seed_score (int): Minimum anchor hits per `(query, target)` pair before chaining. Defaults to 1.
            primary_only (bool): If True (default), restricts output to primary chains (`chains.primary()`).

        Returns:
            tuple[Chains, PairwiseAlignments]: Parallel `(chains, alignments)` for all discovered homologous chains.
        """
        eff_aligner = aligner if aligner is not None else PairwiseAligner(matrix=index._default_matrix())
        raw_seeds = index.hits(queries, min_score=min_seed_score, aggregate=False)
        if len(raw_seeds) == 0:
            return Chains.empty(), PairwiseAlignments.empty()

        chains = self(raw_seeds)
        if primary_only:
            chains = chains.primary()
        if len(chains) == 0:
            return chains, PairwiseAlignments.empty()

        alignments = self.align(queries, targets, chains, aligner=eff_aligner)
        return chains, alignments

align

align(
    queries: Sequences,
    targets: Sequences,
    chains: Chains,
    aligner: PairwiseAligner | None = None,
) -> PairwiseAlignments

Perform piecewise anchor-guided base alignment for each chain in chains.

Uses WFA2 for inter-anchor gaps and adaptive block X-drop extension at chain ends.

Parameters:

Name Type Description Default
queries Sequences

Query sequences collection.

required
targets Sequences

Target sequences collection.

required
chains Chains

Collinear chains to align at the base/residue level.

required
aligner PairwiseAligner | None

Optional PairwiseAligner providing scoring matrix and gap penalties.

None

Returns:

Name Type Description
PairwiseAlignments PairwiseAlignments

Base-level alignment statistics parallel to chains.

Source code in src/seroverse/core/align/chaining.py
def align(
    self,
    queries: Sequences,
    targets: Sequences,
    chains: Chains,
    aligner: PairwiseAligner | None = None,
) -> PairwiseAlignments:
    r"""Perform piecewise anchor-guided base alignment for each chain in `chains`.

    Uses WFA2 for inter-anchor gaps and adaptive block X-drop extension at chain ends.

    Args:
        queries (Sequences): Query sequences collection.
        targets (Sequences): Target sequences collection.
        chains (Chains): Collinear chains to align at the base/residue level.
        aligner (PairwiseAligner | None): Optional `PairwiseAligner` providing scoring matrix and gap penalties.

    Returns:
        PairwiseAlignments: Base-level alignment statistics parallel to `chains`.
    """
    eff_aligner = aligner if aligner is not None else PairwiseAligner()
    return eff_aligner.align_chains(queries, targets, chains)

search

search(
    queries: Sequences,
    index: BaseKmerIndex,
    targets: Sequences,
    aligner: PairwiseAligner | None = None,
    *,
    min_seed_score: int = 1,
    primary_only: bool = True,
) -> tuple[Chains, PairwiseAlignments]

Execute a full minimap2-style seed-chain-align search pipeline.

Extracts unaggregated anchors via index.hits(queries, min_score=min_seed_score, aggregate=False), chains them with concave-gap DP, optionally filters to primary chains, and performs piecewise WFA2 + adaptive block X-drop base alignment.

Parameters:

Name Type Description Default
queries Sequences

Query sequences.

required
index BaseKmerIndex

Sorted k-mer / syncmer / randstrobe index built on targets.

required
targets Sequences

Target sequences.

required
aligner PairwiseAligner | None

Optional PairwiseAligner for scoring parameters. Defaults to PairwiseAligner(matrix=index._default_matrix()).

None
min_seed_score int

Minimum anchor hits per (query, target) pair before chaining. Defaults to 1.

1
primary_only bool

If True (default), restricts output to primary chains (chains.primary()).

True

Returns:

Type Description
tuple[Chains, PairwiseAlignments]

tuple[Chains, PairwiseAlignments]: Parallel (chains, alignments) for all discovered homologous chains.

Source code in src/seroverse/core/align/chaining.py
def search(
    self,
    queries: Sequences,
    index: BaseKmerIndex,
    targets: Sequences,
    aligner: PairwiseAligner | None = None,
    *,
    min_seed_score: int = 1,
    primary_only: bool = True,
) -> tuple[Chains, PairwiseAlignments]:
    r"""Execute a full `minimap2`-style seed-chain-align search pipeline.

    Extracts unaggregated anchors via `index.hits(queries, min_score=min_seed_score, aggregate=False)`,
    chains them with concave-gap DP, optionally filters to primary chains, and performs piecewise
    WFA2 + adaptive block X-drop base alignment.

    Args:
        queries (Sequences): Query sequences.
        index (BaseKmerIndex): Sorted k-mer / syncmer / randstrobe index built on `targets`.
        targets (Sequences): Target sequences.
        aligner (PairwiseAligner | None): Optional `PairwiseAligner` for scoring parameters.
            Defaults to `PairwiseAligner(matrix=index._default_matrix())`.
        min_seed_score (int): Minimum anchor hits per `(query, target)` pair before chaining. Defaults to 1.
        primary_only (bool): If True (default), restricts output to primary chains (`chains.primary()`).

    Returns:
        tuple[Chains, PairwiseAlignments]: Parallel `(chains, alignments)` for all discovered homologous chains.
    """
    eff_aligner = aligner if aligner is not None else PairwiseAligner(matrix=index._default_matrix())
    raw_seeds = index.hits(queries, min_score=min_seed_score, aggregate=False)
    if len(raw_seeds) == 0:
        return Chains.empty(), PairwiseAlignments.empty()

    chains = self(raw_seeds)
    if primary_only:
        chains = chains.primary()
    if len(chains) == 0:
        return chains, PairwiseAlignments.empty()

    alignments = self.align(queries, targets, chains, aligner=eff_aligner)
    return chains, alignments

Sequence Clustering (seroverse.core.seq.clustering)

clustering

Sequence clustering algorithms, ragged SoA data structures, and Numba kernels.

Provides high-performance sequence clustering (Clusterer) and ragged Structure-of-Arrays cluster containers (Clusters, Cluster) combining sparse syncmer-linked randstrobe pre-filtering, parallel banded Smith-Waterman-Gotoh alignment, and sub-millisecond Numba greedy set-cover / CD-HIT clustering.

Classes:

Name Description
Cluster

Scalar view of a single sequence cluster.

Clusters

Ragged Structure-of-Arrays (SoA) container for sequence clustering results.

Clusterer

High-performance sequence clustering engine.

Cluster dataclass

Bases: ScalarView

Scalar view of a single sequence cluster.

Attributes:

Name Type Description
representative int

Sequence index of the cluster centroid in the source collection.

members Int32Array

1D array of sequence indices belonging to this cluster.

Source code in src/seroverse/core/seq/clustering.py
@dataclass(slots=True, frozen=True)
class Cluster(ScalarView):
    r"""Scalar view of a single sequence cluster.

    Attributes:
        representative: Sequence index of the cluster centroid in the source collection.
        members: 1D array of sequence indices belonging to this cluster.
    """

    representative: int
    members: Int32Array

    def __len__(self) -> int:
        r"""Return the number of member sequences in this cluster."""
        return len(self.members)

Clusters dataclass

Bases: RaggedArrayContainer[Cluster, 'Clusters']

Ragged Structure-of-Arrays (SoA) container for sequence clustering results.

Stores centroid representative indices (representatives) and variable-length member sequence indices in a flat contiguous array (members) indexed by offsets and lengths for \(O(1)\) cluster slicing and member lookup.

Attributes:

Name Type Description
representatives Int32Array

1D array of sequence indices representing the centroid of each cluster.

members Int32Array

Flat 1D array of member sequence indices concatenated across all clusters.

offsets Int32Array

Starting index into members for each cluster.

lengths Int32Array

Number of member sequences in each cluster.

Methods:

Name Description
as_csr

Return a zero-copy \((C \times N)\) sparse CSR cluster-membership incidence matrix.

get_members

Return 1D array of original sequence indices assigned to the specified cluster ID.

centroids

Extract the representative centroid sequences from the source collection.

from_assignments

Construct a Clusters container from flat cluster ID assignments.

Source code in src/seroverse/core/seq/clustering.py
@dataclass(slots=True, frozen=True)
class Clusters(RaggedArrayContainer[Cluster, "Clusters"]):
    r"""Ragged Structure-of-Arrays (SoA) container for sequence clustering results.

    Stores centroid representative indices (`representatives`) and variable-length
    member sequence indices in a flat contiguous array (`members`) indexed by
    `offsets` and `lengths` for $O(1)$ cluster slicing and member lookup.

    Attributes:
        representatives: 1D array of sequence indices representing the centroid of each cluster.
        members: Flat 1D array of member sequence indices concatenated across all clusters.
        offsets: Starting index into `members` for each cluster.
        lengths: Number of member sequences in each cluster.
    """

    _ragged_fields: ClassVar[tuple[str, ...]] = ("members",)

    representatives: Int32Array
    members: Int32Array
    offsets: Int32Array
    lengths: Int32Array

    @property
    def n_clusters(self) -> int:
        r"""Total number of clusters $C$."""
        return len(self.offsets)

    @property
    def n_seqs(self) -> int:
        r"""Total number of clustered sequences $N$."""
        return len(self.members)

    @property
    def sizes(self) -> Int32Array:
        r"""1D array of member counts for each cluster (alias for `lengths`)."""
        return self.lengths

    @property
    def representative_indices(self) -> Int32Array:
        r"""1D array of centroid sequence indices (alias for `representatives`)."""
        return self.representatives

    @property
    def cluster_ids(self) -> Int32Array:
        r"""1D array mapping each sequence index to its cluster ID $c \in [0, C-1]$."""
        if len(self.members) == 0:
            return np.empty(0, dtype=np.int32)
        n = int(self.members.max()) + 1
        out = np.full(n, -1, dtype=np.int32)
        out[self.members] = np.repeat(np.arange(len(self.lengths), dtype=np.int32), self.lengths)
        return out

    def as_csr(self, n_seqs: int | None = None) -> CSR:
        r"""Return a zero-copy $(C \times N)$ sparse CSR cluster-membership incidence matrix.

        Row $c$ contains `1` at column $i$ for each sequence $i \in \text{members}_c$.

        Args:
            n_seqs: Total number of sequences $N$ (columns). If `None`, inferred from `max(members) + 1`.

        Returns:
            CSR: Sparse $(C \times N)$ `uint8` membership matrix.
        """
        c = len(self)
        if c == 0 or len(self.members) == 0:
            cols = n_seqs if n_seqs is not None else 0
            return CSR.empty((c, cols), dtype=np.uint8)
        cols = n_seqs if n_seqs is not None else int(self.members.max()) + 1
        data = np.ones(len(self.members), dtype=np.uint8)
        return CSR(indptr=self.indptr, indices=self.members, data=data, shape=(c, cols))

    def get_members(self, cluster_id: int) -> Int32Array:
        r"""Return 1D array of original sequence indices assigned to the specified cluster ID.

        Args:
            cluster_id: 0-based integer cluster identifier.

        Returns:
            Int32Array: Zero-copy slice of sequence indices belonging to this cluster.
        """
        return self[cluster_id].members

    def centroids(self, seqs: Sequences) -> Sequences:
        r"""Extract the representative centroid sequences from the source collection.

        Args:
            seqs: Source [`Sequences`][seroverse.core.seq.seq.Sequences] collection.

        Returns:
            Sequences: Sliced collection containing only cluster centroids.
        """
        if len(seqs) != self.n_seqs:
            raise ValueError(f"Sequence length mismatch: expected {self.n_seqs}, got {len(seqs)}")
        return seqs[self.representatives]

    @classmethod
    def from_assignments(
        cls,
        cluster_ids: Int32Array,
        representatives: Int32Array,
        sizes: Int32Array,
    ) -> Self:
        r"""Construct a [`Clusters`][seroverse.core.seq.clustering.Clusters] container from flat cluster ID assignments.

        Args:
            cluster_ids: 1D array mapping each sequence $i \in [0, N-1]$ to cluster ID $c \in [0, C-1]$.
            representatives: 1D array of centroid sequence indices (length $C$).
            sizes: 1D array of member counts per cluster (length $C$).

        Returns:
            Clusters: Ragged SoA container with $O(1)$ member indexing.
        """
        n_clusters = len(representatives)
        if n_clusters == 0:
            return cls.empty()

        offsets = cls.lengths_to_offsets(sizes)
        members = _pack_members(cluster_ids, offsets)
        return cls(
            representatives=representatives,
            members=members,
            offsets=offsets,
            lengths=sizes,
        )

n_clusters property

n_clusters: int

Total number of clusters \(C\).

n_seqs property

n_seqs: int

Total number of clustered sequences \(N\).

sizes property

sizes: Int32Array

1D array of member counts for each cluster (alias for lengths).

representative_indices property

representative_indices: Int32Array

1D array of centroid sequence indices (alias for representatives).

cluster_ids property

cluster_ids: Int32Array

1D array mapping each sequence index to its cluster ID \(c \in [0, C-1]\).

as_csr

as_csr(n_seqs: int | None = None) -> CSR

Return a zero-copy \((C \times N)\) sparse CSR cluster-membership incidence matrix.

Row \(c\) contains 1 at column \(i\) for each sequence \(i \in \text{members}_c\).

Parameters:

Name Type Description Default
n_seqs int | None

Total number of sequences \(N\) (columns). If None, inferred from max(members) + 1.

None

Returns:

Name Type Description
CSR CSR

Sparse \((C \times N)\) uint8 membership matrix.

Source code in src/seroverse/core/seq/clustering.py
def as_csr(self, n_seqs: int | None = None) -> CSR:
    r"""Return a zero-copy $(C \times N)$ sparse CSR cluster-membership incidence matrix.

    Row $c$ contains `1` at column $i$ for each sequence $i \in \text{members}_c$.

    Args:
        n_seqs: Total number of sequences $N$ (columns). If `None`, inferred from `max(members) + 1`.

    Returns:
        CSR: Sparse $(C \times N)$ `uint8` membership matrix.
    """
    c = len(self)
    if c == 0 or len(self.members) == 0:
        cols = n_seqs if n_seqs is not None else 0
        return CSR.empty((c, cols), dtype=np.uint8)
    cols = n_seqs if n_seqs is not None else int(self.members.max()) + 1
    data = np.ones(len(self.members), dtype=np.uint8)
    return CSR(indptr=self.indptr, indices=self.members, data=data, shape=(c, cols))

get_members

get_members(cluster_id: int) -> Int32Array

Return 1D array of original sequence indices assigned to the specified cluster ID.

Parameters:

Name Type Description Default
cluster_id int

0-based integer cluster identifier.

required

Returns:

Name Type Description
Int32Array Int32Array

Zero-copy slice of sequence indices belonging to this cluster.

Source code in src/seroverse/core/seq/clustering.py
def get_members(self, cluster_id: int) -> Int32Array:
    r"""Return 1D array of original sequence indices assigned to the specified cluster ID.

    Args:
        cluster_id: 0-based integer cluster identifier.

    Returns:
        Int32Array: Zero-copy slice of sequence indices belonging to this cluster.
    """
    return self[cluster_id].members

centroids

centroids(seqs: Sequences) -> Sequences

Extract the representative centroid sequences from the source collection.

Parameters:

Name Type Description Default
seqs Sequences

Source Sequences collection.

required

Returns:

Name Type Description
Sequences Sequences

Sliced collection containing only cluster centroids.

Source code in src/seroverse/core/seq/clustering.py
def centroids(self, seqs: Sequences) -> Sequences:
    r"""Extract the representative centroid sequences from the source collection.

    Args:
        seqs: Source [`Sequences`][seroverse.core.seq.seq.Sequences] collection.

    Returns:
        Sequences: Sliced collection containing only cluster centroids.
    """
    if len(seqs) != self.n_seqs:
        raise ValueError(f"Sequence length mismatch: expected {self.n_seqs}, got {len(seqs)}")
    return seqs[self.representatives]

from_assignments classmethod

from_assignments(
    cluster_ids: Int32Array,
    representatives: Int32Array,
    sizes: Int32Array,
) -> Self

Construct a Clusters container from flat cluster ID assignments.

Parameters:

Name Type Description Default
cluster_ids Int32Array

1D array mapping each sequence \(i \in [0, N-1]\) to cluster ID \(c \in [0, C-1]\).

required
representatives Int32Array

1D array of centroid sequence indices (length \(C\)).

required
sizes Int32Array

1D array of member counts per cluster (length \(C\)).

required

Returns:

Name Type Description
Clusters Self

Ragged SoA container with \(O(1)\) member indexing.

Source code in src/seroverse/core/seq/clustering.py
@classmethod
def from_assignments(
    cls,
    cluster_ids: Int32Array,
    representatives: Int32Array,
    sizes: Int32Array,
) -> Self:
    r"""Construct a [`Clusters`][seroverse.core.seq.clustering.Clusters] container from flat cluster ID assignments.

    Args:
        cluster_ids: 1D array mapping each sequence $i \in [0, N-1]$ to cluster ID $c \in [0, C-1]$.
        representatives: 1D array of centroid sequence indices (length $C$).
        sizes: 1D array of member counts per cluster (length $C$).

    Returns:
        Clusters: Ragged SoA container with $O(1)$ member indexing.
    """
    n_clusters = len(representatives)
    if n_clusters == 0:
        return cls.empty()

    offsets = cls.lengths_to_offsets(sizes)
    members = _pack_members(cluster_ids, offsets)
    return cls(
        representatives=representatives,
        members=members,
        offsets=offsets,
        lengths=sizes,
    )

Clusterer dataclass

High-performance sequence clustering engine.

Combines sparse syncmer-linked randstrobe pre-filtering (RandstrobeIndex), SIMD-parallel banded Smith-Waterman-Gotoh alignment (PairwiseAligner), and compiled Numba clustering algorithms to group biological sequences.

Modes
  • "cdhit" (default): Length-descending greedy incremental clustering (CD-HIT style). Guarantees that full-length proteins are selected as cluster representatives rather than fragments.
  • "mcl": Markov Cluster Algorithm (MCL) on BLOSUM62 alignment bitscores. Sensitive to divergent functional homologs (e.g., glycosyltransferases, polymerases, flippases) via random-walk expansion and inflation (\(I\)).
  • "greedy_cover": Iterative maximum-uncovered-degree greedy set cover.
  • "upgma": Hierarchical average-linkage (UPGMA) clustering.
  • "connected_components": Transitive closure core components.

Parameters:

Name Type Description Default
min_identity float

Minimum pairwise sequence percent identity threshold \([0.0, 100.0]\). Defaults to 80.0 (automatically relaxed to 25.0 when mode="mcl" and left at default 80.0).

80.0
min_coverage float

Minimum alignment coverage fraction \([0.0, 1.0]\) on both query and target. Defaults to 0.0.

0.0
min_bitscore float

Minimum Karlin-Altschul alignment bitscore threshold. Defaults to 0.0 (automatically set to 40.0 when mode="mcl" and left at 0.0).

0.0
inflation float

MCL inflation parameter \(I > 1.0\) controlling cluster granularity. Defaults to 2.0.

2.0
min_score int

Minimum shared randstrobe hash hits for alignment candidate seed filtering. Defaults to 2.

2
k int

Strobemer seed length. Defaults to 10.

10
s int

Strobemer sampling stride. Defaults to 5.

5
aligner_k int

Bandwidth parameter (\(2k + 1\) diagonals) for banded Gotoh alignment. Defaults to 20.

20
matrix SubstitutionMatrix

Substitution matrix for scoring and prefiltering. Defaults to SubstitutionMatrix.BLOSUM62.

BLOSUM62
mode Literal['cdhit', 'mcl', 'greedy_cover', 'upgma', 'connected_components']

Clustering algorithm mode. Defaults to "cdhit".

'cdhit'
embedding_weight float

Blend weight \(\alpha \in [0.0, 1.0]\) for active-site motif cosine similarity in MCL edge weighting and role-gated twilight-zone rescue. Defaults to 0.0 (automatically uses 0.25 when embeddings are explicitly supplied to cluster()).

0.0
motif_rescue_threshold float

Minimum cosine similarity \([0.0, 1.0]\) between active-site embeddings required to trigger role-gated twilight-zone identity relaxation. Defaults to 0.75.

0.75

Methods:

Name Description
cluster

Cluster an input batch of sequences into non-redundant representative groups.

Attributes:

Name Type Description
aligner PairwiseAligner

Return a configured PairwiseAligner instance.

Source code in src/seroverse/core/seq/clustering.py
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
@dataclass(slots=True, frozen=True)
class Clusterer:
    r"""High-performance sequence clustering engine.

    Combines sparse syncmer-linked randstrobe pre-filtering ([`RandstrobeIndex`][seroverse.core.seq.kmers.RandstrobeIndex]),
    SIMD-parallel banded Smith-Waterman-Gotoh alignment ([`PairwiseAligner`][seroverse.core.align.pairwise.PairwiseAligner]),
    and compiled Numba clustering algorithms to group biological sequences.

    Modes:
        - `"cdhit"` (default): Length-descending greedy incremental clustering (CD-HIT style).
          Guarantees that full-length proteins are selected as cluster representatives rather than fragments.
        - `"mcl"`: Markov Cluster Algorithm (MCL) on BLOSUM62 alignment bitscores. Sensitive to
          divergent functional homologs (e.g., glycosyltransferases, polymerases, flippases) via
          random-walk expansion and inflation ($I$).
        - `"greedy_cover"`: Iterative maximum-uncovered-degree greedy set cover.
        - `"upgma"`: Hierarchical average-linkage (UPGMA) clustering.
        - `"connected_components"`: Transitive closure core components.

    Args:
        min_identity: Minimum pairwise sequence percent identity threshold $[0.0, 100.0]$. Defaults to 80.0
            (automatically relaxed to 25.0 when `mode="mcl"` and left at default 80.0).
        min_coverage: Minimum alignment coverage fraction $[0.0, 1.0]$ on both query and target. Defaults to 0.0.
        min_bitscore: Minimum Karlin-Altschul alignment bitscore threshold. Defaults to 0.0
            (automatically set to 40.0 when `mode="mcl"` and left at 0.0).
        inflation: MCL inflation parameter $I > 1.0$ controlling cluster granularity. Defaults to 2.0.
        min_score: Minimum shared randstrobe hash hits for alignment candidate seed filtering. Defaults to 2.
        k: Strobemer seed length. Defaults to 10.
        s: Strobemer sampling stride. Defaults to 5.
        aligner_k: Bandwidth parameter ($2k + 1$ diagonals) for banded Gotoh alignment. Defaults to 20.
        matrix: Substitution matrix for scoring and prefiltering. Defaults to `SubstitutionMatrix.BLOSUM62`.
        mode: Clustering algorithm mode. Defaults to `"cdhit"`.
        embedding_weight: Blend weight $\alpha \in [0.0, 1.0]$ for active-site motif cosine similarity
            in MCL edge weighting and role-gated twilight-zone rescue. Defaults to 0.0 (automatically
            uses 0.25 when `embeddings` are explicitly supplied to `cluster()`).
        motif_rescue_threshold: Minimum cosine similarity $[0.0, 1.0]$ between active-site embeddings
            required to trigger role-gated twilight-zone identity relaxation. Defaults to 0.75.
    """

    min_identity: float = 80.0
    min_coverage: float = 0.0
    min_bitscore: float = 0.0
    inflation: float = 2.0
    min_score: int = 2
    k: int = 10
    s: int = 5
    aligner_k: int = 20
    matrix: SubstitutionMatrix = SubstitutionMatrix.BLOSUM62
    mode: Literal["cdhit", "mcl", "greedy_cover", "upgma", "connected_components"] = "cdhit"
    embedding_weight: float = 0.0
    motif_rescue_threshold: float = 0.75

    @property
    def aligner(self) -> PairwiseAligner:
        r"""Return a configured [`PairwiseAligner`][seroverse.core.align.pairwise.PairwiseAligner] instance."""
        return PairwiseAligner(matrix=self.matrix, k=self.aligner_k)

    def cluster(
        self,
        seqs: Sequences,
        roles: np.ndarray[tuple[int], np.dtype[Any]] | Sequence[int] | None = None,
        embeddings: np.ndarray[tuple[int, int], np.dtype[Any]] | None = None,
    ) -> Clusters:
        r"""Cluster an input batch of sequences into non-redundant representative groups.

        Args:
            seqs: Input collection of sequences to cluster.
            roles: Optional 1D array/sequence of integer `GeneRole` IDs (length $N$). When provided,
                prevents clustering across incompatible known roles (`role > 0`) and, in `"mcl"` mode
                (or when `min_identity < 60.0`), evaluates all intra-role pairs so divergent functional
                homologs sharing the same role are aligned even without shared exact k-mer seeds.
            embeddings: Optional 2D array `(N, D)` of sequence representations (e.g., active-site
                `conv_max` or `task_weighted` features from `GeneRoleClassifier.transform()`). When
                provided, enables role-gated twilight-zone homology rescue for pairs with cosine
                similarity `>= self.motif_rescue_threshold` and blends cosine similarity into MCL
                edge weights.

        Returns:
            Clusters: Ragged SoA container of cluster assignments and centroid representatives.
        """
        n = len(seqs)
        if n == 0:
            return Clusters.empty()

        if n == 1:
            return Clusters(
                representatives=np.zeros(1, dtype=np.int32),
                members=np.zeros(1, dtype=np.int32),
                offsets=np.zeros(1, dtype=np.int32),
                lengths=np.ones(1, dtype=np.int32),
            )

        roles_arr: np.ndarray[tuple[int], np.dtype[np.int32]] | None = None
        if roles is not None:
            roles_arr = np.asarray(roles, dtype=np.int32)
            if len(roles_arr) != n:
                raise ValueError(f"roles length ({len(roles_arr)}) must match seqs length ({n}).")

        emb_norm: np.ndarray[tuple[int, int], np.dtype[np.float32]] | None = None
        if embeddings is not None:
            emb_arr = np.asarray(embeddings, dtype=np.float32)
            if emb_arr.ndim != 2 or emb_arr.shape[0] != n:
                raise ValueError(f"embeddings shape {emb_arr.shape} must be 2D with first dimension {n}.")
            norms = np.maximum(np.linalg.norm(emb_arr, axis=1, keepdims=True), np.float32(1e-8))
            emb_norm = np.ascontiguousarray(emb_arr / norms, dtype=np.float32)

        eff_min_identity = 25.0 if (self.mode == "mcl" and self.min_identity == 80.0) else float(self.min_identity)
        eff_min_bitscore = 40.0 if (self.mode == "mcl" and self.min_bitscore <= 0.0) else float(self.min_bitscore)
        sensitive_mode = self.mode == "mcl" or eff_min_identity < 60.0 or emb_norm is not None

        # 1. Sparse Candidate Pre-Filtering via RandstrobeIndex
        k_eff = min(self.k, 5) if sensitive_mode else self.k
        s_eff = min(self.s, 2) if sensitive_mode else self.s
        min_score_eff = 1 if sensitive_mode else self.min_score

        target_idx = RandstrobeIndex.build(seqs, k=k_eff, s=s_eff, sort_by_hash=True)
        query_idx = RandstrobeIndex.build(seqs, k=k_eff, s=s_eff, sort_by_hash=False)

        seeds = target_idx.hits(
            query_idx,
            min_score=min_score_eff,
            query_seqs=seqs,
            target_seqs=seqs,
            matrix=self.matrix,
        )
        upper_mask = seeds.query_indices < seeds.target_indices
        candidate_seeds = seeds.filter(upper_mask) if len(seeds) > 0 else seeds

        q_chunks: list[np.ndarray[tuple[int], np.dtype[np.int32]]] = []
        t_chunks: list[np.ndarray[tuple[int], np.dtype[np.int32]]] = []
        pident_chunks: list[np.ndarray[tuple[int], np.dtype[np.float32]]] = []
        bitscore_chunks: list[np.ndarray[tuple[int], np.dtype[np.float32]]] = []
        q_cov_chunks: list[np.ndarray[tuple[int], np.dtype[np.float64]]] = []
        t_cov_chunks: list[np.ndarray[tuple[int], np.dtype[np.float64]]] = []

        seeded_pairs: set[int] = set()
        if len(candidate_seeds) > 0:
            alignments = self.aligner.align_seeds(seqs, seqs, candidate_seeds)
            q_idx = candidate_seeds.query_indices.astype(np.int32)
            t_idx = candidate_seeds.target_indices.astype(np.int32)
            if roles_arr is not None:
                r_q = roles_arr[q_idx]
                r_t = roles_arr[t_idx]
                compat = (r_q == r_t) | (r_q == 29) | (r_t == 29) | (r_q < 0) | (r_t < 0) | (alignments.pidents >= 80.0)
            else:
                compat = np.ones(len(q_idx), dtype=np.bool_)

            if np.any(compat):
                q_idx = q_idx[compat]
                t_idx = t_idx[compat]
                p_sub = alignments.pidents[compat]
                b_sub = alignments.bitscores(self.matrix)[compat]
                q_lens = np.maximum(seqs.lengths[q_idx], 1)
                t_lens = np.maximum(seqs.lengths[t_idx], 1)
                qc_sub = (alignments.q_ends[compat] - alignments.q_starts[compat]) / q_lens
                tc_sub = (alignments.t_ends[compat] - alignments.t_starts[compat]) / t_lens
                q_chunks.append(q_idx)
                t_chunks.append(t_idx)
                pident_chunks.append(p_sub)
                bitscore_chunks.append(b_sub)
                q_cov_chunks.append(qc_sub)
                t_cov_chunks.append(tc_sub)

                # Only mark a seeded pair as resolved if the banded alignment actually passed thresholds;
                # spurious single-strobe collisions on off-diagonal offsets must not block Stage 1b unbanded SW.
                passed_seed = (p_sub >= eff_min_identity) & (b_sub >= eff_min_bitscore)
                if self.min_coverage > 0.0:
                    passed_seed &= (qc_sub >= self.min_coverage) & (tc_sub >= self.min_coverage)
                for u_i, v_i in zip(q_idx[passed_seed], t_idx[passed_seed], strict=False):
                    seeded_pairs.add(int(u_i) * n + int(v_i))

        # 1b. MMseqs2-Style Reduced-Alphabet + Ungapped BLOSUM62 Diagonal Prefilter & Unbanded SW for Divergent Homologs
        if sensitive_mode:
            min_cov_eff = max(float(self.min_coverage), 0.0)
            extra_q_parts: list[np.ndarray[tuple[int], np.dtype[np.int32]]] = []
            extra_t_parts: list[np.ndarray[tuple[int], np.dtype[np.int32]]] = []

            if roles_arr is not None:
                small_q_list: list[int] = []
                small_t_list: list[int] = []
                seq_lens = seqs.lengths
                for r_val in np.unique(roles_arr):
                    if r_val < 0 or r_val == 29:
                        continue
                    members_r = np.where(roles_arr == r_val)[0].astype(np.int32)
                    m_r = len(members_r)
                    if 2 <= m_r <= 250:
                        for a_i in range(m_r):
                            u_idx = int(members_r[a_i])
                            l_u = int(seq_lens[u_idx])
                            for b_i in range(a_i + 1, m_r):
                                v_idx = int(members_r[b_i])
                                l_v = int(seq_lens[v_idx])
                                if min_cov_eff > 0.0 and min(l_u, l_v) < min_cov_eff * max(l_u, l_v):
                                    continue
                                if (u_idx * n + v_idx) not in seeded_pairs:
                                    small_q_list.append(u_idx)
                                    small_t_list.append(v_idx)
                    elif m_r > 250:
                        sub_seqs = seqs[members_r]
                        sub_seeds = self.aligner.prefilter(
                            sub_seqs,
                            min_ungapped_score=42,
                            min_coverage=min_cov_eff,
                        )
                        if len(sub_seeds) > 0:
                            gq = members_r[sub_seeds.query_indices.astype(np.int32)]
                            gt = members_r[sub_seeds.target_indices.astype(np.int32)]
                            keep_m = np.array(
                                [
                                    (int(u_i) * n + int(v_i)) not in seeded_pairs
                                    for u_i, v_i in zip(gq, gt, strict=False)
                                ],
                                dtype=np.bool_,
                            )
                            if np.any(keep_m):
                                extra_q_parts.append(gq[keep_m])
                                extra_t_parts.append(gt[keep_m])
                        if emb_norm is not None:
                            sub_emb = emb_norm[members_r]
                            sim_mat = sub_emb @ sub_emb.T
                            eq_idx, et_idx = np.where(np.triu(sim_mat >= float(self.motif_rescue_threshold), k=1))
                            if len(eq_idx) > 0:
                                gq_e = members_r[eq_idx.astype(np.int32)]
                                gt_e = members_r[et_idx.astype(np.int32)]
                                keep_e = np.array(
                                    [
                                        (int(u_i) * n + int(v_i)) not in seeded_pairs
                                        for u_i, v_i in zip(gq_e, gt_e, strict=False)
                                    ],
                                    dtype=np.bool_,
                                )
                                if np.any(keep_e):
                                    extra_q_parts.append(gq_e[keep_e])
                                    extra_t_parts.append(gt_e[keep_e])

                if small_q_list:
                    sq_arr = np.array(small_q_list, dtype=np.int32)
                    st_arr = np.array(small_t_list, dtype=np.int32)
                    if len(sq_arr) > 250:
                        keep_u = _ungapped_pair_prefilter(
                            seqs.seqs,
                            seqs.offsets,
                            seqs.lengths,
                            sq_arr,
                            st_arr,
                            self.matrix.lut,
                            40,
                        )
                        if emb_norm is not None:
                            cos_small = np.sum(emb_norm[sq_arr] * emb_norm[st_arr], axis=1)
                            keep_u = keep_u | (cos_small >= float(self.motif_rescue_threshold))
                        sq_arr = sq_arr[keep_u]
                        st_arr = st_arr[keep_u]
                    if len(sq_arr) > 0:
                        extra_q_parts.append(sq_arr)
                        extra_t_parts.append(st_arr)
            else:
                cand_seeds = self.aligner.prefilter(
                    seqs,
                    min_ungapped_score=42,
                    min_coverage=min_cov_eff,
                )
                if len(cand_seeds) > 0:
                    cand_q = cand_seeds.query_indices.astype(np.int32)
                    cand_t = cand_seeds.target_indices.astype(np.int32)
                    if seeded_pairs:
                        keep_m = np.array(
                            [
                                (int(u_i) * n + int(v_i)) not in seeded_pairs
                                for u_i, v_i in zip(cand_q, cand_t, strict=False)
                            ],
                            dtype=np.bool_,
                        )
                        cand_q = cand_q[keep_m]
                        cand_t = cand_t[keep_m]
                    if len(cand_q) > 0:
                        extra_q_parts.append(cand_q)
                        extra_t_parts.append(cand_t)
                if emb_norm is not None and n <= 2000:
                    sim_mat = emb_norm @ emb_norm.T
                    eq_idx, et_idx = np.where(np.triu(sim_mat >= float(self.motif_rescue_threshold), k=1))
                    if len(eq_idx) > 0:
                        gq_e = eq_idx.astype(np.int32)
                        gt_e = et_idx.astype(np.int32)
                        keep_e = np.array(
                            [
                                (int(u_i) * n + int(v_i)) not in seeded_pairs
                                for u_i, v_i in zip(gq_e, gt_e, strict=False)
                            ],
                            dtype=np.bool_,
                        )
                        if np.any(keep_e):
                            extra_q_parts.append(gq_e[keep_e])
                            extra_t_parts.append(gt_e[keep_e])

            if extra_q_parts:
                extra_q = np.concatenate(extra_q_parts)
                extra_t = np.concatenate(extra_t_parts)
                if len(extra_q) > 1:
                    pair_keys = extra_q.astype(np.int64) * n + extra_t.astype(np.int64)
                    _, uniq_idx = np.unique(pair_keys, return_index=True)
                    extra_q = extra_q[uniq_idx]
                    extra_t = extra_t[uniq_idx]
                extra_aln = self.aligner(seqs[extra_q], seqs[extra_t], seeds=None)
                q_lens_e = np.maximum(seqs.lengths[extra_q], 1)
                t_lens_e = np.maximum(seqs.lengths[extra_t], 1)
                q_chunks.append(extra_q)
                t_chunks.append(extra_t)
                pident_chunks.append(extra_aln.pidents)
                bitscore_chunks.append(extra_aln.bitscores(self.matrix))
                q_cov_chunks.append((extra_aln.q_ends - extra_aln.q_starts) / q_lens_e)
                t_cov_chunks.append((extra_aln.t_ends - extra_aln.t_starts) / t_lens_e)

        if not q_chunks:
            idx_arr = np.arange(n, dtype=np.int32)
            return Clusters(
                representatives=idx_arr,
                members=idx_arr.copy(),
                offsets=idx_arr.copy(),
                lengths=np.ones(n, dtype=np.int32),
            )

        all_q = np.concatenate(q_chunks)
        all_t = np.concatenate(t_chunks)
        all_pidents = np.concatenate(pident_chunks)
        all_bitscores = np.concatenate(bitscore_chunks)
        all_q_cov = np.concatenate(q_cov_chunks)
        all_t_cov = np.concatenate(t_cov_chunks)

        # 3. Filter Matches by Identity, Bitscore, Coverage, and Role-Gated Motif Rescue
        std_valid = (all_pidents >= eff_min_identity) & (all_bitscores >= eff_min_bitscore)
        if self.min_coverage > 0.0:
            std_valid &= (all_q_cov >= self.min_coverage) & (all_t_cov >= self.min_coverage)

        if emb_norm is not None and len(all_q) > 0:
            cos_all = np.sum(emb_norm[all_q] * emb_norm[all_t], axis=1)
            if roles_arr is not None:
                rq_all = roles_arr[all_q]
                rt_all = roles_arr[all_t]
                same_role = (rq_all == rt_all) & (rq_all >= 0) & (rq_all != 29)
            else:
                same_role = np.ones(len(all_q), dtype=np.bool_)
            twilight_pident = max(15.0, eff_min_identity - 10.0)
            twilight_bitscore = max(22.0, eff_min_bitscore * 0.65)
            rescued = (
                same_role
                & (cos_all >= float(self.motif_rescue_threshold))
                & (all_pidents >= twilight_pident)
                & (all_bitscores >= twilight_bitscore)
            )
            if self.min_coverage > 0.0:
                rescued &= (all_q_cov >= self.min_coverage * 0.85) & (all_t_cov >= self.min_coverage * 0.85)
            valid = std_valid | rescued
        else:
            cos_all = None
            valid = std_valid

        q_valid = all_q[valid].astype(np.int32)
        t_valid = all_t[valid].astype(np.int32)
        p_valid = all_pidents[valid]
        b_valid = all_bitscores[valid].copy()

        if cos_all is not None and len(q_valid) > 0:
            eff_alpha = float(self.embedding_weight) if self.embedding_weight > 0.0 else 0.25
            cos_pos = np.maximum(cos_all[valid], np.float32(0.0))
            max_lens_v = np.maximum(np.maximum(seqs.lengths[q_valid], seqs.lengths[t_valid]), 1).astype(np.float32)
            b_valid = b_valid + np.float32(eff_alpha) * cos_pos * max_lens_v
            p_valid = np.maximum(p_valid, p_valid + np.float32(8.0 * eff_alpha) * cos_pos)

        # 4. Clustering Execution
        if self.mode == "cdhit":
            degree = np.zeros(n, dtype=np.int32)
            np.add.at(degree, q_valid, 1)
            np.add.at(degree, t_valid, 1)
            # Sort descending by sequence length, break ties by degree descending
            order = np.lexsort((-degree, -seqs.lengths)).astype(np.int32)
            c_ids, reps, sizes = _cdhit_cluster(n, order, q_valid, t_valid)

        elif self.mode == "mcl":
            c_ids, reps, sizes = _mcl_cluster(
                n,
                seqs.lengths,
                q_valid,
                t_valid,
                b_valid,
                inflation=self.inflation,
            )

        elif self.mode == "greedy_cover":
            c_ids, reps, sizes = _greedy_cover_cluster(n, q_valid, t_valid)

        elif self.mode == "upgma":
            c_ids, reps, sizes = _upgma_cluster(n, seqs.lengths, q_valid, t_valid, p_valid, eff_min_identity)

        elif self.mode == "connected_components":
            c_ids, reps, sizes = _connected_components_cluster(n, seqs.lengths, q_valid, t_valid)

        else:
            raise RuntimeError(f"Unhandled mode: {self.mode}")

        return Clusters.from_assignments(c_ids, reps, sizes)

aligner property

aligner: PairwiseAligner

Return a configured PairwiseAligner instance.

cluster

cluster(
    seqs: Sequences,
    roles: ndarray[tuple[int], dtype[Any]]
    | Sequence[int]
    | None = None,
    embeddings: ndarray[tuple[int, int], dtype[Any]]
    | None = None,
) -> Clusters

Cluster an input batch of sequences into non-redundant representative groups.

Parameters:

Name Type Description Default
seqs Sequences

Input collection of sequences to cluster.

required
roles ndarray[tuple[int], dtype[Any]] | Sequence[int] | None

Optional 1D array/sequence of integer GeneRole IDs (length \(N\)). When provided, prevents clustering across incompatible known roles (role > 0) and, in "mcl" mode (or when min_identity < 60.0), evaluates all intra-role pairs so divergent functional homologs sharing the same role are aligned even without shared exact k-mer seeds.

None
embeddings ndarray[tuple[int, int], dtype[Any]] | None

Optional 2D array (N, D) of sequence representations (e.g., active-site conv_max or task_weighted features from GeneRoleClassifier.transform()). When provided, enables role-gated twilight-zone homology rescue for pairs with cosine similarity >= self.motif_rescue_threshold and blends cosine similarity into MCL edge weights.

None

Returns:

Name Type Description
Clusters Clusters

Ragged SoA container of cluster assignments and centroid representatives.

Source code in src/seroverse/core/seq/clustering.py
def cluster(
    self,
    seqs: Sequences,
    roles: np.ndarray[tuple[int], np.dtype[Any]] | Sequence[int] | None = None,
    embeddings: np.ndarray[tuple[int, int], np.dtype[Any]] | None = None,
) -> Clusters:
    r"""Cluster an input batch of sequences into non-redundant representative groups.

    Args:
        seqs: Input collection of sequences to cluster.
        roles: Optional 1D array/sequence of integer `GeneRole` IDs (length $N$). When provided,
            prevents clustering across incompatible known roles (`role > 0`) and, in `"mcl"` mode
            (or when `min_identity < 60.0`), evaluates all intra-role pairs so divergent functional
            homologs sharing the same role are aligned even without shared exact k-mer seeds.
        embeddings: Optional 2D array `(N, D)` of sequence representations (e.g., active-site
            `conv_max` or `task_weighted` features from `GeneRoleClassifier.transform()`). When
            provided, enables role-gated twilight-zone homology rescue for pairs with cosine
            similarity `>= self.motif_rescue_threshold` and blends cosine similarity into MCL
            edge weights.

    Returns:
        Clusters: Ragged SoA container of cluster assignments and centroid representatives.
    """
    n = len(seqs)
    if n == 0:
        return Clusters.empty()

    if n == 1:
        return Clusters(
            representatives=np.zeros(1, dtype=np.int32),
            members=np.zeros(1, dtype=np.int32),
            offsets=np.zeros(1, dtype=np.int32),
            lengths=np.ones(1, dtype=np.int32),
        )

    roles_arr: np.ndarray[tuple[int], np.dtype[np.int32]] | None = None
    if roles is not None:
        roles_arr = np.asarray(roles, dtype=np.int32)
        if len(roles_arr) != n:
            raise ValueError(f"roles length ({len(roles_arr)}) must match seqs length ({n}).")

    emb_norm: np.ndarray[tuple[int, int], np.dtype[np.float32]] | None = None
    if embeddings is not None:
        emb_arr = np.asarray(embeddings, dtype=np.float32)
        if emb_arr.ndim != 2 or emb_arr.shape[0] != n:
            raise ValueError(f"embeddings shape {emb_arr.shape} must be 2D with first dimension {n}.")
        norms = np.maximum(np.linalg.norm(emb_arr, axis=1, keepdims=True), np.float32(1e-8))
        emb_norm = np.ascontiguousarray(emb_arr / norms, dtype=np.float32)

    eff_min_identity = 25.0 if (self.mode == "mcl" and self.min_identity == 80.0) else float(self.min_identity)
    eff_min_bitscore = 40.0 if (self.mode == "mcl" and self.min_bitscore <= 0.0) else float(self.min_bitscore)
    sensitive_mode = self.mode == "mcl" or eff_min_identity < 60.0 or emb_norm is not None

    # 1. Sparse Candidate Pre-Filtering via RandstrobeIndex
    k_eff = min(self.k, 5) if sensitive_mode else self.k
    s_eff = min(self.s, 2) if sensitive_mode else self.s
    min_score_eff = 1 if sensitive_mode else self.min_score

    target_idx = RandstrobeIndex.build(seqs, k=k_eff, s=s_eff, sort_by_hash=True)
    query_idx = RandstrobeIndex.build(seqs, k=k_eff, s=s_eff, sort_by_hash=False)

    seeds = target_idx.hits(
        query_idx,
        min_score=min_score_eff,
        query_seqs=seqs,
        target_seqs=seqs,
        matrix=self.matrix,
    )
    upper_mask = seeds.query_indices < seeds.target_indices
    candidate_seeds = seeds.filter(upper_mask) if len(seeds) > 0 else seeds

    q_chunks: list[np.ndarray[tuple[int], np.dtype[np.int32]]] = []
    t_chunks: list[np.ndarray[tuple[int], np.dtype[np.int32]]] = []
    pident_chunks: list[np.ndarray[tuple[int], np.dtype[np.float32]]] = []
    bitscore_chunks: list[np.ndarray[tuple[int], np.dtype[np.float32]]] = []
    q_cov_chunks: list[np.ndarray[tuple[int], np.dtype[np.float64]]] = []
    t_cov_chunks: list[np.ndarray[tuple[int], np.dtype[np.float64]]] = []

    seeded_pairs: set[int] = set()
    if len(candidate_seeds) > 0:
        alignments = self.aligner.align_seeds(seqs, seqs, candidate_seeds)
        q_idx = candidate_seeds.query_indices.astype(np.int32)
        t_idx = candidate_seeds.target_indices.astype(np.int32)
        if roles_arr is not None:
            r_q = roles_arr[q_idx]
            r_t = roles_arr[t_idx]
            compat = (r_q == r_t) | (r_q == 29) | (r_t == 29) | (r_q < 0) | (r_t < 0) | (alignments.pidents >= 80.0)
        else:
            compat = np.ones(len(q_idx), dtype=np.bool_)

        if np.any(compat):
            q_idx = q_idx[compat]
            t_idx = t_idx[compat]
            p_sub = alignments.pidents[compat]
            b_sub = alignments.bitscores(self.matrix)[compat]
            q_lens = np.maximum(seqs.lengths[q_idx], 1)
            t_lens = np.maximum(seqs.lengths[t_idx], 1)
            qc_sub = (alignments.q_ends[compat] - alignments.q_starts[compat]) / q_lens
            tc_sub = (alignments.t_ends[compat] - alignments.t_starts[compat]) / t_lens
            q_chunks.append(q_idx)
            t_chunks.append(t_idx)
            pident_chunks.append(p_sub)
            bitscore_chunks.append(b_sub)
            q_cov_chunks.append(qc_sub)
            t_cov_chunks.append(tc_sub)

            # Only mark a seeded pair as resolved if the banded alignment actually passed thresholds;
            # spurious single-strobe collisions on off-diagonal offsets must not block Stage 1b unbanded SW.
            passed_seed = (p_sub >= eff_min_identity) & (b_sub >= eff_min_bitscore)
            if self.min_coverage > 0.0:
                passed_seed &= (qc_sub >= self.min_coverage) & (tc_sub >= self.min_coverage)
            for u_i, v_i in zip(q_idx[passed_seed], t_idx[passed_seed], strict=False):
                seeded_pairs.add(int(u_i) * n + int(v_i))

    # 1b. MMseqs2-Style Reduced-Alphabet + Ungapped BLOSUM62 Diagonal Prefilter & Unbanded SW for Divergent Homologs
    if sensitive_mode:
        min_cov_eff = max(float(self.min_coverage), 0.0)
        extra_q_parts: list[np.ndarray[tuple[int], np.dtype[np.int32]]] = []
        extra_t_parts: list[np.ndarray[tuple[int], np.dtype[np.int32]]] = []

        if roles_arr is not None:
            small_q_list: list[int] = []
            small_t_list: list[int] = []
            seq_lens = seqs.lengths
            for r_val in np.unique(roles_arr):
                if r_val < 0 or r_val == 29:
                    continue
                members_r = np.where(roles_arr == r_val)[0].astype(np.int32)
                m_r = len(members_r)
                if 2 <= m_r <= 250:
                    for a_i in range(m_r):
                        u_idx = int(members_r[a_i])
                        l_u = int(seq_lens[u_idx])
                        for b_i in range(a_i + 1, m_r):
                            v_idx = int(members_r[b_i])
                            l_v = int(seq_lens[v_idx])
                            if min_cov_eff > 0.0 and min(l_u, l_v) < min_cov_eff * max(l_u, l_v):
                                continue
                            if (u_idx * n + v_idx) not in seeded_pairs:
                                small_q_list.append(u_idx)
                                small_t_list.append(v_idx)
                elif m_r > 250:
                    sub_seqs = seqs[members_r]
                    sub_seeds = self.aligner.prefilter(
                        sub_seqs,
                        min_ungapped_score=42,
                        min_coverage=min_cov_eff,
                    )
                    if len(sub_seeds) > 0:
                        gq = members_r[sub_seeds.query_indices.astype(np.int32)]
                        gt = members_r[sub_seeds.target_indices.astype(np.int32)]
                        keep_m = np.array(
                            [
                                (int(u_i) * n + int(v_i)) not in seeded_pairs
                                for u_i, v_i in zip(gq, gt, strict=False)
                            ],
                            dtype=np.bool_,
                        )
                        if np.any(keep_m):
                            extra_q_parts.append(gq[keep_m])
                            extra_t_parts.append(gt[keep_m])
                    if emb_norm is not None:
                        sub_emb = emb_norm[members_r]
                        sim_mat = sub_emb @ sub_emb.T
                        eq_idx, et_idx = np.where(np.triu(sim_mat >= float(self.motif_rescue_threshold), k=1))
                        if len(eq_idx) > 0:
                            gq_e = members_r[eq_idx.astype(np.int32)]
                            gt_e = members_r[et_idx.astype(np.int32)]
                            keep_e = np.array(
                                [
                                    (int(u_i) * n + int(v_i)) not in seeded_pairs
                                    for u_i, v_i in zip(gq_e, gt_e, strict=False)
                                ],
                                dtype=np.bool_,
                            )
                            if np.any(keep_e):
                                extra_q_parts.append(gq_e[keep_e])
                                extra_t_parts.append(gt_e[keep_e])

            if small_q_list:
                sq_arr = np.array(small_q_list, dtype=np.int32)
                st_arr = np.array(small_t_list, dtype=np.int32)
                if len(sq_arr) > 250:
                    keep_u = _ungapped_pair_prefilter(
                        seqs.seqs,
                        seqs.offsets,
                        seqs.lengths,
                        sq_arr,
                        st_arr,
                        self.matrix.lut,
                        40,
                    )
                    if emb_norm is not None:
                        cos_small = np.sum(emb_norm[sq_arr] * emb_norm[st_arr], axis=1)
                        keep_u = keep_u | (cos_small >= float(self.motif_rescue_threshold))
                    sq_arr = sq_arr[keep_u]
                    st_arr = st_arr[keep_u]
                if len(sq_arr) > 0:
                    extra_q_parts.append(sq_arr)
                    extra_t_parts.append(st_arr)
        else:
            cand_seeds = self.aligner.prefilter(
                seqs,
                min_ungapped_score=42,
                min_coverage=min_cov_eff,
            )
            if len(cand_seeds) > 0:
                cand_q = cand_seeds.query_indices.astype(np.int32)
                cand_t = cand_seeds.target_indices.astype(np.int32)
                if seeded_pairs:
                    keep_m = np.array(
                        [
                            (int(u_i) * n + int(v_i)) not in seeded_pairs
                            for u_i, v_i in zip(cand_q, cand_t, strict=False)
                        ],
                        dtype=np.bool_,
                    )
                    cand_q = cand_q[keep_m]
                    cand_t = cand_t[keep_m]
                if len(cand_q) > 0:
                    extra_q_parts.append(cand_q)
                    extra_t_parts.append(cand_t)
            if emb_norm is not None and n <= 2000:
                sim_mat = emb_norm @ emb_norm.T
                eq_idx, et_idx = np.where(np.triu(sim_mat >= float(self.motif_rescue_threshold), k=1))
                if len(eq_idx) > 0:
                    gq_e = eq_idx.astype(np.int32)
                    gt_e = et_idx.astype(np.int32)
                    keep_e = np.array(
                        [
                            (int(u_i) * n + int(v_i)) not in seeded_pairs
                            for u_i, v_i in zip(gq_e, gt_e, strict=False)
                        ],
                        dtype=np.bool_,
                    )
                    if np.any(keep_e):
                        extra_q_parts.append(gq_e[keep_e])
                        extra_t_parts.append(gt_e[keep_e])

        if extra_q_parts:
            extra_q = np.concatenate(extra_q_parts)
            extra_t = np.concatenate(extra_t_parts)
            if len(extra_q) > 1:
                pair_keys = extra_q.astype(np.int64) * n + extra_t.astype(np.int64)
                _, uniq_idx = np.unique(pair_keys, return_index=True)
                extra_q = extra_q[uniq_idx]
                extra_t = extra_t[uniq_idx]
            extra_aln = self.aligner(seqs[extra_q], seqs[extra_t], seeds=None)
            q_lens_e = np.maximum(seqs.lengths[extra_q], 1)
            t_lens_e = np.maximum(seqs.lengths[extra_t], 1)
            q_chunks.append(extra_q)
            t_chunks.append(extra_t)
            pident_chunks.append(extra_aln.pidents)
            bitscore_chunks.append(extra_aln.bitscores(self.matrix))
            q_cov_chunks.append((extra_aln.q_ends - extra_aln.q_starts) / q_lens_e)
            t_cov_chunks.append((extra_aln.t_ends - extra_aln.t_starts) / t_lens_e)

    if not q_chunks:
        idx_arr = np.arange(n, dtype=np.int32)
        return Clusters(
            representatives=idx_arr,
            members=idx_arr.copy(),
            offsets=idx_arr.copy(),
            lengths=np.ones(n, dtype=np.int32),
        )

    all_q = np.concatenate(q_chunks)
    all_t = np.concatenate(t_chunks)
    all_pidents = np.concatenate(pident_chunks)
    all_bitscores = np.concatenate(bitscore_chunks)
    all_q_cov = np.concatenate(q_cov_chunks)
    all_t_cov = np.concatenate(t_cov_chunks)

    # 3. Filter Matches by Identity, Bitscore, Coverage, and Role-Gated Motif Rescue
    std_valid = (all_pidents >= eff_min_identity) & (all_bitscores >= eff_min_bitscore)
    if self.min_coverage > 0.0:
        std_valid &= (all_q_cov >= self.min_coverage) & (all_t_cov >= self.min_coverage)

    if emb_norm is not None and len(all_q) > 0:
        cos_all = np.sum(emb_norm[all_q] * emb_norm[all_t], axis=1)
        if roles_arr is not None:
            rq_all = roles_arr[all_q]
            rt_all = roles_arr[all_t]
            same_role = (rq_all == rt_all) & (rq_all >= 0) & (rq_all != 29)
        else:
            same_role = np.ones(len(all_q), dtype=np.bool_)
        twilight_pident = max(15.0, eff_min_identity - 10.0)
        twilight_bitscore = max(22.0, eff_min_bitscore * 0.65)
        rescued = (
            same_role
            & (cos_all >= float(self.motif_rescue_threshold))
            & (all_pidents >= twilight_pident)
            & (all_bitscores >= twilight_bitscore)
        )
        if self.min_coverage > 0.0:
            rescued &= (all_q_cov >= self.min_coverage * 0.85) & (all_t_cov >= self.min_coverage * 0.85)
        valid = std_valid | rescued
    else:
        cos_all = None
        valid = std_valid

    q_valid = all_q[valid].astype(np.int32)
    t_valid = all_t[valid].astype(np.int32)
    p_valid = all_pidents[valid]
    b_valid = all_bitscores[valid].copy()

    if cos_all is not None and len(q_valid) > 0:
        eff_alpha = float(self.embedding_weight) if self.embedding_weight > 0.0 else 0.25
        cos_pos = np.maximum(cos_all[valid], np.float32(0.0))
        max_lens_v = np.maximum(np.maximum(seqs.lengths[q_valid], seqs.lengths[t_valid]), 1).astype(np.float32)
        b_valid = b_valid + np.float32(eff_alpha) * cos_pos * max_lens_v
        p_valid = np.maximum(p_valid, p_valid + np.float32(8.0 * eff_alpha) * cos_pos)

    # 4. Clustering Execution
    if self.mode == "cdhit":
        degree = np.zeros(n, dtype=np.int32)
        np.add.at(degree, q_valid, 1)
        np.add.at(degree, t_valid, 1)
        # Sort descending by sequence length, break ties by degree descending
        order = np.lexsort((-degree, -seqs.lengths)).astype(np.int32)
        c_ids, reps, sizes = _cdhit_cluster(n, order, q_valid, t_valid)

    elif self.mode == "mcl":
        c_ids, reps, sizes = _mcl_cluster(
            n,
            seqs.lengths,
            q_valid,
            t_valid,
            b_valid,
            inflation=self.inflation,
        )

    elif self.mode == "greedy_cover":
        c_ids, reps, sizes = _greedy_cover_cluster(n, q_valid, t_valid)

    elif self.mode == "upgma":
        c_ids, reps, sizes = _upgma_cluster(n, seqs.lengths, q_valid, t_valid, p_valid, eff_min_identity)

    elif self.mode == "connected_components":
        c_ids, reps, sizes = _connected_components_cluster(n, seqs.lengths, q_valid, t_valid)

    else:
        raise RuntimeError(f"Unhandled mode: {self.mode}")

    return Clusters.from_assignments(c_ids, reps, sizes)

Graph Models, CSR Matrices, Alignment & Layout (seroverse.core.graph)

csr

Compressed Sparse Row (CSR) and multi-relational CSR (RelationalCSR) graph matrices.

Provides zero-SciPy, Numba-accelerated sparse adjacency representations: - CSR: Base 2D Compressed Sparse Row matrix supporting fast COO/dense construction, transpose (.T), SpMV/SpMM (@), sparse addition (+), submatrix slicing, symmetric normalized Laplacian (.laplacian), and connected components (.connected_components). - RelationalCSR: Subclass of CSR storing per-edge relation type IDs (rel_types) and virtual pool node indices (virtual_nodes) for multi-relational message passing.

Classes:

Name Description
CSR

Immutable 2D Compressed Sparse Row (CSR) matrix backed by contiguous NumPy arrays and Numba kernels.

RelationalCSR

Immutable Compressed Sparse Row (CSR) incoming-edge representation of a multi-relational directed graph.

CSR dataclass

Immutable 2D Compressed Sparse Row (CSR) matrix backed by contiguous NumPy arrays and Numba kernels.

Supports direct attribute access (indptr, indices, data, shape) and standard sparse matrix operations (@, +, .T, .sum(), .toarray(), .tocoo(), .laplacian(), .connected_components(), .with_diag(), .without_zeros(), submatrix slicing) without requiring scipy.sparse.

Attributes:

Name Type Description
indptr ndarray[tuple[int], dtype[int32]]

1D int32 row pointer array of shape (n_rows + 1,).

indices ndarray[tuple[int], dtype[int32]]

1D int32 column index array of shape (nnz,).

data ndarray[tuple[int], Any]

1D non-zero value array of shape (nnz,).

shape tuple[int, int]

2-tuple (n_rows, n_cols) giving matrix dimensions.

Methods:

Name Description
empty

Create an empty CSR matrix of shape (n_rows, n_cols) with zero non-zeros.

from_coo

Construct a CSR matrix from coordinate (rows, cols, data) arrays.

from_dense

Construct a CSR matrix from a 2D dense NumPy array.

copy

Return a deep copy of this CSR matrix.

astype

Cast self.data to dtype and return a CSR matrix.

tocsr

Return self (compatibility with scipy.sparse).

tocsc

Return self.T whose .indptr indexes columns (compatibility with scipy.sparse).

tocoo

Return self, which exposes .row, .col, and .data directly.

toarray

Convert this sparse CSR matrix into a 2D dense NumPy array.

with_diag

Return a new CSR matrix with diagonal entries (i, i) set to values (or removed when values == 0).

without_zeros

Return a new CSR matrix with zero-valued entries removed from indices and data.

sum

Sum matrix elements over the given axis (None, 0, or 1).

laplacian

Compute the dense (normalized or unnormalized) graph Laplacian matrix \(L\) in a single Numba pass.

connected_components

Compute undirected connected components (n_components, labels) in \(O(N + \text{nnz})\) time.

connected_components_from_edges

Compute undirected connected components (n_components, labels) directly from flat edge arrays in \(O(N + E)\) time.

extract_undirected_edges

Symmetrize (A + A.T) and extract unique upper-triangular undirected edges (u < v, w).

Source code in src/seroverse/core/graph/csr.py
@dataclass(slots=True, frozen=True)
class CSR:
    r"""Immutable 2D Compressed Sparse Row (`CSR`) matrix backed by contiguous NumPy arrays and Numba kernels.

    Supports direct attribute access (`indptr`, `indices`, `data`, `shape`) and standard sparse
    matrix operations (`@`, `+`, `.T`, `.sum()`, `.toarray()`, `.tocoo()`, `.laplacian()`,
    `.connected_components()`, `.with_diag()`, `.without_zeros()`, submatrix slicing) without
    requiring `scipy.sparse`.

    Attributes:
        indptr: 1D `int32` row pointer array of shape `(n_rows + 1,)`.
        indices: 1D `int32` column index array of shape `(nnz,)`.
        data: 1D non-zero value array of shape `(nnz,)`.
        shape: 2-tuple `(n_rows, n_cols)` giving matrix dimensions.
    """

    indptr: np.ndarray[tuple[int], np.dtype[np.int32]]
    indices: np.ndarray[tuple[int], np.dtype[np.int32]]
    data: np.ndarray[tuple[int], Any]
    shape: tuple[int, int]

    def __post_init__(self) -> None:
        r"""Normalize CSR arrays to contiguous `int32` index arrays and validated 2-tuple `shape`."""
        object.__setattr__(self, "indptr", np.ascontiguousarray(self.indptr, dtype=np.int32))
        object.__setattr__(self, "indices", np.ascontiguousarray(self.indices, dtype=np.int32))
        object.__setattr__(self, "data", np.ascontiguousarray(self.data))
        object.__setattr__(self, "shape", (int(self.shape[0]), int(self.shape[1])))

    # Classmethod Constructors -----------------------------------------------------------------------------------------
    @classmethod
    def empty(cls, shape: tuple[int, int], dtype: Any = np.float64) -> Self:
        r"""Create an empty `CSR` matrix of shape `(n_rows, n_cols)` with zero non-zeros."""
        nr, nc = int(shape[0]), int(shape[1])
        return cls(
            indptr=np.zeros(nr + 1, dtype=np.int32),
            indices=np.empty(0, dtype=np.int32),
            data=np.empty(0, dtype=dtype),
            shape=(nr, nc),
        )

    @classmethod
    def from_coo(
        cls,
        rows: Sequence[int] | np.ndarray,
        cols: Sequence[int] | np.ndarray,
        data: Sequence[Any] | np.ndarray | None = None,
        shape: tuple[int, int] | None = None,
        dtype: Any = None,
        *,
        sum_duplicates: bool = True,
    ) -> Self:
        r"""Construct a `CSR` matrix from coordinate `(rows, cols, data)` arrays."""
        r_arr = np.asarray(rows, dtype=np.int32)
        c_arr = np.asarray(cols, dtype=np.int32)
        if len(r_arr) != len(c_arr):
            raise ValueError(f"rows length ({len(r_arr)}) != cols length ({len(c_arr)}).")

        if data is None:
            dt = np.float64 if dtype is None else dtype
            d_arr = np.ones(len(r_arr), dtype=dt)
        else:
            d_arr = np.asarray(data, dtype=dtype) if dtype is not None else np.asarray(data)
            if len(d_arr) != len(r_arr):
                raise ValueError(f"data length ({len(d_arr)}) != rows length ({len(r_arr)}).")

        if shape is None:
            nr = int(np.max(r_arr)) + 1 if len(r_arr) > 0 else 0
            nc = int(np.max(c_arr)) + 1 if len(c_arr) > 0 else 0
        else:
            nr, nc = int(shape[0]), int(shape[1])

        if len(r_arr) == 0 or nr == 0 or nc == 0:
            return cls.empty((nr, nc), dtype=d_arr.dtype)

        keys = (r_arr.astype(np.int64) << 32) | (c_arr.astype(np.int64) & 0xFFFFFFFF)
        order = np.argsort(keys, kind="mergesort")
        r_sorted = np.ascontiguousarray(r_arr[order], dtype=np.int32)
        c_sorted = np.ascontiguousarray(c_arr[order], dtype=np.int32)
        d_sorted = np.ascontiguousarray(d_arr[order])

        indptr, indices, out_data = _compress_sorted_coo_kernel(
            r_sorted,
            c_sorted,
            d_sorted,
            nr,
            sum_duplicates,
        )
        return cls(indptr=indptr, indices=indices, data=out_data, shape=(nr, nc))

    @classmethod
    def from_dense(cls, arr: np.ndarray) -> Self:
        r"""Construct a `CSR` matrix from a 2D dense NumPy array."""
        mat = np.asarray(arr)
        if mat.ndim != 2:
            raise ValueError(f"Expected 2D array for CSR.from_dense, got shape {mat.shape}.")
        rows, cols = np.nonzero(mat)
        if len(rows) == 0:
            return cls.empty((int(mat.shape[0]), int(mat.shape[1])), dtype=mat.dtype)
        vals = mat[rows, cols]
        return cls.from_coo(rows, cols, vals, shape=(int(mat.shape[0]), int(mat.shape[1])), sum_duplicates=False)

    # Properties -------------------------------------------------------------------------------------------------------
    @property
    def ndim(self) -> int:
        r"""Number of matrix dimensions (always `2`)."""
        return 2

    @property
    def nnz(self) -> int:
        r"""Total number of stored elements."""
        return len(self.data)

    @property
    def dtype(self) -> np.dtype[Any]:
        r"""Data type of stored matrix values."""
        return self.data.dtype

    @property
    def row(self) -> np.ndarray[tuple[int], np.dtype[np.int32]]:
        r"""1D `int32` array of row indices for each stored entry (COO compatibility)."""
        if self.nnz == 0:
            return np.empty(0, dtype=np.int32)
        return np.repeat(np.arange(self.shape[0], dtype=np.int32), np.diff(self.indptr))

    @property
    def col(self) -> np.ndarray[tuple[int], np.dtype[np.int32]]:
        r"""1D `int32` array of column indices for each stored entry (COO compatibility)."""
        return self.indices

    @property
    def T(self) -> CSR:
        r"""Return the transpose $(N_{\text{cols}} \times N_{\text{rows}})$ as a `CSR` matrix."""
        nr, nc = self.shape
        if self.nnz == 0:
            return CSR.empty((nc, nr), dtype=self.dtype)
        t_indptr, t_indices, t_data = _csr_transpose_kernel(self.indptr, self.indices, self.data, nr, nc)
        return CSR(indptr=t_indptr, indices=t_indices, data=t_data, shape=(nc, nr))

    # Format & Copy Conversions ----------------------------------------------------------------------------------------
    def copy(self) -> Self:
        r"""Return a deep copy of this `CSR` matrix."""
        return self.__class__(
            indptr=self.indptr.copy(),
            indices=self.indices.copy(),
            data=self.data.copy(),
            shape=self.shape,
        )

    def astype(self, dtype: Any, copy: bool = True) -> CSR:
        r"""Cast `self.data` to `dtype` and return a `CSR` matrix."""
        if not copy and self.data.dtype == np.dtype(dtype):
            return self
        return CSR(
            indptr=self.indptr.copy() if copy else self.indptr,
            indices=self.indices.copy() if copy else self.indices,
            data=self.data.astype(dtype, copy=copy),
            shape=self.shape,
        )

    def tocsr(self) -> CSR:
        r"""Return `self` (compatibility with `scipy.sparse`)."""
        return self

    def tocsc(self) -> CSR:
        r"""Return `self.T` whose `.indptr` indexes columns (compatibility with `scipy.sparse`)."""
        return self.T

    def tocoo(self) -> CSR:
        r"""Return `self`, which exposes `.row`, `.col`, and `.data` directly."""
        return self

    def toarray(self) -> np.ndarray:
        r"""Convert this sparse `CSR` matrix into a 2D dense NumPy array."""
        nr, nc = self.shape
        if nr == 0 or nc == 0:
            return np.zeros((nr, nc), dtype=self.dtype)
        return _csr_to_dense_kernel(self.indptr, self.indices, self.data, nr, nc)

    # Functional Structural Transformations ----------------------------------------------------------------------------
    def with_diag(self, values: float = 0) -> CSR:
        r"""Return a new `CSR` matrix with diagonal entries `(i, i)` set to `values` (or removed when `values == 0`)."""
        if values == 0:
            if self.nnz == 0:
                return self
            rows = self.row
            keep = rows != self.indices
            if bool(np.all(keep)):
                return self
            return CSR.from_coo(rows[keep], self.indices[keep], self.data[keep], shape=self.shape, sum_duplicates=False)
        n_diag = min(self.shape[0], self.shape[1])
        diag_idx = np.arange(n_diag, dtype=np.int32)
        rows = self.row
        non_diag = rows != self.indices
        all_rows = np.concatenate([rows[non_diag], diag_idx])
        all_cols = np.concatenate([self.indices[non_diag], diag_idx])
        all_vals = np.concatenate([self.data[non_diag], np.full(n_diag, values, dtype=self.dtype)])
        return CSR.from_coo(all_rows, all_cols, all_vals, shape=self.shape, sum_duplicates=False)

    def without_zeros(self) -> CSR:
        r"""Return a new `CSR` matrix with zero-valued entries removed from `indices` and `data`."""
        if self.nnz == 0:
            return self
        nz = self.data != 0
        if bool(np.all(nz)):
            return self
        rows = self.row[nz]
        return CSR.from_coo(rows, self.indices[nz], self.data[nz], shape=self.shape, sum_duplicates=False)

    # Arithmetic & Reductions ------------------------------------------------------------------------------------------
    def sum(self, axis: int | None = None) -> np.ndarray | float | int:
        r"""Sum matrix elements over the given axis (`None`, `0`, or `1`)."""
        if axis is None:
            return self.data.sum()
        if axis == 1 or axis == -1:
            return _csr_row_sum_kernel(self.indptr, self.data, self.shape[0])
        if axis == 0 or axis == -2:
            return _csr_col_sum_kernel(self.indices, self.data, self.shape[1])
        raise ValueError(f"axis must be None, 0, or 1; got {axis}.")

    def __add__(self, other: CSR) -> CSR:
        r"""Sparse matrix addition `self + other`."""
        if not isinstance(other, CSR):
            return NotImplemented
        if self.shape != other.shape:
            raise ValueError(f"Shape mismatch in CSR addition: {self.shape} vs {other.shape}.")
        if self.nnz == 0:
            return other.copy()
        if other.nnz == 0:
            return self.copy()
        res_dtype = np.result_type(self.dtype, other.dtype)
        rows = np.concatenate([self.row, other.row])
        cols = np.concatenate([self.indices, other.indices])
        vals = np.concatenate([self.data.astype(res_dtype, copy=False), other.data.astype(res_dtype, copy=False)])
        return CSR.from_coo(rows, cols, vals, shape=self.shape, sum_duplicates=True)

    @overload
    def __matmul__(self, other: np.ndarray) -> np.ndarray: ...

    @overload
    def __matmul__(self, other: CSR) -> CSR: ...

    def __matmul__(self, other: np.ndarray | CSR) -> np.ndarray | CSR:
        r"""Multiply this `CSR` matrix by a 1D/2D dense NumPy array or another `CSR` matrix."""
        if isinstance(other, np.ndarray):
            if other.ndim == 1:
                if len(other) != self.shape[1]:
                    raise ValueError(f"Dimension mismatch in CSR @ 1D array: {self.shape} vs {other.shape}.")
                return _csr_matmul_1d_kernel(self.indptr, self.indices, self.data, other, self.shape[0])
            if other.ndim == 2:
                if other.shape[0] != self.shape[1]:
                    raise ValueError(f"Dimension mismatch in CSR @ 2D array: {self.shape} vs {other.shape}.")
                return _csr_matmul_2d_kernel(self.indptr, self.indices, self.data, other, self.shape[0])
            raise ValueError(f"CSR @ ndarray only supports 1D or 2D arrays; got {other.ndim}D.")
        if isinstance(other, CSR):
            return CSR.from_dense(self.toarray() @ other.toarray())
        return NotImplemented

    def __getitem__(self, item: Any) -> Any:
        r"""Support scalar lookup `csr[i, j]` and 2D submatrix slicing `csr[rows, cols]`."""
        if not isinstance(item, tuple) or len(item) != 2:
            raise IndexError("CSR indexing requires a 2-tuple (row_idx, col_idx).")
        r_idx, c_idx = item
        nr, nc = self.shape

        if isinstance(r_idx, (int, np.integer)) and isinstance(c_idx, (int, np.integer)):
            r = int(r_idx)
            c = int(c_idx)
            if r < 0:
                r += nr
            if c < 0:
                c += nc
            if r < 0 or r >= nr or c < 0 or c >= nc:
                raise IndexError(f"CSR index ({r_idx}, {c_idx}) out of bounds for shape {self.shape}.")
            row_cols = self.indices[self.indptr[r] : self.indptr[r + 1]]
            row_vals = self.data[self.indptr[r] : self.indptr[r + 1]]
            matches = row_cols == c
            return row_vals[matches].sum() if np.any(matches) else self.dtype.type(0)

        def _resolve_axis(sel: Any, n: int) -> np.ndarray:
            if isinstance(sel, slice):
                return np.arange(*sel.indices(n), dtype=np.int32)
            arr = np.asarray(sel)
            if arr.dtype == np.bool_:
                return np.nonzero(arr)[0].astype(np.int32)
            return arr.astype(np.int32, copy=False).ravel()

        sel_r = _resolve_axis(r_idx, nr)
        sel_c = _resolve_axis(c_idx, nc)
        out_nr, out_nc = len(sel_r), len(sel_c)
        if out_nr == 0 or out_nc == 0 or self.nnz == 0:
            return CSR.empty((out_nr, out_nc), dtype=self.dtype)

        r_map = np.full(nr, -1, dtype=np.int32)
        r_map[sel_r] = np.arange(out_nr, dtype=np.int32)
        c_map = np.full(nc, -1, dtype=np.int32)
        c_map[sel_c] = np.arange(out_nc, dtype=np.int32)

        new_r = r_map[self.row]
        new_c = c_map[self.indices]
        keep = (new_r >= 0) & (new_c >= 0)
        if not np.any(keep):
            return CSR.empty((out_nr, out_nc), dtype=self.dtype)
        return CSR.from_coo(
            new_r[keep],
            new_c[keep],
            self.data[keep],
            shape=(out_nr, out_nc),
            sum_duplicates=True,
        )

    # Graph Algorithms on CSR ------------------------------------------------------------------------------------------
    def laplacian(
        self,
        *,
        normed: bool = True,
        symmetrize_unweighted: bool = False,
    ) -> np.ndarray[tuple[int, int], np.dtype[np.float64]]:
        r"""Compute the dense (normalized or unnormalized) graph Laplacian matrix $L$ in a single Numba pass."""
        if self.shape[0] != self.shape[1]:
            raise ValueError(f"Graph Laplacian requires a square CSR matrix, got shape {self.shape}.")
        return _csr_laplacian_dense_kernel(
            self.indptr,
            self.indices,
            self.data,
            self.shape[0],
            normed,
            symmetrize_unweighted,
        )

    def connected_components(self) -> tuple[int, np.ndarray[tuple[int], np.dtype[np.int32]]]:
        r"""Compute undirected connected components `(n_components, labels)` in $O(N + \text{nnz})$ time."""
        if self.shape[0] != self.shape[1]:
            raise ValueError(f"connected_components requires a square CSR matrix, got shape {self.shape}.")
        return _connected_components_kernel(self.indptr, self.indices, self.data, self.shape[0])

    @staticmethod
    def connected_components_from_edges(
        n_nodes: int,
        edge_u: np.ndarray,
        edge_v: np.ndarray,
    ) -> tuple[int, np.ndarray[tuple[int], np.dtype[np.int32]]]:
        r"""Compute undirected connected components `(n_components, labels)` directly from flat edge arrays in $O(N + E)$ time."""
        return _edges_connected_components_kernel(
            int(n_nodes),
            np.ascontiguousarray(edge_u, dtype=np.int32),
            np.ascontiguousarray(edge_v, dtype=np.int32),
        )

    def extract_undirected_edges(
        self,
        *,
        use_weights: bool = True,
    ) -> tuple[
        np.ndarray[tuple[int], np.dtype[np.int32]],
        np.ndarray[tuple[int], np.dtype[np.int32]],
        np.ndarray[tuple[int], np.dtype[np.float64]],
    ]:
        r"""Symmetrize (`A + A.T`) and extract unique upper-triangular undirected edges `(u < v, w)`."""
        return _extract_upper_undirected_edges_kernel(
            self.indptr,
            self.indices,
            self.data,
            self.shape[0],
            use_weights,
        )

    def __repr__(self) -> str:
        return f"CSR(shape={self.shape}, nnz={self.nnz}, dtype={self.dtype})"

ndim property

ndim: int

Number of matrix dimensions (always 2).

nnz property

nnz: int

Total number of stored elements.

dtype property

dtype: dtype[Any]

Data type of stored matrix values.

row property

row: ndarray[tuple[int], dtype[int32]]

1D int32 array of row indices for each stored entry (COO compatibility).

col property

col: ndarray[tuple[int], dtype[int32]]

1D int32 array of column indices for each stored entry (COO compatibility).

T property

T: CSR

Return the transpose \((N_{\text{cols}} \times N_{\text{rows}})\) as a CSR matrix.

empty classmethod

empty(shape: tuple[int, int], dtype: Any = float64) -> Self

Create an empty CSR matrix of shape (n_rows, n_cols) with zero non-zeros.

Source code in src/seroverse/core/graph/csr.py
@classmethod
def empty(cls, shape: tuple[int, int], dtype: Any = np.float64) -> Self:
    r"""Create an empty `CSR` matrix of shape `(n_rows, n_cols)` with zero non-zeros."""
    nr, nc = int(shape[0]), int(shape[1])
    return cls(
        indptr=np.zeros(nr + 1, dtype=np.int32),
        indices=np.empty(0, dtype=np.int32),
        data=np.empty(0, dtype=dtype),
        shape=(nr, nc),
    )

from_coo classmethod

from_coo(
    rows: Sequence[int] | ndarray,
    cols: Sequence[int] | ndarray,
    data: Sequence[Any] | ndarray | None = None,
    shape: tuple[int, int] | None = None,
    dtype: Any = None,
    *,
    sum_duplicates: bool = True,
) -> Self

Construct a CSR matrix from coordinate (rows, cols, data) arrays.

Source code in src/seroverse/core/graph/csr.py
@classmethod
def from_coo(
    cls,
    rows: Sequence[int] | np.ndarray,
    cols: Sequence[int] | np.ndarray,
    data: Sequence[Any] | np.ndarray | None = None,
    shape: tuple[int, int] | None = None,
    dtype: Any = None,
    *,
    sum_duplicates: bool = True,
) -> Self:
    r"""Construct a `CSR` matrix from coordinate `(rows, cols, data)` arrays."""
    r_arr = np.asarray(rows, dtype=np.int32)
    c_arr = np.asarray(cols, dtype=np.int32)
    if len(r_arr) != len(c_arr):
        raise ValueError(f"rows length ({len(r_arr)}) != cols length ({len(c_arr)}).")

    if data is None:
        dt = np.float64 if dtype is None else dtype
        d_arr = np.ones(len(r_arr), dtype=dt)
    else:
        d_arr = np.asarray(data, dtype=dtype) if dtype is not None else np.asarray(data)
        if len(d_arr) != len(r_arr):
            raise ValueError(f"data length ({len(d_arr)}) != rows length ({len(r_arr)}).")

    if shape is None:
        nr = int(np.max(r_arr)) + 1 if len(r_arr) > 0 else 0
        nc = int(np.max(c_arr)) + 1 if len(c_arr) > 0 else 0
    else:
        nr, nc = int(shape[0]), int(shape[1])

    if len(r_arr) == 0 or nr == 0 or nc == 0:
        return cls.empty((nr, nc), dtype=d_arr.dtype)

    keys = (r_arr.astype(np.int64) << 32) | (c_arr.astype(np.int64) & 0xFFFFFFFF)
    order = np.argsort(keys, kind="mergesort")
    r_sorted = np.ascontiguousarray(r_arr[order], dtype=np.int32)
    c_sorted = np.ascontiguousarray(c_arr[order], dtype=np.int32)
    d_sorted = np.ascontiguousarray(d_arr[order])

    indptr, indices, out_data = _compress_sorted_coo_kernel(
        r_sorted,
        c_sorted,
        d_sorted,
        nr,
        sum_duplicates,
    )
    return cls(indptr=indptr, indices=indices, data=out_data, shape=(nr, nc))

from_dense classmethod

from_dense(arr: ndarray) -> Self

Construct a CSR matrix from a 2D dense NumPy array.

Source code in src/seroverse/core/graph/csr.py
@classmethod
def from_dense(cls, arr: np.ndarray) -> Self:
    r"""Construct a `CSR` matrix from a 2D dense NumPy array."""
    mat = np.asarray(arr)
    if mat.ndim != 2:
        raise ValueError(f"Expected 2D array for CSR.from_dense, got shape {mat.shape}.")
    rows, cols = np.nonzero(mat)
    if len(rows) == 0:
        return cls.empty((int(mat.shape[0]), int(mat.shape[1])), dtype=mat.dtype)
    vals = mat[rows, cols]
    return cls.from_coo(rows, cols, vals, shape=(int(mat.shape[0]), int(mat.shape[1])), sum_duplicates=False)

copy

copy() -> Self

Return a deep copy of this CSR matrix.

Source code in src/seroverse/core/graph/csr.py
def copy(self) -> Self:
    r"""Return a deep copy of this `CSR` matrix."""
    return self.__class__(
        indptr=self.indptr.copy(),
        indices=self.indices.copy(),
        data=self.data.copy(),
        shape=self.shape,
    )

astype

astype(dtype: Any, copy: bool = True) -> CSR

Cast self.data to dtype and return a CSR matrix.

Source code in src/seroverse/core/graph/csr.py
def astype(self, dtype: Any, copy: bool = True) -> CSR:
    r"""Cast `self.data` to `dtype` and return a `CSR` matrix."""
    if not copy and self.data.dtype == np.dtype(dtype):
        return self
    return CSR(
        indptr=self.indptr.copy() if copy else self.indptr,
        indices=self.indices.copy() if copy else self.indices,
        data=self.data.astype(dtype, copy=copy),
        shape=self.shape,
    )

tocsr

tocsr() -> CSR

Return self (compatibility with scipy.sparse).

Source code in src/seroverse/core/graph/csr.py
def tocsr(self) -> CSR:
    r"""Return `self` (compatibility with `scipy.sparse`)."""
    return self

tocsc

tocsc() -> CSR

Return self.T whose .indptr indexes columns (compatibility with scipy.sparse).

Source code in src/seroverse/core/graph/csr.py
def tocsc(self) -> CSR:
    r"""Return `self.T` whose `.indptr` indexes columns (compatibility with `scipy.sparse`)."""
    return self.T

tocoo

tocoo() -> CSR

Return self, which exposes .row, .col, and .data directly.

Source code in src/seroverse/core/graph/csr.py
def tocoo(self) -> CSR:
    r"""Return `self`, which exposes `.row`, `.col`, and `.data` directly."""
    return self

toarray

toarray() -> ndarray

Convert this sparse CSR matrix into a 2D dense NumPy array.

Source code in src/seroverse/core/graph/csr.py
def toarray(self) -> np.ndarray:
    r"""Convert this sparse `CSR` matrix into a 2D dense NumPy array."""
    nr, nc = self.shape
    if nr == 0 or nc == 0:
        return np.zeros((nr, nc), dtype=self.dtype)
    return _csr_to_dense_kernel(self.indptr, self.indices, self.data, nr, nc)

with_diag

with_diag(values: float = 0) -> CSR

Return a new CSR matrix with diagonal entries (i, i) set to values (or removed when values == 0).

Source code in src/seroverse/core/graph/csr.py
def with_diag(self, values: float = 0) -> CSR:
    r"""Return a new `CSR` matrix with diagonal entries `(i, i)` set to `values` (or removed when `values == 0`)."""
    if values == 0:
        if self.nnz == 0:
            return self
        rows = self.row
        keep = rows != self.indices
        if bool(np.all(keep)):
            return self
        return CSR.from_coo(rows[keep], self.indices[keep], self.data[keep], shape=self.shape, sum_duplicates=False)
    n_diag = min(self.shape[0], self.shape[1])
    diag_idx = np.arange(n_diag, dtype=np.int32)
    rows = self.row
    non_diag = rows != self.indices
    all_rows = np.concatenate([rows[non_diag], diag_idx])
    all_cols = np.concatenate([self.indices[non_diag], diag_idx])
    all_vals = np.concatenate([self.data[non_diag], np.full(n_diag, values, dtype=self.dtype)])
    return CSR.from_coo(all_rows, all_cols, all_vals, shape=self.shape, sum_duplicates=False)

without_zeros

without_zeros() -> CSR

Return a new CSR matrix with zero-valued entries removed from indices and data.

Source code in src/seroverse/core/graph/csr.py
def without_zeros(self) -> CSR:
    r"""Return a new `CSR` matrix with zero-valued entries removed from `indices` and `data`."""
    if self.nnz == 0:
        return self
    nz = self.data != 0
    if bool(np.all(nz)):
        return self
    rows = self.row[nz]
    return CSR.from_coo(rows, self.indices[nz], self.data[nz], shape=self.shape, sum_duplicates=False)

sum

sum(axis: int | None = None) -> ndarray | float | int

Sum matrix elements over the given axis (None, 0, or 1).

Source code in src/seroverse/core/graph/csr.py
def sum(self, axis: int | None = None) -> np.ndarray | float | int:
    r"""Sum matrix elements over the given axis (`None`, `0`, or `1`)."""
    if axis is None:
        return self.data.sum()
    if axis == 1 or axis == -1:
        return _csr_row_sum_kernel(self.indptr, self.data, self.shape[0])
    if axis == 0 or axis == -2:
        return _csr_col_sum_kernel(self.indices, self.data, self.shape[1])
    raise ValueError(f"axis must be None, 0, or 1; got {axis}.")

laplacian

laplacian(
    *,
    normed: bool = True,
    symmetrize_unweighted: bool = False,
) -> ndarray[tuple[int, int], dtype[float64]]

Compute the dense (normalized or unnormalized) graph Laplacian matrix \(L\) in a single Numba pass.

Source code in src/seroverse/core/graph/csr.py
def laplacian(
    self,
    *,
    normed: bool = True,
    symmetrize_unweighted: bool = False,
) -> np.ndarray[tuple[int, int], np.dtype[np.float64]]:
    r"""Compute the dense (normalized or unnormalized) graph Laplacian matrix $L$ in a single Numba pass."""
    if self.shape[0] != self.shape[1]:
        raise ValueError(f"Graph Laplacian requires a square CSR matrix, got shape {self.shape}.")
    return _csr_laplacian_dense_kernel(
        self.indptr,
        self.indices,
        self.data,
        self.shape[0],
        normed,
        symmetrize_unweighted,
    )

connected_components

connected_components() -> tuple[
    int, ndarray[tuple[int], dtype[int32]]
]

Compute undirected connected components (n_components, labels) in \(O(N + \text{nnz})\) time.

Source code in src/seroverse/core/graph/csr.py
def connected_components(self) -> tuple[int, np.ndarray[tuple[int], np.dtype[np.int32]]]:
    r"""Compute undirected connected components `(n_components, labels)` in $O(N + \text{nnz})$ time."""
    if self.shape[0] != self.shape[1]:
        raise ValueError(f"connected_components requires a square CSR matrix, got shape {self.shape}.")
    return _connected_components_kernel(self.indptr, self.indices, self.data, self.shape[0])

connected_components_from_edges staticmethod

connected_components_from_edges(
    n_nodes: int, edge_u: ndarray, edge_v: ndarray
) -> tuple[int, ndarray[tuple[int], dtype[int32]]]

Compute undirected connected components (n_components, labels) directly from flat edge arrays in \(O(N + E)\) time.

Source code in src/seroverse/core/graph/csr.py
@staticmethod
def connected_components_from_edges(
    n_nodes: int,
    edge_u: np.ndarray,
    edge_v: np.ndarray,
) -> tuple[int, np.ndarray[tuple[int], np.dtype[np.int32]]]:
    r"""Compute undirected connected components `(n_components, labels)` directly from flat edge arrays in $O(N + E)$ time."""
    return _edges_connected_components_kernel(
        int(n_nodes),
        np.ascontiguousarray(edge_u, dtype=np.int32),
        np.ascontiguousarray(edge_v, dtype=np.int32),
    )

extract_undirected_edges

extract_undirected_edges(
    *, use_weights: bool = True
) -> tuple[
    ndarray[tuple[int], dtype[int32]],
    ndarray[tuple[int], dtype[int32]],
    ndarray[tuple[int], dtype[float64]],
]

Symmetrize (A + A.T) and extract unique upper-triangular undirected edges (u < v, w).

Source code in src/seroverse/core/graph/csr.py
def extract_undirected_edges(
    self,
    *,
    use_weights: bool = True,
) -> tuple[
    np.ndarray[tuple[int], np.dtype[np.int32]],
    np.ndarray[tuple[int], np.dtype[np.int32]],
    np.ndarray[tuple[int], np.dtype[np.float64]],
]:
    r"""Symmetrize (`A + A.T`) and extract unique upper-triangular undirected edges `(u < v, w)`."""
    return _extract_upper_undirected_edges_kernel(
        self.indptr,
        self.indices,
        self.data,
        self.shape[0],
        use_weights,
    )

RelationalCSR dataclass

Bases: CSR

Immutable Compressed Sparse Row (CSR) incoming-edge representation of a multi-relational directed graph.

Inherits indptr, indices (incoming source vertex IDs), data (normalized float32 edge aggregation weights), and shape from CSR, while adding per-edge relation type IDs (rel_types), total relation count (n_relations), and optional virtual pool node indices (virtual_nodes).

For each target vertex \(v \in [0, N-1]\), the slice k in range(indptr[v], indptr[v + 1]) enumerates incoming directed edges \((u_k \xrightarrow{r_k} v)\) with source vertex u_k = indices[k] (also aliased as .src_indices), relation ID r_k = rel_types[k], and normalized aggregation weight data[k] (also aliased as .edge_weights).

Attributes:

Name Type Description
indptr ndarray[tuple[int], dtype[int32]]

1D int32 array of shape (N + 1,) indexing incoming edges per target vertex.

indices ndarray[tuple[int], dtype[int32]]

1D int32 array of shape (E,) of source vertex indices.

data ndarray[tuple[int], Any]

1D float32 array of shape (E,) of per-edge aggregation weights.

shape tuple[int, int]

2-tuple (N, N) giving total node dimensions.

rel_types ndarray[tuple[int], dtype[int32]]

1D int32 array of shape (E,) of relation type IDs in [0, R - 1].

n_relations int

Total number of distinct relation types \(R\).

virtual_nodes ndarray[tuple[int], dtype[int32]]

1D int32 array of virtual global pool vertex indices (empty if none).

Methods:

Name Description
from_edges

Construct a RelationalCSR from flat directed edge arrays.

from_multihop

Build an 8-relation multi-hop RelationalCSR for a graph or ragged batch.

from_batch

Build a multi-hop RelationalCSR directly from a Graphs batch.

from_graph

Build a multi-hop RelationalCSR from a single Graph.

Source code in src/seroverse/core/graph/csr.py
@dataclass(slots=True, frozen=True)
class RelationalCSR(CSR):
    r"""Immutable Compressed Sparse Row (`CSR`) incoming-edge representation of a multi-relational directed graph.

    Inherits `indptr`, `indices` (incoming source vertex IDs), `data` (normalized `float32` edge
    aggregation weights), and `shape` from [`CSR`][seroverse.core.graph.csr.CSR], while adding
    per-edge relation type IDs (`rel_types`), total relation count (`n_relations`), and optional
    virtual pool node indices (`virtual_nodes`).

    For each target vertex $v \in [0, N-1]$, the slice `k in range(indptr[v], indptr[v + 1])`
    enumerates incoming directed edges $(u_k \xrightarrow{r_k} v)$ with source vertex
    `u_k = indices[k]` (also aliased as `.src_indices`), relation ID `r_k = rel_types[k]`,
    and normalized aggregation weight `data[k]` (also aliased as `.edge_weights`).

    Attributes:
        indptr: 1D `int32` array of shape `(N + 1,)` indexing incoming edges per target vertex.
        indices: 1D `int32` array of shape `(E,)` of source vertex indices.
        data: 1D `float32` array of shape `(E,)` of per-edge aggregation weights.
        shape: 2-tuple `(N, N)` giving total node dimensions.
        rel_types: 1D `int32` array of shape `(E,)` of relation type IDs in `[0, R - 1]`.
        n_relations: Total number of distinct relation types $R$.
        virtual_nodes: 1D `int32` array of virtual global pool vertex indices (empty if none).
    """

    rel_types: np.ndarray[tuple[int], np.dtype[np.int32]] = field(default_factory=lambda: np.empty(0, dtype=np.int32))
    n_relations: int = 1
    virtual_nodes: np.ndarray[tuple[int], np.dtype[np.int32]] = field(
        default_factory=lambda: np.empty(0, dtype=np.int32)
    )

    def __post_init__(self) -> None:
        r"""Normalize multi-relational CSR arrays and validate dimensions."""
        super().__post_init__()
        object.__setattr__(self, "data", np.ascontiguousarray(self.data, dtype=np.float32))
        object.__setattr__(self, "rel_types", np.ascontiguousarray(self.rel_types, dtype=np.int32))
        object.__setattr__(self, "n_relations", max(int(self.n_relations), 1))
        object.__setattr__(self, "virtual_nodes", np.ascontiguousarray(self.virtual_nodes, dtype=np.int32))

    @property
    def n_nodes(self) -> int:
        r"""Total number of vertices $N$ (including any virtual pool vertices)."""
        return self.shape[0]

    @property
    def src_indices(self) -> np.ndarray[tuple[int], np.dtype[np.int32]]:
        r"""1D `int32` array of incoming source vertex indices (alias for `self.indices`)."""
        return self.indices

    @property
    def edge_weights(self) -> np.ndarray[tuple[int], np.dtype[np.float32]]:
        r"""1D `float32` array of per-edge aggregation weights (alias for `self.data`)."""
        return self.data

    @classmethod
    def from_edges(
        cls,
        n_nodes: int,
        sources: Sequence[int] | np.ndarray,
        targets: Sequence[int] | np.ndarray,
        relations: Sequence[int] | np.ndarray | None = None,
        *,
        n_relations: int | None = None,
        add_self_loops: bool = True,
        normalize_by_degree: bool = True,
        virtual_nodes: np.ndarray | None = None,
    ) -> Self:
        r"""Construct a [`RelationalCSR`][seroverse.core.graph.csr.RelationalCSR] from flat directed edge arrays.

        Args:
            n_nodes: Total number of vertices $N$.
            sources: 1D array-like of source vertex indices `u`.
            targets: 1D array-like of target vertex indices `v`.
            relations: Optional 1D array-like of integer relation IDs `r >= 0`. If `None`, all edges
                receive relation `1` (when `add_self_loops=True`, self-loops receive relation `0`).
            n_relations: Total number of relation types $R$. Inferred if `None`.
            add_self_loops: If `True` (default), prepends a self-loop `(v -> v, r=0)` for every vertex.
            normalize_by_degree: If `True` (default), normalizes incoming weights per `(target, relation)`
                group so each relation's incoming messages average cleanly.
            virtual_nodes: Optional 1D `int32` array of virtual global pool vertex indices.

        Returns:
            RelationalCSR: Incoming-edge multi-relational CSR structure.
        """
        src_arr = np.asarray(sources, dtype=np.int32)
        dst_arr = np.asarray(targets, dtype=np.int32)
        if len(src_arr) != len(dst_arr):
            raise ValueError(f"sources length ({len(src_arr)}) != targets length ({len(dst_arr)}).")

        if relations is None:
            rel_arr = np.full(len(src_arr), 1 if add_self_loops else 0, dtype=np.int32)
        else:
            rel_arr = np.asarray(relations, dtype=np.int32)
            if len(rel_arr) != len(src_arr):
                raise ValueError(f"relations length ({len(rel_arr)}) != sources length ({len(src_arr)}).")

        if add_self_loops and n_nodes > 0:
            self_idx = np.arange(n_nodes, dtype=np.int32)
            self_rel = np.zeros(n_nodes, dtype=np.int32)
            src_arr = np.concatenate([self_idx, src_arr])
            dst_arr = np.concatenate([self_idx, dst_arr])
            rel_arr = np.concatenate([self_rel, rel_arr])

        n_rel = (
            (int(np.max(rel_arr)) + 1 if len(rel_arr) > 0 else 1) if n_relations is None else max(int(n_relations), 1)
        )
        v_nodes = (
            np.asarray(virtual_nodes, dtype=np.int32) if virtual_nodes is not None else np.empty(0, dtype=np.int32)
        )

        if n_nodes == 0 or len(src_arr) == 0:
            return cls(
                indptr=np.zeros(n_nodes + 1, dtype=np.int32),
                indices=np.empty(0, dtype=np.int32),
                data=np.empty(0, dtype=np.float32),
                shape=(n_nodes, n_nodes),
                rel_types=np.empty(0, dtype=np.int32),
                n_relations=n_rel,
                virtual_nodes=v_nodes,
            )

        order = np.lexsort((src_arr, rel_arr, dst_arr))
        src_sorted = np.ascontiguousarray(src_arr[order], dtype=np.int32)
        dst_sorted = np.ascontiguousarray(dst_arr[order], dtype=np.int32)
        rel_sorted = np.ascontiguousarray(rel_arr[order], dtype=np.int32)

        counts = np.bincount(dst_sorted, minlength=n_nodes).astype(np.int32)
        indptr = np.zeros(n_nodes + 1, dtype=np.int32)
        np.cumsum(counts, out=indptr[1:])

        weights = np.ones(len(src_sorted), dtype=np.float32)
        if normalize_by_degree:
            key = dst_sorted.astype(np.int64) * n_rel + rel_sorted.astype(np.int64)
            _, inv, key_counts = np.unique(key, return_inverse=True, return_counts=True)
            weights = (1.0 / np.maximum(key_counts[inv], 1)).astype(np.float32)

        return cls(
            indptr=indptr,
            indices=src_sorted,
            data=np.ascontiguousarray(weights, dtype=np.float32),
            shape=(n_nodes, n_nodes),
            rel_types=rel_sorted,
            n_relations=n_rel,
            virtual_nodes=v_nodes,
        )

    @classmethod
    def from_multihop(
        cls,
        node_offsets: Sequence[int] | np.ndarray,
        edge_sources: Sequence[int] | np.ndarray,
        edge_targets: Sequence[int] | np.ndarray,
        edge_orientations: Sequence[int] | np.ndarray | None = None,
        *,
        include_2hop: bool = True,
        add_virtual_pool: bool = True,
    ) -> Self:
        r"""Build an 8-relation multi-hop [`RelationalCSR`][seroverse.core.graph.csr.RelationalCSR] for a graph or ragged batch.

        Constructs up to 8 directed relation types:
            - `0`: Self-loop (`v -> v`)
            - `1`: 1-hop forward (`u -> v`, codirectional)
            - `2`: 1-hop reverse (`v -> u`, codirectional)
            - `3`: 1-hop convergent/divergent (`u <-> v` when `edge_orientations != 0`)
            - `4`: 2-hop forward (`u -> w -> v`)
            - `5`: 2-hop reverse (`v -> w -> u`)
            - `6`: Real vertex to per-graph virtual global pool (`u -> pool_g`)
            - `7`: Per-graph virtual global pool to real vertex (`pool_g -> u`)
        """
        offsets = np.asarray(node_offsets, dtype=np.int32)
        n_graphs = max(len(offsets) - 1, 0)
        n_real = int(offsets[-1]) if len(offsets) > 0 else 0
        e_src = np.asarray(edge_sources, dtype=np.int32)
        e_dst = np.asarray(edge_targets, dtype=np.int32)
        e_ori = (
            np.asarray(edge_orientations, dtype=np.int32)
            if edge_orientations is not None
            else np.zeros(len(e_src), dtype=np.int32)
        )

        src_list: list[np.ndarray] = []
        dst_list: list[np.ndarray] = []
        rel_list: list[np.ndarray] = []

        if len(e_src) > 0:
            codir = e_ori == 0
            if np.any(codir):
                u_c = e_src[codir]
                v_c = e_dst[codir]
                src_list.extend([u_c, v_c])
                dst_list.extend([v_c, u_c])
                rel_list.extend([np.full(len(u_c), 1, dtype=np.int32), np.full(len(u_c), 2, dtype=np.int32)])
            non_codir = ~codir
            if np.any(non_codir):
                u_nc = e_src[non_codir]
                v_nc = e_dst[non_codir]
                src_list.extend([u_nc, v_nc])
                dst_list.extend([v_nc, u_nc])
                rel_list.extend([np.full(len(u_nc), 3, dtype=np.int32), np.full(len(u_nc), 3, dtype=np.int32)])

            if include_2hop and n_real > 0:
                h2u, h2v = _build_2hop_edges_kernel(n_real, e_src, e_dst)
                if len(h2u) > 0:
                    src_list.extend([h2u, h2v])
                    dst_list.extend([h2v, h2u])
                    rel_list.extend([np.full(len(h2u), 4, dtype=np.int32), np.full(len(h2u), 5, dtype=np.int32)])

        if add_virtual_pool and n_graphs > 0:
            virtual_nodes = np.arange(n_real, n_real + n_graphs, dtype=np.int32)
            total_nodes = n_real + n_graphs
            if n_real > 0:
                graph_Lengths = np.diff(offsets).astype(np.int32)
                nodes_all = np.arange(n_real, dtype=np.int32)
                v_pool_ids = np.repeat(virtual_nodes, graph_Lengths)
                src_list.extend([nodes_all, v_pool_ids])
                dst_list.extend([v_pool_ids, nodes_all])
                rel_list.extend([np.full(n_real, 6, dtype=np.int32), np.full(n_real, 7, dtype=np.int32)])
        else:
            virtual_nodes = np.empty(0, dtype=np.int32)
            total_nodes = n_real

        all_src = np.concatenate(src_list) if src_list else np.empty(0, dtype=np.int32)
        all_dst = np.concatenate(dst_list) if dst_list else np.empty(0, dtype=np.int32)
        all_rel = np.concatenate(rel_list) if rel_list else np.empty(0, dtype=np.int32)

        return cls.from_edges(
            total_nodes,
            all_src,
            all_dst,
            all_rel,
            n_relations=8,
            add_self_loops=True,
            normalize_by_degree=True,
            virtual_nodes=virtual_nodes,
        )

    @classmethod
    def from_batch(
        cls,
        batch: Graphs[Any, Any],
        edge_orientations: Sequence[int] | np.ndarray | None = None,
        *,
        include_2hop: bool = True,
        add_virtual_pool: bool = True,
    ) -> Self:
        r"""Build a multi-hop [`RelationalCSR`][seroverse.core.graph.csr.RelationalCSR] directly from a [`Graphs`][seroverse.core.graph.models.Graphs] batch."""
        return cls.from_multihop(
            batch.node_offsets,
            batch.edge_sources,
            batch.edge_targets,
            edge_orientations=edge_orientations,
            include_2hop=include_2hop,
            add_virtual_pool=add_virtual_pool,
        )

    @classmethod
    def from_graph(
        cls,
        graph: Graph,
        *,
        include_2hop: bool = True,
        add_virtual_pool: bool = True,
    ) -> Self:
        r"""Build a multi-hop [`RelationalCSR`][seroverse.core.graph.csr.RelationalCSR] from a single [`Graph`][seroverse.core.graph.models.Graph]."""
        n_nodes = len(graph.nodes)
        edges = graph.edges
        n_edges = len(edges)
        sources = np.fromiter((e.source for e in edges), dtype=np.int32, count=n_edges)
        targets = np.fromiter((e.target for e in edges), dtype=np.int32, count=n_edges)
        return cls.from_multihop(
            np.array([0, n_nodes], dtype=np.int32),
            sources,
            targets,
            include_2hop=include_2hop,
            add_virtual_pool=add_virtual_pool,
        )

    def __repr__(self) -> str:
        return (
            f"RelationalCSR(n_nodes={self.n_nodes}, n_relations={self.n_relations}, "
            f"nnz={self.nnz}, virtual_nodes={len(self.virtual_nodes)})"
        )

n_nodes property

n_nodes: int

Total number of vertices \(N\) (including any virtual pool vertices).

src_indices property

src_indices: ndarray[tuple[int], dtype[int32]]

1D int32 array of incoming source vertex indices (alias for self.indices).

edge_weights property

edge_weights: ndarray[tuple[int], dtype[float32]]

1D float32 array of per-edge aggregation weights (alias for self.data).

from_edges classmethod

from_edges(
    n_nodes: int,
    sources: Sequence[int] | ndarray,
    targets: Sequence[int] | ndarray,
    relations: Sequence[int] | ndarray | None = None,
    *,
    n_relations: int | None = None,
    add_self_loops: bool = True,
    normalize_by_degree: bool = True,
    virtual_nodes: ndarray | None = None,
) -> Self

Construct a RelationalCSR from flat directed edge arrays.

Parameters:

Name Type Description Default
n_nodes int

Total number of vertices \(N\).

required
sources Sequence[int] | ndarray

1D array-like of source vertex indices u.

required
targets Sequence[int] | ndarray

1D array-like of target vertex indices v.

required
relations Sequence[int] | ndarray | None

Optional 1D array-like of integer relation IDs r >= 0. If None, all edges receive relation 1 (when add_self_loops=True, self-loops receive relation 0).

None
n_relations int | None

Total number of relation types \(R\). Inferred if None.

None
add_self_loops bool

If True (default), prepends a self-loop (v -> v, r=0) for every vertex.

True
normalize_by_degree bool

If True (default), normalizes incoming weights per (target, relation) group so each relation's incoming messages average cleanly.

True
virtual_nodes ndarray | None

Optional 1D int32 array of virtual global pool vertex indices.

None

Returns:

Name Type Description
RelationalCSR Self

Incoming-edge multi-relational CSR structure.

Source code in src/seroverse/core/graph/csr.py
@classmethod
def from_edges(
    cls,
    n_nodes: int,
    sources: Sequence[int] | np.ndarray,
    targets: Sequence[int] | np.ndarray,
    relations: Sequence[int] | np.ndarray | None = None,
    *,
    n_relations: int | None = None,
    add_self_loops: bool = True,
    normalize_by_degree: bool = True,
    virtual_nodes: np.ndarray | None = None,
) -> Self:
    r"""Construct a [`RelationalCSR`][seroverse.core.graph.csr.RelationalCSR] from flat directed edge arrays.

    Args:
        n_nodes: Total number of vertices $N$.
        sources: 1D array-like of source vertex indices `u`.
        targets: 1D array-like of target vertex indices `v`.
        relations: Optional 1D array-like of integer relation IDs `r >= 0`. If `None`, all edges
            receive relation `1` (when `add_self_loops=True`, self-loops receive relation `0`).
        n_relations: Total number of relation types $R$. Inferred if `None`.
        add_self_loops: If `True` (default), prepends a self-loop `(v -> v, r=0)` for every vertex.
        normalize_by_degree: If `True` (default), normalizes incoming weights per `(target, relation)`
            group so each relation's incoming messages average cleanly.
        virtual_nodes: Optional 1D `int32` array of virtual global pool vertex indices.

    Returns:
        RelationalCSR: Incoming-edge multi-relational CSR structure.
    """
    src_arr = np.asarray(sources, dtype=np.int32)
    dst_arr = np.asarray(targets, dtype=np.int32)
    if len(src_arr) != len(dst_arr):
        raise ValueError(f"sources length ({len(src_arr)}) != targets length ({len(dst_arr)}).")

    if relations is None:
        rel_arr = np.full(len(src_arr), 1 if add_self_loops else 0, dtype=np.int32)
    else:
        rel_arr = np.asarray(relations, dtype=np.int32)
        if len(rel_arr) != len(src_arr):
            raise ValueError(f"relations length ({len(rel_arr)}) != sources length ({len(src_arr)}).")

    if add_self_loops and n_nodes > 0:
        self_idx = np.arange(n_nodes, dtype=np.int32)
        self_rel = np.zeros(n_nodes, dtype=np.int32)
        src_arr = np.concatenate([self_idx, src_arr])
        dst_arr = np.concatenate([self_idx, dst_arr])
        rel_arr = np.concatenate([self_rel, rel_arr])

    n_rel = (
        (int(np.max(rel_arr)) + 1 if len(rel_arr) > 0 else 1) if n_relations is None else max(int(n_relations), 1)
    )
    v_nodes = (
        np.asarray(virtual_nodes, dtype=np.int32) if virtual_nodes is not None else np.empty(0, dtype=np.int32)
    )

    if n_nodes == 0 or len(src_arr) == 0:
        return cls(
            indptr=np.zeros(n_nodes + 1, dtype=np.int32),
            indices=np.empty(0, dtype=np.int32),
            data=np.empty(0, dtype=np.float32),
            shape=(n_nodes, n_nodes),
            rel_types=np.empty(0, dtype=np.int32),
            n_relations=n_rel,
            virtual_nodes=v_nodes,
        )

    order = np.lexsort((src_arr, rel_arr, dst_arr))
    src_sorted = np.ascontiguousarray(src_arr[order], dtype=np.int32)
    dst_sorted = np.ascontiguousarray(dst_arr[order], dtype=np.int32)
    rel_sorted = np.ascontiguousarray(rel_arr[order], dtype=np.int32)

    counts = np.bincount(dst_sorted, minlength=n_nodes).astype(np.int32)
    indptr = np.zeros(n_nodes + 1, dtype=np.int32)
    np.cumsum(counts, out=indptr[1:])

    weights = np.ones(len(src_sorted), dtype=np.float32)
    if normalize_by_degree:
        key = dst_sorted.astype(np.int64) * n_rel + rel_sorted.astype(np.int64)
        _, inv, key_counts = np.unique(key, return_inverse=True, return_counts=True)
        weights = (1.0 / np.maximum(key_counts[inv], 1)).astype(np.float32)

    return cls(
        indptr=indptr,
        indices=src_sorted,
        data=np.ascontiguousarray(weights, dtype=np.float32),
        shape=(n_nodes, n_nodes),
        rel_types=rel_sorted,
        n_relations=n_rel,
        virtual_nodes=v_nodes,
    )

from_multihop classmethod

from_multihop(
    node_offsets: Sequence[int] | ndarray,
    edge_sources: Sequence[int] | ndarray,
    edge_targets: Sequence[int] | ndarray,
    edge_orientations: Sequence[int]
    | ndarray
    | None = None,
    *,
    include_2hop: bool = True,
    add_virtual_pool: bool = True,
) -> Self

Build an 8-relation multi-hop RelationalCSR for a graph or ragged batch.

Constructs up to 8 directed relation types
  • 0: Self-loop (v -> v)
  • 1: 1-hop forward (u -> v, codirectional)
  • 2: 1-hop reverse (v -> u, codirectional)
  • 3: 1-hop convergent/divergent (u <-> v when edge_orientations != 0)
  • 4: 2-hop forward (u -> w -> v)
  • 5: 2-hop reverse (v -> w -> u)
  • 6: Real vertex to per-graph virtual global pool (u -> pool_g)
  • 7: Per-graph virtual global pool to real vertex (pool_g -> u)
Source code in src/seroverse/core/graph/csr.py
@classmethod
def from_multihop(
    cls,
    node_offsets: Sequence[int] | np.ndarray,
    edge_sources: Sequence[int] | np.ndarray,
    edge_targets: Sequence[int] | np.ndarray,
    edge_orientations: Sequence[int] | np.ndarray | None = None,
    *,
    include_2hop: bool = True,
    add_virtual_pool: bool = True,
) -> Self:
    r"""Build an 8-relation multi-hop [`RelationalCSR`][seroverse.core.graph.csr.RelationalCSR] for a graph or ragged batch.

    Constructs up to 8 directed relation types:
        - `0`: Self-loop (`v -> v`)
        - `1`: 1-hop forward (`u -> v`, codirectional)
        - `2`: 1-hop reverse (`v -> u`, codirectional)
        - `3`: 1-hop convergent/divergent (`u <-> v` when `edge_orientations != 0`)
        - `4`: 2-hop forward (`u -> w -> v`)
        - `5`: 2-hop reverse (`v -> w -> u`)
        - `6`: Real vertex to per-graph virtual global pool (`u -> pool_g`)
        - `7`: Per-graph virtual global pool to real vertex (`pool_g -> u`)
    """
    offsets = np.asarray(node_offsets, dtype=np.int32)
    n_graphs = max(len(offsets) - 1, 0)
    n_real = int(offsets[-1]) if len(offsets) > 0 else 0
    e_src = np.asarray(edge_sources, dtype=np.int32)
    e_dst = np.asarray(edge_targets, dtype=np.int32)
    e_ori = (
        np.asarray(edge_orientations, dtype=np.int32)
        if edge_orientations is not None
        else np.zeros(len(e_src), dtype=np.int32)
    )

    src_list: list[np.ndarray] = []
    dst_list: list[np.ndarray] = []
    rel_list: list[np.ndarray] = []

    if len(e_src) > 0:
        codir = e_ori == 0
        if np.any(codir):
            u_c = e_src[codir]
            v_c = e_dst[codir]
            src_list.extend([u_c, v_c])
            dst_list.extend([v_c, u_c])
            rel_list.extend([np.full(len(u_c), 1, dtype=np.int32), np.full(len(u_c), 2, dtype=np.int32)])
        non_codir = ~codir
        if np.any(non_codir):
            u_nc = e_src[non_codir]
            v_nc = e_dst[non_codir]
            src_list.extend([u_nc, v_nc])
            dst_list.extend([v_nc, u_nc])
            rel_list.extend([np.full(len(u_nc), 3, dtype=np.int32), np.full(len(u_nc), 3, dtype=np.int32)])

        if include_2hop and n_real > 0:
            h2u, h2v = _build_2hop_edges_kernel(n_real, e_src, e_dst)
            if len(h2u) > 0:
                src_list.extend([h2u, h2v])
                dst_list.extend([h2v, h2u])
                rel_list.extend([np.full(len(h2u), 4, dtype=np.int32), np.full(len(h2u), 5, dtype=np.int32)])

    if add_virtual_pool and n_graphs > 0:
        virtual_nodes = np.arange(n_real, n_real + n_graphs, dtype=np.int32)
        total_nodes = n_real + n_graphs
        if n_real > 0:
            graph_Lengths = np.diff(offsets).astype(np.int32)
            nodes_all = np.arange(n_real, dtype=np.int32)
            v_pool_ids = np.repeat(virtual_nodes, graph_Lengths)
            src_list.extend([nodes_all, v_pool_ids])
            dst_list.extend([v_pool_ids, nodes_all])
            rel_list.extend([np.full(n_real, 6, dtype=np.int32), np.full(n_real, 7, dtype=np.int32)])
    else:
        virtual_nodes = np.empty(0, dtype=np.int32)
        total_nodes = n_real

    all_src = np.concatenate(src_list) if src_list else np.empty(0, dtype=np.int32)
    all_dst = np.concatenate(dst_list) if dst_list else np.empty(0, dtype=np.int32)
    all_rel = np.concatenate(rel_list) if rel_list else np.empty(0, dtype=np.int32)

    return cls.from_edges(
        total_nodes,
        all_src,
        all_dst,
        all_rel,
        n_relations=8,
        add_self_loops=True,
        normalize_by_degree=True,
        virtual_nodes=virtual_nodes,
    )

from_batch classmethod

from_batch(
    batch: Graphs[Any, Any],
    edge_orientations: Sequence[int]
    | ndarray
    | None = None,
    *,
    include_2hop: bool = True,
    add_virtual_pool: bool = True,
) -> Self

Build a multi-hop RelationalCSR directly from a Graphs batch.

Source code in src/seroverse/core/graph/csr.py
@classmethod
def from_batch(
    cls,
    batch: Graphs[Any, Any],
    edge_orientations: Sequence[int] | np.ndarray | None = None,
    *,
    include_2hop: bool = True,
    add_virtual_pool: bool = True,
) -> Self:
    r"""Build a multi-hop [`RelationalCSR`][seroverse.core.graph.csr.RelationalCSR] directly from a [`Graphs`][seroverse.core.graph.models.Graphs] batch."""
    return cls.from_multihop(
        batch.node_offsets,
        batch.edge_sources,
        batch.edge_targets,
        edge_orientations=edge_orientations,
        include_2hop=include_2hop,
        add_virtual_pool=add_virtual_pool,
    )

from_graph classmethod

from_graph(
    graph: Graph,
    *,
    include_2hop: bool = True,
    add_virtual_pool: bool = True,
) -> Self

Build a multi-hop RelationalCSR from a single Graph.

Source code in src/seroverse/core/graph/csr.py
@classmethod
def from_graph(
    cls,
    graph: Graph,
    *,
    include_2hop: bool = True,
    add_virtual_pool: bool = True,
) -> Self:
    r"""Build a multi-hop [`RelationalCSR`][seroverse.core.graph.csr.RelationalCSR] from a single [`Graph`][seroverse.core.graph.models.Graph]."""
    n_nodes = len(graph.nodes)
    edges = graph.edges
    n_edges = len(edges)
    sources = np.fromiter((e.source for e in edges), dtype=np.int32, count=n_edges)
    targets = np.fromiter((e.target for e in edges), dtype=np.int32, count=n_edges)
    return cls.from_multihop(
        np.array([0, n_nodes], dtype=np.int32),
        sources,
        targets,
        include_2hop=include_2hop,
        add_virtual_pool=add_virtual_pool,
    )

models

Core graph representations, bitfield packing, and vectorized SoA containers.

Classes:

Name Description
BitField

Descriptor defining a bitfield slice (shift, width, mask).

NodeBase

Abstract base protocol for all core nodes.

IntNode

Base model for core nodes represented as bit-packed primitive integers.

DirectedEdge

Base class for all directed core edges.

Graph

Scalar view and topological model representing a single directed graph.

Graphs

Base Structure-of-Arrays (SoA) container for batched graph datasets.

BitField dataclass

Descriptor defining a bitfield slice (shift, width, mask). Provides safe packing, extraction, and bitmask computation.

Methods:

Name Description
extract

Extracts and right-shifts this bitfield's value from an integer.

extract_array

Extracts and right-shifts this bitfield's value across a numpy array.

pack

Validates and shifts field_val into its bit position.

Attributes:

Name Type Description
mask int

Absolute bitmask positioned at field shift.

max_val int

Maximum unsigned value that fits within this bitfield width.

Source code in src/seroverse/core/graph/models.py
@dataclass(slots=True, frozen=True)
class BitField:
    """
    Descriptor defining a bitfield slice (shift, width, mask).
    Provides safe packing, extraction, and bitmask computation.
    """

    shift: int
    width: int

    @property
    def mask(self) -> int:
        """Absolute bitmask positioned at field shift."""
        return ((1 << self.width) - 1) << self.shift

    @property
    def max_val(self) -> int:
        """Maximum unsigned value that fits within this bitfield width."""
        return (1 << self.width) - 1

    def extract(self, val: int) -> int:
        """Extracts and right-shifts this bitfield's value from an integer."""
        return (val >> self.shift) & self.max_val

    def extract_array(self, arr: np.ndarray) -> np.ndarray:
        """Extracts and right-shifts this bitfield's value across a numpy array."""
        return (arr >> self.shift) & self.max_val

    def pack(self, field_val: int) -> int:
        """Validates and shifts field_val into its bit position."""
        if not (0 <= field_val <= self.max_val):
            raise ValueError(f"Value {field_val} exceeds {self.width} bits (max {self.max_val})")
        return (field_val & self.max_val) << self.shift

    def __get__(self, instance: Any, owner: Any = None) -> Any:
        """Descriptor access: returns self on class access, extracted field integer on instance access."""
        if instance is None:
            return self
        return (int(instance) >> self.shift) & self.max_val

mask property

mask: int

Absolute bitmask positioned at field shift.

max_val property

max_val: int

Maximum unsigned value that fits within this bitfield width.

extract

extract(val: int) -> int

Extracts and right-shifts this bitfield's value from an integer.

Source code in src/seroverse/core/graph/models.py
def extract(self, val: int) -> int:
    """Extracts and right-shifts this bitfield's value from an integer."""
    return (val >> self.shift) & self.max_val

extract_array

extract_array(arr: ndarray) -> ndarray

Extracts and right-shifts this bitfield's value across a numpy array.

Source code in src/seroverse/core/graph/models.py
def extract_array(self, arr: np.ndarray) -> np.ndarray:
    """Extracts and right-shifts this bitfield's value across a numpy array."""
    return (arr >> self.shift) & self.max_val

pack

pack(field_val: int) -> int

Validates and shifts field_val into its bit position.

Source code in src/seroverse/core/graph/models.py
def pack(self, field_val: int) -> int:
    """Validates and shifts field_val into its bit position."""
    if not (0 <= field_val <= self.max_val):
        raise ValueError(f"Value {field_val} exceeds {self.width} bits (max {self.max_val})")
    return (field_val & self.max_val) << self.shift

NodeBase

Bases: ABC

Abstract base protocol for all core nodes.

Attributes:

Name Type Description
type_id int

Semantic integer label used in core adjacency matrices and embeddings.

is_edge_node bool

Returns True if this node represents an injected edge in a bipartite core.

Source code in src/seroverse/core/graph/models.py
class NodeBase(ABC):
    """Abstract base protocol for all core nodes."""

    @property
    @abstractmethod
    def type_id(self) -> int:
        """Semantic integer label used in core adjacency matrices and embeddings."""
        ...

    @property
    def is_edge_node(self) -> bool:
        """Returns True if this node represents an injected edge in a bipartite core."""
        return bool(self.type_id & TAG_EDGE)

type_id abstractmethod property

type_id: int

Semantic integer label used in core adjacency matrices and embeddings.

is_edge_node property

is_edge_node: bool

Returns True if this node represents an injected edge in a bipartite core.

IntNode

Bases: int, NodeBase

Base model for core nodes represented as bit-packed primitive integers.

Inherits from Python's built-in int and GraphNode, enabling zero-allocation integer node semantics across NumPy arrays, SciPy CSR matrices, and core algorithms. Hosts BitField member descriptors that automatically extract bit values when accessed on instances, while providing bitmasking, range validation, and packing at the class level.

Methods:

Name Description
mask

Constructs an absolute bitmask covering the specified bitfield names.

mask_excluding

Constructs a bitmask excluding the specified bitfield names.

pack

Packs keyword field values into a composite integer.

Source code in src/seroverse/core/graph/models.py
class IntNode(int, NodeBase):
    """Base model for core nodes represented as bit-packed primitive integers.

    Inherits from Python's built-in `int` and `GraphNode`, enabling zero-allocation
    integer node semantics across NumPy arrays, SciPy CSR matrices, and core algorithms.
    Hosts BitField member descriptors that automatically extract bit values when accessed
    on instances, while providing bitmasking, range validation, and packing at the class level.
    """

    _fields: dict[str, BitField]
    _total_bits: int = 32

    def __init_subclass__(cls, total_bits: int = 32, **kwargs: Any) -> None:
        super().__init_subclass__(**kwargs)
        cls._total_bits = total_bits
        fields: dict[str, BitField] = {}
        occupied_mask = 0

        for attr_name, attr_val in cls.__dict__.items():
            if isinstance(attr_val, BitField):
                name_upper = attr_name.upper()
                if attr_val.shift + attr_val.width > total_bits:
                    raise ValueError(
                        f"{cls.__name__}.{attr_name} (shift={attr_val.shift}, width={attr_val.width}) "
                        f"exceeds total {total_bits} bits."
                    )
                field_mask = attr_val.mask
                if (occupied_mask & field_mask) and not any(
                    f.shift == attr_val.shift and f.width == attr_val.width for f in fields.values()
                ):
                    raise ValueError(
                        f"BitField overlap detected in {cls.__name__} for field '{attr_name}' "
                        f"(shift={attr_val.shift}, width={attr_val.width})."
                    )
                occupied_mask |= field_mask
                fields[name_upper] = attr_val

        cls._fields = fields

    @property
    def type_id(self) -> int:
        return int(self)

    @property
    def is_edge_node(self) -> bool:
        return bool(int(self) & TAG_EDGE)

    @classmethod
    def get_field(cls, field_name: str) -> BitField:
        name = field_name.upper()
        if name not in cls._fields:
            raise AttributeError(f"{cls.__name__} has no BitField named '{field_name}'.")
        return cls._fields[name]

    @classmethod
    def mask(cls, *field_names: str) -> int:
        """Constructs an absolute bitmask covering the specified bitfield names."""
        m = 0
        for name in field_names:
            m |= cls.get_field(name).mask
        return m

    @classmethod
    def mask_excluding(cls, *field_names: str) -> int:
        """Constructs a bitmask excluding the specified bitfield names."""
        full_mask = (1 << cls._total_bits) - 1
        return full_mask & ~cls.mask(*field_names)

    @classmethod
    def pack(cls, **kwargs: int) -> int:
        """Packs keyword field values into a composite integer."""
        val = 0
        for name, field_val in kwargs.items():
            val |= cls.get_field(name).pack(field_val)
        return val

    @classmethod
    def extract(cls, val: int, field_name: str) -> int:
        return cls.get_field(field_name).extract(val)

    @classmethod
    def extract_array(cls, arr: np.ndarray, field_name: str) -> np.ndarray:
        return cls.get_field(field_name).extract_array(arr)

mask classmethod

mask(*field_names: str) -> int

Constructs an absolute bitmask covering the specified bitfield names.

Source code in src/seroverse/core/graph/models.py
@classmethod
def mask(cls, *field_names: str) -> int:
    """Constructs an absolute bitmask covering the specified bitfield names."""
    m = 0
    for name in field_names:
        m |= cls.get_field(name).mask
    return m

mask_excluding classmethod

mask_excluding(*field_names: str) -> int

Constructs a bitmask excluding the specified bitfield names.

Source code in src/seroverse/core/graph/models.py
@classmethod
def mask_excluding(cls, *field_names: str) -> int:
    """Constructs a bitmask excluding the specified bitfield names."""
    full_mask = (1 << cls._total_bits) - 1
    return full_mask & ~cls.mask(*field_names)

pack classmethod

pack(**kwargs: int) -> int

Packs keyword field values into a composite integer.

Source code in src/seroverse/core/graph/models.py
@classmethod
def pack(cls, **kwargs: int) -> int:
    """Packs keyword field values into a composite integer."""
    val = 0
    for name, field_val in kwargs.items():
        val |= cls.get_field(name).pack(field_val)
    return val

DirectedEdge dataclass

Base class for all directed core edges.

Source code in src/seroverse/core/graph/models.py
@dataclass(slots=True, frozen=True)
class DirectedEdge:
    """Base class for all directed core edges."""

    source: int
    target: int
    edge_type: int = 1

    def __post_init__(self) -> None:
        if self.source < 0 or self.target < 0:
            raise ValueError(
                f"DirectedEdge source and target must be non-negative node indices, got source={self.source}, target={self.target}"
            )

Graph dataclass

Bases: ScalarView

Scalar view and topological model representing a single directed graph. Provides sparse CSR conversion, multi-hop RelationalCSR construction, Laplacian spectrum calculation, and 1-Wasserstein spectral distance.

Methods:

Name Description
as_graph

Builds a sparse CSR adjacency matrix and node label array.

as_relational_csr

Build a multi-hop RelationalCSR for this graph.

laplacian_spectrum

Computes the continuous topological spectrum of the core.

spectral_distance

Computes the topological Wasserstein Distance (Earth Mover's Distance)

Source code in src/seroverse/core/graph/models.py
@dataclass(slots=True, frozen=True)
class Graph(ScalarView):
    """
    Scalar view and topological model representing a single directed graph.
    Provides sparse CSR conversion, multi-hop [`RelationalCSR`][seroverse.core.graph.csr.RelationalCSR]
    construction, Laplacian spectrum calculation, and 1-Wasserstein spectral distance.
    """

    nodes: tuple[Any, ...] = ()
    edges: tuple[DirectedEdge, ...] = ()

    @cache  # noqa: B019
    def as_graph(
        self,
        as_bipartite: bool = False,
        symmetrize: bool = False,
    ) -> tuple[CSR, np.ndarray[tuple[int], np.dtype[np.uint32]]]:
        """
        Builds a sparse CSR adjacency matrix and node label array.

        All biological and genomic models in KBank are strictly directed graphs.
        By default, returns the canonical directed representation. If a mathematical
        algorithm requires a symmetric input matrix (e.g. Laplacian spectrum calculation
        or bidirectional message passing), specify `symmetrize=True`.

        Args:
            as_bipartite: If True, treats each directed edge as an intermediate node,
                forming a bipartite incidence core where edge weights are 1.
                If False, edges directly connect primary nodes with edge weights equal
                to the integer edge type.
            symmetrize: If True, produces a symmetrized (bidirectional) adjacency matrix projection.

        Returns:
            A tuple of:
                - adj: Sparse CSR adjacency matrix of shape (total_nodes, total_nodes).
                - labels: 1D NumPy array of node labels (`np.ndarray[tuple[int], np.dtype[np.uint32]]`).
        """
        is_directed = not symmetrize

        nodes = self.nodes
        edges = self.edges
        num_primary = len(nodes)
        num_edges = len(edges)
        total_nodes = num_primary + num_edges if as_bipartite else num_primary

        if total_nodes == 0:
            return CSR.empty((0, 0), dtype=np.uint32), np.empty(0, dtype=np.uint32)

        labels = np.empty(total_nodes, dtype=np.uint32)
        for i, node in enumerate(nodes):
            labels[i] = node.type_id if hasattr(node, "type_id") else int(node)

        if num_edges == 0:
            return CSR.empty((total_nodes, total_nodes), dtype=np.uint32), labels

        sources = np.fromiter((e.source for e in edges), dtype=np.uint32, count=num_edges)
        targets = np.fromiter((e.target for e in edges), dtype=np.uint32, count=num_edges)
        edge_types = np.fromiter((int(e.edge_type) for e in edges), dtype=np.uint32, count=num_edges)

        if as_bipartite:
            edge_node_ids = np.arange(num_primary, total_nodes, dtype=np.uint32)
            labels[num_primary:total_nodes] = edge_types

            if is_directed:
                rows = np.concatenate([sources, edge_node_ids])
                cols = np.concatenate([edge_node_ids, targets])
                data = np.ones(2 * num_edges, dtype=np.uint32)
            else:
                rows = np.concatenate([sources, edge_node_ids, targets, edge_node_ids])
                cols = np.concatenate([edge_node_ids, sources, edge_node_ids, targets])
                data = np.ones(4 * num_edges, dtype=np.uint32)
        else:
            if is_directed:
                rows = sources
                cols = targets
                data = edge_types
            else:
                rows = np.concatenate([sources, targets])
                cols = np.concatenate([targets, sources])
                data = np.concatenate([edge_types, edge_types])

        adj = CSR.from_coo(rows, cols, data, shape=(total_nodes, total_nodes), dtype=np.uint32)
        return adj, labels

    def as_relational_csr(
        self,
        *,
        include_2hop: bool = True,
        add_virtual_pool: bool = True,
    ) -> RelationalCSR:
        """Build a multi-hop [`RelationalCSR`][seroverse.core.graph.csr.RelationalCSR] for this graph."""
        return RelationalCSR.from_graph(
            self,
            include_2hop=include_2hop,
            add_virtual_pool=add_virtual_pool,
        )

    def __len__(self) -> int:
        return len(self.nodes)

    @cache  # noqa: B019
    def laplacian_spectrum(self) -> np.ndarray[tuple[int], np.dtype[np.float64]]:
        """
        Computes the continuous topological spectrum of the core.

        Constructs the unweighted, symmetrized normalized core Laplacian via
        [`CSR.laplacian`][seroverse.core.graph.csr.CSR.laplacian] and solves for the sorted
        eigenvalues using :func:`numpy.linalg.eigvalsh`.

        Returns:
            A 1D sorted NumPy array of eigenvalues (`np.ndarray[tuple[int], np.dtype[np.float64]]`).
        """
        adj, _ = self.as_graph(as_bipartite=False)
        if adj.shape[0] == 0:
            return np.empty(0, dtype=np.float64)

        L_dense = adj.laplacian(normed=True, symmetrize_unweighted=True)
        return np.sort(np.linalg.eigvalsh(L_dense))

    def spectral_distance(self, other: Graph) -> float:
        """
        Computes the topological Wasserstein Distance (Earth Mover's Distance)
        between this core model and another, comparing skeletal shapes.

        Extracts the Laplacian spectra from both graphs and computes their
        Wasserstein-1 distance via a compiled Numba 1D optimal transport kernel.

        Args:
            other: Another [`Graph`][seroverse.core.graph.models.Graph] instance to compare against.

        Returns:
            Wasserstein distance between the two normalized Laplacian spectra as a float.
        """
        spec1 = self.laplacian_spectrum()
        spec2 = other.laplacian_spectrum()
        return float(_wasserstein_1d_sorted_kernel(spec1, spec2))

    def __repr__(self) -> str:
        return f"{self.__class__.__name__}({len(self.nodes)} nodes, {len(self.edges)} edges)"

as_graph cached

as_graph(
    as_bipartite: bool = False, symmetrize: bool = False
) -> tuple[CSR, ndarray[tuple[int], dtype[uint32]]]

Builds a sparse CSR adjacency matrix and node label array.

All biological and genomic models in KBank are strictly directed graphs. By default, returns the canonical directed representation. If a mathematical algorithm requires a symmetric input matrix (e.g. Laplacian spectrum calculation or bidirectional message passing), specify symmetrize=True.

Parameters:

Name Type Description Default
as_bipartite bool

If True, treats each directed edge as an intermediate node, forming a bipartite incidence core where edge weights are 1. If False, edges directly connect primary nodes with edge weights equal to the integer edge type.

False
symmetrize bool

If True, produces a symmetrized (bidirectional) adjacency matrix projection.

False

Returns:

Type Description
tuple[CSR, ndarray[tuple[int], dtype[uint32]]]

A tuple of: - adj: Sparse CSR adjacency matrix of shape (total_nodes, total_nodes). - labels: 1D NumPy array of node labels (np.ndarray[tuple[int], np.dtype[np.uint32]]).

Source code in src/seroverse/core/graph/models.py
@cache  # noqa: B019
def as_graph(
    self,
    as_bipartite: bool = False,
    symmetrize: bool = False,
) -> tuple[CSR, np.ndarray[tuple[int], np.dtype[np.uint32]]]:
    """
    Builds a sparse CSR adjacency matrix and node label array.

    All biological and genomic models in KBank are strictly directed graphs.
    By default, returns the canonical directed representation. If a mathematical
    algorithm requires a symmetric input matrix (e.g. Laplacian spectrum calculation
    or bidirectional message passing), specify `symmetrize=True`.

    Args:
        as_bipartite: If True, treats each directed edge as an intermediate node,
            forming a bipartite incidence core where edge weights are 1.
            If False, edges directly connect primary nodes with edge weights equal
            to the integer edge type.
        symmetrize: If True, produces a symmetrized (bidirectional) adjacency matrix projection.

    Returns:
        A tuple of:
            - adj: Sparse CSR adjacency matrix of shape (total_nodes, total_nodes).
            - labels: 1D NumPy array of node labels (`np.ndarray[tuple[int], np.dtype[np.uint32]]`).
    """
    is_directed = not symmetrize

    nodes = self.nodes
    edges = self.edges
    num_primary = len(nodes)
    num_edges = len(edges)
    total_nodes = num_primary + num_edges if as_bipartite else num_primary

    if total_nodes == 0:
        return CSR.empty((0, 0), dtype=np.uint32), np.empty(0, dtype=np.uint32)

    labels = np.empty(total_nodes, dtype=np.uint32)
    for i, node in enumerate(nodes):
        labels[i] = node.type_id if hasattr(node, "type_id") else int(node)

    if num_edges == 0:
        return CSR.empty((total_nodes, total_nodes), dtype=np.uint32), labels

    sources = np.fromiter((e.source for e in edges), dtype=np.uint32, count=num_edges)
    targets = np.fromiter((e.target for e in edges), dtype=np.uint32, count=num_edges)
    edge_types = np.fromiter((int(e.edge_type) for e in edges), dtype=np.uint32, count=num_edges)

    if as_bipartite:
        edge_node_ids = np.arange(num_primary, total_nodes, dtype=np.uint32)
        labels[num_primary:total_nodes] = edge_types

        if is_directed:
            rows = np.concatenate([sources, edge_node_ids])
            cols = np.concatenate([edge_node_ids, targets])
            data = np.ones(2 * num_edges, dtype=np.uint32)
        else:
            rows = np.concatenate([sources, edge_node_ids, targets, edge_node_ids])
            cols = np.concatenate([edge_node_ids, sources, edge_node_ids, targets])
            data = np.ones(4 * num_edges, dtype=np.uint32)
    else:
        if is_directed:
            rows = sources
            cols = targets
            data = edge_types
        else:
            rows = np.concatenate([sources, targets])
            cols = np.concatenate([targets, sources])
            data = np.concatenate([edge_types, edge_types])

    adj = CSR.from_coo(rows, cols, data, shape=(total_nodes, total_nodes), dtype=np.uint32)
    return adj, labels

as_relational_csr

as_relational_csr(
    *,
    include_2hop: bool = True,
    add_virtual_pool: bool = True,
) -> RelationalCSR

Build a multi-hop RelationalCSR for this graph.

Source code in src/seroverse/core/graph/models.py
def as_relational_csr(
    self,
    *,
    include_2hop: bool = True,
    add_virtual_pool: bool = True,
) -> RelationalCSR:
    """Build a multi-hop [`RelationalCSR`][seroverse.core.graph.csr.RelationalCSR] for this graph."""
    return RelationalCSR.from_graph(
        self,
        include_2hop=include_2hop,
        add_virtual_pool=add_virtual_pool,
    )

laplacian_spectrum cached

laplacian_spectrum() -> ndarray[tuple[int], dtype[float64]]

Computes the continuous topological spectrum of the core.

Constructs the unweighted, symmetrized normalized core Laplacian via CSR.laplacian and solves for the sorted eigenvalues using :func:numpy.linalg.eigvalsh.

Returns:

Type Description
ndarray[tuple[int], dtype[float64]]

A 1D sorted NumPy array of eigenvalues (np.ndarray[tuple[int], np.dtype[np.float64]]).

Source code in src/seroverse/core/graph/models.py
@cache  # noqa: B019
def laplacian_spectrum(self) -> np.ndarray[tuple[int], np.dtype[np.float64]]:
    """
    Computes the continuous topological spectrum of the core.

    Constructs the unweighted, symmetrized normalized core Laplacian via
    [`CSR.laplacian`][seroverse.core.graph.csr.CSR.laplacian] and solves for the sorted
    eigenvalues using :func:`numpy.linalg.eigvalsh`.

    Returns:
        A 1D sorted NumPy array of eigenvalues (`np.ndarray[tuple[int], np.dtype[np.float64]]`).
    """
    adj, _ = self.as_graph(as_bipartite=False)
    if adj.shape[0] == 0:
        return np.empty(0, dtype=np.float64)

    L_dense = adj.laplacian(normed=True, symmetrize_unweighted=True)
    return np.sort(np.linalg.eigvalsh(L_dense))

spectral_distance

spectral_distance(other: Graph) -> float

Computes the topological Wasserstein Distance (Earth Mover's Distance) between this core model and another, comparing skeletal shapes.

Extracts the Laplacian spectra from both graphs and computes their Wasserstein-1 distance via a compiled Numba 1D optimal transport kernel.

Parameters:

Name Type Description Default
other Graph

Another Graph instance to compare against.

required

Returns:

Type Description
float

Wasserstein distance between the two normalized Laplacian spectra as a float.

Source code in src/seroverse/core/graph/models.py
def spectral_distance(self, other: Graph) -> float:
    """
    Computes the topological Wasserstein Distance (Earth Mover's Distance)
    between this core model and another, comparing skeletal shapes.

    Extracts the Laplacian spectra from both graphs and computes their
    Wasserstein-1 distance via a compiled Numba 1D optimal transport kernel.

    Args:
        other: Another [`Graph`][seroverse.core.graph.models.Graph] instance to compare against.

    Returns:
        Wasserstein distance between the two normalized Laplacian spectra as a float.
    """
    spec1 = self.laplacian_spectrum()
    spec2 = other.laplacian_spectrum()
    return float(_wasserstein_1d_sorted_kernel(spec1, spec2))

Graphs dataclass

Bases: BatchedContainer[T, S]

Base Structure-of-Arrays (SoA) container for batched graph datasets. Provides block-diagonal CSR adjacency, Wasserstein spectral distance matrices, pan-graph compression, and vectorized SoA array slicing and concatenation.

Methods:

Name Description
empty

Create an empty 0-length graph batch.

concat

Concatenate multiple graph batches into a single contiguous batch.

from_list

Pack a sequence of Graph instances into a Graphs batch.

random

Generate a random Graphs batch using the Erdős–Rényi \(G(n, p)\) model.

get_block_diagonal_graph

Returns a single disconnected CSR core representing the entire batch.

as_relational_csr

Build a multi-hop RelationalCSR for this batch.

spectral_distance_matrix

Computes the all-vs-all continuous topological distance matrix across the batch.

as_pan_graph

Compresses the SoA batch into a unified directed pan-core network.

Attributes:

Name Type Description
node_lengths UInt32Array

Number of nodes in each graph in the batch.

edge_lengths UInt32Array

Number of edges in each graph in the batch.

Source code in src/seroverse/core/graph/models.py
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
@dataclass(slots=True, frozen=True)
class Graphs[T: Graph = Graph, S: Graphs[Any, Any] = Graphs[Any, Any]](BatchedContainer[T, S]):
    """
    Base Structure-of-Arrays (SoA) container for batched graph datasets.
    Provides block-diagonal CSR adjacency, Wasserstein spectral distance matrices,
    pan-graph compression, and vectorized SoA array slicing and concatenation.
    """

    node_types: UInt32Array
    node_offsets: UInt32Array
    edge_sources: UInt32Array
    edge_targets: UInt32Array
    edge_types: UInt32Array
    edge_offsets: UInt32Array

    def __len__(self) -> int:
        return len(self.node_offsets) - 1

    @property
    def total_nodes(self) -> int:
        return len(self.node_types)

    @property
    def total_edges(self) -> int:
        return len(self.edge_sources)

    @property
    def node_lengths(self) -> UInt32Array:
        """Number of nodes in each graph in the batch."""
        return np.diff(self.node_offsets)

    @property
    def edge_lengths(self) -> UInt32Array:
        """Number of edges in each graph in the batch."""
        return np.diff(self.edge_offsets)

    @classmethod
    def empty(cls) -> Self:
        """Create an empty 0-length graph batch."""
        return cls(
            node_types=np.empty(0, dtype=np.uint32),
            node_offsets=np.zeros(1, dtype=np.uint32),
            edge_sources=np.empty(0, dtype=np.uint32),
            edge_targets=np.empty(0, dtype=np.uint32),
            edge_types=np.empty(0, dtype=np.uint32),
            edge_offsets=np.zeros(1, dtype=np.uint32),
        )

    @classmethod
    def concat(cls, batches: Iterable[Self]) -> Self:
        """Concatenate multiple graph batches into a single contiguous batch."""
        batch_list: list[Self] = [b for b in batches if len(b) > 0]
        if not batch_list:
            return cls.empty()
        if len(batch_list) == 1:
            return batch_list[0]

        nodes, n_offs, sources, targets, types, e_offs = cls._concat_soa_base(batch_list)
        return cls(
            node_types=nodes,
            node_offsets=n_offs,
            edge_sources=sources,
            edge_targets=targets,
            edge_types=types,
            edge_offsets=e_offs,
        )

    @classmethod
    def from_list(cls, items: Sequence[T]) -> Self:
        """Pack a sequence of [`Graph`][seroverse.core.graph.models.Graph] instances into a [`Graphs`][seroverse.core.graph.models.Graphs] batch."""
        n_graphs = len(items)
        if n_graphs == 0:
            return cls.empty()

        node_offsets = np.zeros(n_graphs + 1, dtype=np.uint32)
        edge_offsets = np.zeros(n_graphs + 1, dtype=np.uint32)
        tot_nodes = sum(len(g.nodes) for g in items)
        tot_edges = sum(len(g.edges) for g in items)

        node_types = np.empty(tot_nodes, dtype=np.uint32)
        edge_sources = np.empty(tot_edges, dtype=np.uint32)
        edge_targets = np.empty(tot_edges, dtype=np.uint32)
        edge_types = np.empty(tot_edges, dtype=np.uint32)

        curr_n = 0
        curr_e = 0
        for i, g in enumerate(items):
            gn = len(g.nodes)
            ge = len(g.edges)
            for j, node in enumerate(g.nodes):
                node_types[curr_n + j] = node.type_id if hasattr(node, "type_id") else int(node)
            for j, edge in enumerate(g.edges):
                edge_sources[curr_e + j] = edge.source + curr_n
                edge_targets[curr_e + j] = edge.target + curr_n
                edge_types[curr_e + j] = int(edge.edge_type)
            curr_n += gn
            curr_e += ge
            node_offsets[i + 1] = curr_n
            edge_offsets[i + 1] = curr_e

        return cls(
            node_types=node_types,
            node_offsets=node_offsets,
            edge_sources=edge_sources,
            edge_targets=edge_targets,
            edge_types=edge_types,
            edge_offsets=edge_offsets,
        )

    @classmethod
    def random(
        cls,
        n_graphs: int = 10,
        n_nodes: int | tuple[int, int] = (5, 15),
        p: float = 0.25,
        *,
        n_node_types: int = 8,
        n_edge_types: int = 4,
        directed: bool = True,
        allow_self_loops: bool = False,
        rng: np.random.Generator | int | None = None,
    ) -> Self:
        r"""Generate a random [`Graphs`][seroverse.core.graph.models.Graphs] batch using the Erdős–Rényi $G(n, p)$ model.

        Args:
            n_graphs: Number of graphs in the batch (`n_graphs >= 0`). Defaults to `10`.
            n_nodes: Either a fixed integer node count per graph or an inclusive `(min_nodes, max_nodes)`
                2-tuple sampled uniformly per graph. Defaults to `(5, 15)`.
            p: Edge inclusion probability $p \in [0, 1]$. Defaults to `0.25`.
            n_node_types: Number of distinct integer node types sampled uniformly in `[1, n_node_types]`. Defaults to `8`.
            n_edge_types: Number of distinct integer edge types sampled uniformly in `[1, n_edge_types]`. Defaults to `4`.
            directed: If `True` (default), generates independent directed edges $(u \to v)$. If `False`,
                generates symmetric undirected edges $(u \leftrightarrow v)$.
            allow_self_loops: If `True`, permits self-loops $(u \to u)$. Defaults to `False`.
            rng: Optional `np.random.Generator`, integer seed, or `None` passed to `np.random.default_rng`.

        Returns:
            Self: A newly generated [`Graphs`][seroverse.core.graph.models.Graphs] instance.
        """
        if n_graphs < 0:
            raise ValueError(f"n_graphs must be >= 0; got {n_graphs}.")
        if not (0.0 <= p <= 1.0):
            raise ValueError(f"Edge probability p must be in [0.0, 1.0]; got {p}.")
        if n_node_types < 1 or n_edge_types < 1:
            raise ValueError(f"n_node_types ({n_node_types}) and n_edge_types ({n_edge_types}) must be >= 1.")
        if n_graphs == 0:
            return cls.empty()

        gen = np.random.default_rng(rng)
        if isinstance(n_nodes, (int, np.integer)):
            if int(n_nodes) < 0:
                raise ValueError(f"n_nodes must be >= 0; got {n_nodes}.")
            node_lens = np.full(n_graphs, int(n_nodes), dtype=np.uint32)
        else:
            min_n, max_n = int(n_nodes[0]), int(n_nodes[1])
            if min_n < 0 or max_n < min_n:
                raise ValueError(f"Invalid n_nodes range ({min_n}, {max_n}); require 0 <= min_nodes <= max_nodes.")
            node_lens = gen.integers(min_n, max_n + 1, size=n_graphs, dtype=np.uint32)

        node_offsets = np.zeros(n_graphs + 1, dtype=np.uint32)
        np.cumsum(node_lens, dtype=np.uint32, out=node_offsets[1:])
        total_nodes = int(node_offsets[-1])
        node_types = gen.integers(1, n_node_types + 1, size=total_nodes, dtype=np.uint32)

        seed = np.uint64(gen.integers(1, (1 << 63) - 1, dtype=np.uint64))
        edge_sources, edge_targets, edge_types, edge_offsets = _erdos_renyi_edges_kernel(
            node_offsets,
            float(p),
            int(n_edge_types),
            bool(directed),
            bool(allow_self_loops),
            seed,
        )
        return cls(
            node_types=node_types,
            node_offsets=node_offsets,
            edge_sources=edge_sources,
            edge_targets=edge_targets,
            edge_types=edge_types,
            edge_offsets=edge_offsets,
        )

    def _get_scalar(self, idx: int) -> T:
        """Materialize the [`Graph`][seroverse.core.graph.models.Graph] at index `idx`."""
        n_s, n_e = int(self.node_offsets[idx]), int(self.node_offsets[idx + 1])
        e_s, e_e = int(self.edge_offsets[idx]), int(self.edge_offsets[idx + 1])
        nodes = tuple(int(x) for x in self.node_types[n_s:n_e])
        edges = tuple(
            DirectedEdge(
                source=int(self.edge_sources[k]) - n_s,
                target=int(self.edge_targets[k]) - n_s,
                edge_type=int(self.edge_types[k]),
            )
            for k in range(e_s, e_e)
        )
        return cast(T, Graph(nodes=nodes, edges=edges))

    def _get_slice(self, item: slice | np.ndarray[Any, Any] | list[Any]) -> S:
        """Slice a sub-batch of graphs by slice, boolean mask, or index array."""
        indices = self._normalize_indices(item)
        if len(indices) == 0:
            return cast(S, self.empty())
        nodes_arr, n_offs, sources, targets, types, e_offs = self._slice_soa_arrays(indices)
        return cast(
            S,
            self.__class__(
                node_types=nodes_arr,
                node_offsets=n_offs,
                edge_sources=sources,
                edge_targets=targets,
                edge_types=types,
                edge_offsets=e_offs,
            ),
        )

    def get_block_diagonal_graph(self, unweighted: bool = False) -> CSR:
        """
        Returns a single disconnected CSR core representing the entire batch.

        Args:
            unweighted: If True, sets all non-zero edge weights to 1;
                otherwise retains the integer edge types.

        Returns:
            Block-diagonal sparse CSR adjacency matrix of shape (total_nodes, total_nodes).
        """
        N = len(self.node_types)
        if N == 0 or len(self.edge_sources) == 0:
            return CSR.empty((N, N), dtype=np.uint32)

        weights = np.ones(len(self.edge_types), dtype=np.uint32) if unweighted else self.edge_types
        return CSR.from_coo(self.edge_sources, self.edge_targets, weights, shape=(N, N), dtype=np.uint32)

    def as_relational_csr(
        self,
        edge_orientations: Sequence[int] | np.ndarray | None = None,
        *,
        include_2hop: bool = True,
        add_virtual_pool: bool = True,
    ) -> RelationalCSR:
        """Build a multi-hop [`RelationalCSR`][seroverse.core.graph.csr.RelationalCSR] for this batch."""
        return RelationalCSR.from_batch(
            self,
            edge_orientations=edge_orientations,
            include_2hop=include_2hop,
            add_virtual_pool=add_virtual_pool,
        )

    def spectral_distance_matrix(self) -> np.ndarray[tuple[int, int], np.dtype[np.float64]]:
        """
        Computes the all-vs-all continuous topological distance matrix across the batch.

        Extracts per-core normalized Laplacian spectra and computes pairwise
        Wasserstein-1 distances in parallel using compiled Numba kernels.

        Returns:
            A dense symmetric matrix (N x N) of Wasserstein distances (`np.ndarray[tuple[int, int], np.dtype[np.float64]]`).
        """
        return _batch_spectral_distance_matrix_kernel(
            self.node_offsets,
            self.edge_sources,
            self.edge_targets,
            self.edge_offsets,
        )

    def as_pan_graph(
        self,
        node_mask: int | None = None,
    ) -> tuple[
        CSR,
        np.ndarray[tuple[int], np.dtype[np.uint32]],
        np.ndarray[tuple[int], np.dtype[np.uint32]],
    ]:
        """
        Compresses the SoA batch into a unified directed pan-core network.
        Merges identical nodes, records node occurrence frequencies,
        and computes the exact occurrence frequency (count) of each directed transition.

        Pan-graphs in KBank are strictly directed consensus networks representing
        synteny flow and biosynthetic transitions.

        Args:
            node_mask: Optional bitmask to apply to node types before merging.

        Returns:
            A tuple of:
                - adj: Sparse CSR pan-core adjacency matrix of shape (num_pan_nodes, num_pan_nodes)
                  where data values represent transition occurrence counts.
                - pan_nodes: 1D array of pan-node integer labels (`np.ndarray[tuple[int], np.dtype[np.uint32]]`).
                - node_counts: 1D array of frequencies indicating how many original nodes merged into
                  each pan-node (`np.ndarray[tuple[int], np.dtype[np.uint32]]`).
        """
        N = len(self.node_types)
        if N == 0:
            return CSR.empty((0, 0), dtype=np.uint32), np.empty(0, dtype=np.uint32), np.empty(0, dtype=np.uint32)

        masked_types = (self.node_types & node_mask) if node_mask is not None else self.node_types
        pan_nodes, inverse_indices, node_counts = np.unique(masked_types, return_inverse=True, return_counts=True)
        num_pan_nodes = len(pan_nodes)

        if len(self.edge_sources) == 0:
            return CSR.empty((num_pan_nodes, num_pan_nodes), dtype=np.uint32), pan_nodes, node_counts.astype(np.uint32)

        p_src = inverse_indices[self.edge_sources]
        p_tgt = inverse_indices[self.edge_targets]

        adj = CSR.from_coo(
            p_src,
            p_tgt,
            np.ones(len(p_src), dtype=np.uint32),
            shape=(num_pan_nodes, num_pan_nodes),
            dtype=np.uint32,
            sum_duplicates=True,
        )
        return adj, pan_nodes, node_counts.astype(np.uint32)

    def _slice_soa_arrays(
        self, kept_indices: np.ndarray[tuple[int], np.dtype[np.integer[Any]]]
    ) -> tuple[
        np.ndarray[tuple[int], np.dtype[np.uint32]],
        np.ndarray[tuple[int], np.dtype[np.uint32]],
        np.ndarray[tuple[int], np.dtype[np.uint32]],
        np.ndarray[tuple[int], np.dtype[np.uint32]],
        np.ndarray[tuple[int], np.dtype[np.uint32]],
        np.ndarray[tuple[int], np.dtype[np.uint32]],
    ]:
        """
        Vectorized helper to slice the core SoA arrays for a subset of graphs.
        """
        num_kept = len(kept_indices)
        if num_kept == 0:
            return (
                np.array([], dtype=np.uint32),
                np.zeros(1, dtype=np.uint32),
                np.array([], dtype=np.uint32),
                np.array([], dtype=np.uint32),
                np.array([], dtype=np.uint32),
                np.zeros(1, dtype=np.uint32),
            )

        idx = np.ascontiguousarray(kept_indices, dtype=np.int64)
        node_lens = self.node_offsets[idx + 1] - self.node_offsets[idx]
        edge_lens = self.edge_offsets[idx + 1] - self.edge_offsets[idx]

        new_node_offsets = np.empty(num_kept + 1, dtype=np.uint32)
        new_node_offsets[0] = 0
        np.cumsum(node_lens, dtype=np.uint32, out=new_node_offsets[1:])

        new_edge_offsets = np.empty(num_kept + 1, dtype=np.uint32)
        new_edge_offsets[0] = 0
        np.cumsum(edge_lens, dtype=np.uint32, out=new_edge_offsets[1:])

        new_nodes, new_sources, new_targets, new_types = _slice_graph_batch_kernel(
            idx,
            self.node_types,
            self.node_offsets,
            self.edge_sources,
            self.edge_targets,
            self.edge_types,
            self.edge_offsets,
            new_node_offsets,
            new_edge_offsets,
        )
        return (
            new_nodes,
            new_node_offsets,
            new_sources,
            new_targets,
            new_types,
            new_edge_offsets,
        )

    @classmethod
    def _concat_soa_base(
        cls, batches: Sequence[Any]
    ) -> tuple[
        np.ndarray[tuple[int], np.dtype[np.uint32]],
        np.ndarray[tuple[int], np.dtype[np.uint32]],
        np.ndarray[tuple[int], np.dtype[np.uint32]],
        np.ndarray[tuple[int], np.dtype[np.uint32]],
        np.ndarray[tuple[int], np.dtype[np.uint32]],
        np.ndarray[tuple[int], np.dtype[np.uint32]],
    ]:
        """
        Fast vectorized concatenation of core SoA arrays across multiple batches.
        Shifts node_offsets, edge_offsets, and rebases edge_sources and edge_targets.
        """
        if not batches:
            return (
                np.empty(0, dtype=np.uint32),
                np.zeros(1, dtype=np.uint32),
                np.empty(0, dtype=np.uint32),
                np.empty(0, dtype=np.uint32),
                np.empty(0, dtype=np.uint32),
                np.zeros(1, dtype=np.uint32),
            )

        new_node_types = np.concatenate([b.node_types for b in batches])
        new_edge_types = np.concatenate([b.edge_types for b in batches])

        total_graphs = sum(len(b) for b in batches)
        new_node_offsets = np.empty(total_graphs + 1, dtype=np.uint32)
        new_node_offsets[0] = 0
        new_edge_offsets = np.empty(total_graphs + 1, dtype=np.uint32)
        new_edge_offsets[0] = 0

        sources_list: list[np.ndarray] = []
        targets_list: list[np.ndarray] = []
        node_shift = 0
        edge_shift = 0
        curr_g = 0

        for b in batches:
            n_g = len(b)
            if n_g > 0:
                new_node_offsets[curr_g + 1 : curr_g + 1 + n_g] = b.node_offsets[1:] + node_shift
                new_edge_offsets[curr_g + 1 : curr_g + 1 + n_g] = b.edge_offsets[1:] + edge_shift
                curr_g += n_g

            if len(b.edge_sources) > 0:
                sources_list.append(b.edge_sources + node_shift)
                targets_list.append(b.edge_targets + node_shift)

            node_shift += len(b.node_types)
            edge_shift += len(b.edge_types)

        new_sources = np.concatenate(sources_list) if sources_list else np.empty(0, dtype=np.uint32)
        new_targets = np.concatenate(targets_list) if targets_list else np.empty(0, dtype=np.uint32)

        return (
            new_node_types,
            new_node_offsets,
            new_sources,
            new_targets,
            new_edge_types,
            new_edge_offsets,
        )

    def __repr__(self) -> str:
        return (
            f"{self.__class__.__name__}({len(self):,} items | {self.total_nodes:,} nodes, {self.total_edges:,} edges)"
        )

node_lengths property

node_lengths: UInt32Array

Number of nodes in each graph in the batch.

edge_lengths property

edge_lengths: UInt32Array

Number of edges in each graph in the batch.

empty classmethod

empty() -> Self

Create an empty 0-length graph batch.

Source code in src/seroverse/core/graph/models.py
@classmethod
def empty(cls) -> Self:
    """Create an empty 0-length graph batch."""
    return cls(
        node_types=np.empty(0, dtype=np.uint32),
        node_offsets=np.zeros(1, dtype=np.uint32),
        edge_sources=np.empty(0, dtype=np.uint32),
        edge_targets=np.empty(0, dtype=np.uint32),
        edge_types=np.empty(0, dtype=np.uint32),
        edge_offsets=np.zeros(1, dtype=np.uint32),
    )

concat classmethod

concat(batches: Iterable[Self]) -> Self

Concatenate multiple graph batches into a single contiguous batch.

Source code in src/seroverse/core/graph/models.py
@classmethod
def concat(cls, batches: Iterable[Self]) -> Self:
    """Concatenate multiple graph batches into a single contiguous batch."""
    batch_list: list[Self] = [b for b in batches if len(b) > 0]
    if not batch_list:
        return cls.empty()
    if len(batch_list) == 1:
        return batch_list[0]

    nodes, n_offs, sources, targets, types, e_offs = cls._concat_soa_base(batch_list)
    return cls(
        node_types=nodes,
        node_offsets=n_offs,
        edge_sources=sources,
        edge_targets=targets,
        edge_types=types,
        edge_offsets=e_offs,
    )

from_list classmethod

from_list(items: Sequence[T]) -> Self

Pack a sequence of Graph instances into a Graphs batch.

Source code in src/seroverse/core/graph/models.py
@classmethod
def from_list(cls, items: Sequence[T]) -> Self:
    """Pack a sequence of [`Graph`][seroverse.core.graph.models.Graph] instances into a [`Graphs`][seroverse.core.graph.models.Graphs] batch."""
    n_graphs = len(items)
    if n_graphs == 0:
        return cls.empty()

    node_offsets = np.zeros(n_graphs + 1, dtype=np.uint32)
    edge_offsets = np.zeros(n_graphs + 1, dtype=np.uint32)
    tot_nodes = sum(len(g.nodes) for g in items)
    tot_edges = sum(len(g.edges) for g in items)

    node_types = np.empty(tot_nodes, dtype=np.uint32)
    edge_sources = np.empty(tot_edges, dtype=np.uint32)
    edge_targets = np.empty(tot_edges, dtype=np.uint32)
    edge_types = np.empty(tot_edges, dtype=np.uint32)

    curr_n = 0
    curr_e = 0
    for i, g in enumerate(items):
        gn = len(g.nodes)
        ge = len(g.edges)
        for j, node in enumerate(g.nodes):
            node_types[curr_n + j] = node.type_id if hasattr(node, "type_id") else int(node)
        for j, edge in enumerate(g.edges):
            edge_sources[curr_e + j] = edge.source + curr_n
            edge_targets[curr_e + j] = edge.target + curr_n
            edge_types[curr_e + j] = int(edge.edge_type)
        curr_n += gn
        curr_e += ge
        node_offsets[i + 1] = curr_n
        edge_offsets[i + 1] = curr_e

    return cls(
        node_types=node_types,
        node_offsets=node_offsets,
        edge_sources=edge_sources,
        edge_targets=edge_targets,
        edge_types=edge_types,
        edge_offsets=edge_offsets,
    )

random classmethod

random(
    n_graphs: int = 10,
    n_nodes: int | tuple[int, int] = (5, 15),
    p: float = 0.25,
    *,
    n_node_types: int = 8,
    n_edge_types: int = 4,
    directed: bool = True,
    allow_self_loops: bool = False,
    rng: Generator | int | None = None,
) -> Self

Generate a random Graphs batch using the Erdős–Rényi \(G(n, p)\) model.

Parameters:

Name Type Description Default
n_graphs int

Number of graphs in the batch (n_graphs >= 0). Defaults to 10.

10
n_nodes int | tuple[int, int]

Either a fixed integer node count per graph or an inclusive (min_nodes, max_nodes) 2-tuple sampled uniformly per graph. Defaults to (5, 15).

(5, 15)
p float

Edge inclusion probability \(p \in [0, 1]\). Defaults to 0.25.

0.25
n_node_types int

Number of distinct integer node types sampled uniformly in [1, n_node_types]. Defaults to 8.

8
n_edge_types int

Number of distinct integer edge types sampled uniformly in [1, n_edge_types]. Defaults to 4.

4
directed bool

If True (default), generates independent directed edges \((u \to v)\). If False, generates symmetric undirected edges \((u \leftrightarrow v)\).

True
allow_self_loops bool

If True, permits self-loops \((u \to u)\). Defaults to False.

False
rng Generator | int | None

Optional np.random.Generator, integer seed, or None passed to np.random.default_rng.

None

Returns:

Name Type Description
Self Self

A newly generated Graphs instance.

Source code in src/seroverse/core/graph/models.py
@classmethod
def random(
    cls,
    n_graphs: int = 10,
    n_nodes: int | tuple[int, int] = (5, 15),
    p: float = 0.25,
    *,
    n_node_types: int = 8,
    n_edge_types: int = 4,
    directed: bool = True,
    allow_self_loops: bool = False,
    rng: np.random.Generator | int | None = None,
) -> Self:
    r"""Generate a random [`Graphs`][seroverse.core.graph.models.Graphs] batch using the Erdős–Rényi $G(n, p)$ model.

    Args:
        n_graphs: Number of graphs in the batch (`n_graphs >= 0`). Defaults to `10`.
        n_nodes: Either a fixed integer node count per graph or an inclusive `(min_nodes, max_nodes)`
            2-tuple sampled uniformly per graph. Defaults to `(5, 15)`.
        p: Edge inclusion probability $p \in [0, 1]$. Defaults to `0.25`.
        n_node_types: Number of distinct integer node types sampled uniformly in `[1, n_node_types]`. Defaults to `8`.
        n_edge_types: Number of distinct integer edge types sampled uniformly in `[1, n_edge_types]`. Defaults to `4`.
        directed: If `True` (default), generates independent directed edges $(u \to v)$. If `False`,
            generates symmetric undirected edges $(u \leftrightarrow v)$.
        allow_self_loops: If `True`, permits self-loops $(u \to u)$. Defaults to `False`.
        rng: Optional `np.random.Generator`, integer seed, or `None` passed to `np.random.default_rng`.

    Returns:
        Self: A newly generated [`Graphs`][seroverse.core.graph.models.Graphs] instance.
    """
    if n_graphs < 0:
        raise ValueError(f"n_graphs must be >= 0; got {n_graphs}.")
    if not (0.0 <= p <= 1.0):
        raise ValueError(f"Edge probability p must be in [0.0, 1.0]; got {p}.")
    if n_node_types < 1 or n_edge_types < 1:
        raise ValueError(f"n_node_types ({n_node_types}) and n_edge_types ({n_edge_types}) must be >= 1.")
    if n_graphs == 0:
        return cls.empty()

    gen = np.random.default_rng(rng)
    if isinstance(n_nodes, (int, np.integer)):
        if int(n_nodes) < 0:
            raise ValueError(f"n_nodes must be >= 0; got {n_nodes}.")
        node_lens = np.full(n_graphs, int(n_nodes), dtype=np.uint32)
    else:
        min_n, max_n = int(n_nodes[0]), int(n_nodes[1])
        if min_n < 0 or max_n < min_n:
            raise ValueError(f"Invalid n_nodes range ({min_n}, {max_n}); require 0 <= min_nodes <= max_nodes.")
        node_lens = gen.integers(min_n, max_n + 1, size=n_graphs, dtype=np.uint32)

    node_offsets = np.zeros(n_graphs + 1, dtype=np.uint32)
    np.cumsum(node_lens, dtype=np.uint32, out=node_offsets[1:])
    total_nodes = int(node_offsets[-1])
    node_types = gen.integers(1, n_node_types + 1, size=total_nodes, dtype=np.uint32)

    seed = np.uint64(gen.integers(1, (1 << 63) - 1, dtype=np.uint64))
    edge_sources, edge_targets, edge_types, edge_offsets = _erdos_renyi_edges_kernel(
        node_offsets,
        float(p),
        int(n_edge_types),
        bool(directed),
        bool(allow_self_loops),
        seed,
    )
    return cls(
        node_types=node_types,
        node_offsets=node_offsets,
        edge_sources=edge_sources,
        edge_targets=edge_targets,
        edge_types=edge_types,
        edge_offsets=edge_offsets,
    )

get_block_diagonal_graph

get_block_diagonal_graph(unweighted: bool = False) -> CSR

Returns a single disconnected CSR core representing the entire batch.

Parameters:

Name Type Description Default
unweighted bool

If True, sets all non-zero edge weights to 1; otherwise retains the integer edge types.

False

Returns:

Type Description
CSR

Block-diagonal sparse CSR adjacency matrix of shape (total_nodes, total_nodes).

Source code in src/seroverse/core/graph/models.py
def get_block_diagonal_graph(self, unweighted: bool = False) -> CSR:
    """
    Returns a single disconnected CSR core representing the entire batch.

    Args:
        unweighted: If True, sets all non-zero edge weights to 1;
            otherwise retains the integer edge types.

    Returns:
        Block-diagonal sparse CSR adjacency matrix of shape (total_nodes, total_nodes).
    """
    N = len(self.node_types)
    if N == 0 or len(self.edge_sources) == 0:
        return CSR.empty((N, N), dtype=np.uint32)

    weights = np.ones(len(self.edge_types), dtype=np.uint32) if unweighted else self.edge_types
    return CSR.from_coo(self.edge_sources, self.edge_targets, weights, shape=(N, N), dtype=np.uint32)

as_relational_csr

as_relational_csr(
    edge_orientations: Sequence[int]
    | ndarray
    | None = None,
    *,
    include_2hop: bool = True,
    add_virtual_pool: bool = True,
) -> RelationalCSR

Build a multi-hop RelationalCSR for this batch.

Source code in src/seroverse/core/graph/models.py
def as_relational_csr(
    self,
    edge_orientations: Sequence[int] | np.ndarray | None = None,
    *,
    include_2hop: bool = True,
    add_virtual_pool: bool = True,
) -> RelationalCSR:
    """Build a multi-hop [`RelationalCSR`][seroverse.core.graph.csr.RelationalCSR] for this batch."""
    return RelationalCSR.from_batch(
        self,
        edge_orientations=edge_orientations,
        include_2hop=include_2hop,
        add_virtual_pool=add_virtual_pool,
    )

spectral_distance_matrix

spectral_distance_matrix() -> ndarray[
    tuple[int, int], dtype[float64]
]

Computes the all-vs-all continuous topological distance matrix across the batch.

Extracts per-core normalized Laplacian spectra and computes pairwise Wasserstein-1 distances in parallel using compiled Numba kernels.

Returns:

Type Description
ndarray[tuple[int, int], dtype[float64]]

A dense symmetric matrix (N x N) of Wasserstein distances (np.ndarray[tuple[int, int], np.dtype[np.float64]]).

Source code in src/seroverse/core/graph/models.py
def spectral_distance_matrix(self) -> np.ndarray[tuple[int, int], np.dtype[np.float64]]:
    """
    Computes the all-vs-all continuous topological distance matrix across the batch.

    Extracts per-core normalized Laplacian spectra and computes pairwise
    Wasserstein-1 distances in parallel using compiled Numba kernels.

    Returns:
        A dense symmetric matrix (N x N) of Wasserstein distances (`np.ndarray[tuple[int, int], np.dtype[np.float64]]`).
    """
    return _batch_spectral_distance_matrix_kernel(
        self.node_offsets,
        self.edge_sources,
        self.edge_targets,
        self.edge_offsets,
    )

as_pan_graph

as_pan_graph(
    node_mask: int | None = None,
) -> tuple[
    CSR,
    ndarray[tuple[int], dtype[uint32]],
    ndarray[tuple[int], dtype[uint32]],
]

Compresses the SoA batch into a unified directed pan-core network. Merges identical nodes, records node occurrence frequencies, and computes the exact occurrence frequency (count) of each directed transition.

Pan-graphs in KBank are strictly directed consensus networks representing synteny flow and biosynthetic transitions.

Parameters:

Name Type Description Default
node_mask int | None

Optional bitmask to apply to node types before merging.

None

Returns:

Type Description
tuple[CSR, ndarray[tuple[int], dtype[uint32]], ndarray[tuple[int], dtype[uint32]]]

A tuple of: - adj: Sparse CSR pan-core adjacency matrix of shape (num_pan_nodes, num_pan_nodes) where data values represent transition occurrence counts. - pan_nodes: 1D array of pan-node integer labels (np.ndarray[tuple[int], np.dtype[np.uint32]]). - node_counts: 1D array of frequencies indicating how many original nodes merged into each pan-node (np.ndarray[tuple[int], np.dtype[np.uint32]]).

Source code in src/seroverse/core/graph/models.py
def as_pan_graph(
    self,
    node_mask: int | None = None,
) -> tuple[
    CSR,
    np.ndarray[tuple[int], np.dtype[np.uint32]],
    np.ndarray[tuple[int], np.dtype[np.uint32]],
]:
    """
    Compresses the SoA batch into a unified directed pan-core network.
    Merges identical nodes, records node occurrence frequencies,
    and computes the exact occurrence frequency (count) of each directed transition.

    Pan-graphs in KBank are strictly directed consensus networks representing
    synteny flow and biosynthetic transitions.

    Args:
        node_mask: Optional bitmask to apply to node types before merging.

    Returns:
        A tuple of:
            - adj: Sparse CSR pan-core adjacency matrix of shape (num_pan_nodes, num_pan_nodes)
              where data values represent transition occurrence counts.
            - pan_nodes: 1D array of pan-node integer labels (`np.ndarray[tuple[int], np.dtype[np.uint32]]`).
            - node_counts: 1D array of frequencies indicating how many original nodes merged into
              each pan-node (`np.ndarray[tuple[int], np.dtype[np.uint32]]`).
    """
    N = len(self.node_types)
    if N == 0:
        return CSR.empty((0, 0), dtype=np.uint32), np.empty(0, dtype=np.uint32), np.empty(0, dtype=np.uint32)

    masked_types = (self.node_types & node_mask) if node_mask is not None else self.node_types
    pan_nodes, inverse_indices, node_counts = np.unique(masked_types, return_inverse=True, return_counts=True)
    num_pan_nodes = len(pan_nodes)

    if len(self.edge_sources) == 0:
        return CSR.empty((num_pan_nodes, num_pan_nodes), dtype=np.uint32), pan_nodes, node_counts.astype(np.uint32)

    p_src = inverse_indices[self.edge_sources]
    p_tgt = inverse_indices[self.edge_targets]

    adj = CSR.from_coo(
        p_src,
        p_tgt,
        np.ones(len(p_src), dtype=np.uint32),
        shape=(num_pan_nodes, num_pan_nodes),
        dtype=np.uint32,
        sum_duplicates=True,
    )
    return adj, pan_nodes, node_counts.astype(np.uint32)

align

Generic bipartite graph alignment framework with pluggable cost strategies and topological embeddings.

Classes:

Name Description
AlignmentConfig

Immutable configuration for bipartite graph alignment solvers.

EmbeddingStrategy

Abstract base strategy for computing continuous topological node embeddings.

WeisfeilerLehmanEmbedding

Computes generalized continuous Weisfeiler-Lehman topological smoothing.

DegreeProfileEmbedding

Computes in-degree, out-degree, and total degree profiles for each node.

CostStrategy

Abstract base strategy for computing semantic node compatibility cost matrices.

ExactMatchCostStrategy

Isomorphic cost model: 0.0 for identical node types, infinite penalty for mismatches.

CallableCostStrategy

Adapter wrapping custom user-defined cost functions.

Alignment

Outcome of bipartite graph alignment containing matched node pairs, costs, and unmatched nodes.

GraphAligner

Configurable bipartite graph alignment solver using Hungarian assignment.

IsomorphicGraphAligner

Specialized aligner pre-configured for same-domain structural isomorphism matching.

AlignmentConfig dataclass

Immutable configuration for bipartite graph alignment solvers.

Methods:

Name Description
default

Return default AlignmentConfig.

rigid

Strict alignment prioritizing rigid semantic constraints over topology.

topological

Relaxed alignment prioritizing topological graph flow smoothing.

Source code in src/seroverse/core/graph/align.py
@dataclass(frozen=True, slots=True)
class AlignmentConfig:
    """Immutable configuration for bipartite graph alignment solvers."""

    iterations: int = 2
    topo_weight: float = 1.0
    penalty_unmatched: float = 1e6
    as_bipartite: bool = True
    symmetrize_embedding: bool = False

    @classmethod
    def default(cls) -> Self:
        """Return default `AlignmentConfig`."""
        return cls()

    @classmethod
    def rigid(cls) -> Self:
        """Strict alignment prioritizing rigid semantic constraints over topology."""
        return cls(iterations=1, topo_weight=0.2, penalty_unmatched=1e8)

    @classmethod
    def topological(cls) -> Self:
        """Relaxed alignment prioritizing topological graph flow smoothing."""
        return cls(iterations=4, topo_weight=2.5, penalty_unmatched=1e5)

default classmethod

default() -> Self

Return default AlignmentConfig.

Source code in src/seroverse/core/graph/align.py
@classmethod
def default(cls) -> Self:
    """Return default `AlignmentConfig`."""
    return cls()

rigid classmethod

rigid() -> Self

Strict alignment prioritizing rigid semantic constraints over topology.

Source code in src/seroverse/core/graph/align.py
@classmethod
def rigid(cls) -> Self:
    """Strict alignment prioritizing rigid semantic constraints over topology."""
    return cls(iterations=1, topo_weight=0.2, penalty_unmatched=1e8)

topological classmethod

topological() -> Self

Relaxed alignment prioritizing topological graph flow smoothing.

Source code in src/seroverse/core/graph/align.py
@classmethod
def topological(cls) -> Self:
    """Relaxed alignment prioritizing topological graph flow smoothing."""
    return cls(iterations=4, topo_weight=2.5, penalty_unmatched=1e5)

EmbeddingStrategy

Bases: ABC

Abstract base strategy for computing continuous topological node embeddings.

Methods:

Name Description
compute

Compute node embeddings from sparse adjacency and initial node features.

Source code in src/seroverse/core/graph/align.py
class EmbeddingStrategy(ABC):
    """Abstract base strategy for computing continuous topological node embeddings."""

    @abstractmethod
    def compute(
        self,
        adj: CSR,
        features: np.ndarray[tuple[int, int], np.dtype[np.float64]],
    ) -> np.ndarray[tuple[int, int], np.dtype[np.float64]]:
        """Compute node embeddings from sparse adjacency and initial node features."""
        ...

compute abstractmethod

compute(
    adj: CSR,
    features: ndarray[tuple[int, int], dtype[float64]],
) -> ndarray[tuple[int, int], dtype[float64]]

Compute node embeddings from sparse adjacency and initial node features.

Source code in src/seroverse/core/graph/align.py
@abstractmethod
def compute(
    self,
    adj: CSR,
    features: np.ndarray[tuple[int, int], np.dtype[np.float64]],
) -> np.ndarray[tuple[int, int], np.dtype[np.float64]]:
    """Compute node embeddings from sparse adjacency and initial node features."""
    ...

WeisfeilerLehmanEmbedding dataclass

Bases: EmbeddingStrategy

Computes generalized continuous Weisfeiler-Lehman topological smoothing.

Methods:

Name Description
compute

Smooth continuous features across sparse graph adjacency.

Source code in src/seroverse/core/graph/align.py
@dataclass(frozen=True, slots=True)
class WeisfeilerLehmanEmbedding(EmbeddingStrategy):
    """Computes generalized continuous Weisfeiler-Lehman topological smoothing."""

    iterations: int = 2
    smoothing_factor: float = 0.5

    def compute(
        self,
        adj: CSR,
        features: np.ndarray[tuple[int, int], np.dtype[np.float64]],
    ) -> np.ndarray[tuple[int, int], np.dtype[np.float64]]:
        """Smooth continuous features across sparse graph adjacency."""
        n_nodes = adj.shape[0]
        if n_nodes == 0:
            return features

        deg = np.asarray(adj.sum(axis=1), dtype=np.float64).ravel()
        deg[deg == 0] = 1.0
        inv_deg = (1.0 / deg)[:, None]

        embeddings = [features]
        current_h = features
        alpha = self.smoothing_factor
        for _ in range(self.iterations):
            current_h = (1.0 - alpha) * current_h + alpha * ((adj @ current_h) * inv_deg)
            embeddings.append(current_h)

        return np.concatenate(embeddings, axis=1)

compute

compute(
    adj: CSR,
    features: ndarray[tuple[int, int], dtype[float64]],
) -> ndarray[tuple[int, int], dtype[float64]]

Smooth continuous features across sparse graph adjacency.

Source code in src/seroverse/core/graph/align.py
def compute(
    self,
    adj: CSR,
    features: np.ndarray[tuple[int, int], np.dtype[np.float64]],
) -> np.ndarray[tuple[int, int], np.dtype[np.float64]]:
    """Smooth continuous features across sparse graph adjacency."""
    n_nodes = adj.shape[0]
    if n_nodes == 0:
        return features

    deg = np.asarray(adj.sum(axis=1), dtype=np.float64).ravel()
    deg[deg == 0] = 1.0
    inv_deg = (1.0 / deg)[:, None]

    embeddings = [features]
    current_h = features
    alpha = self.smoothing_factor
    for _ in range(self.iterations):
        current_h = (1.0 - alpha) * current_h + alpha * ((adj @ current_h) * inv_deg)
        embeddings.append(current_h)

    return np.concatenate(embeddings, axis=1)

DegreeProfileEmbedding dataclass

Bases: EmbeddingStrategy

Computes in-degree, out-degree, and total degree profiles for each node.

Methods:

Name Description
compute

Augment node features with in-degree and out-degree computed from sparse adjacency.

Source code in src/seroverse/core/graph/align.py
@dataclass(frozen=True, slots=True)
class DegreeProfileEmbedding(EmbeddingStrategy):
    """Computes in-degree, out-degree, and total degree profiles for each node."""

    def compute(
        self,
        adj: CSR,
        features: np.ndarray[tuple[int, int], np.dtype[np.float64]],
    ) -> np.ndarray[tuple[int, int], np.dtype[np.float64]]:
        """Augment node features with in-degree and out-degree computed from sparse adjacency."""
        n_nodes = adj.shape[0]
        if n_nodes == 0:
            return features
        out_deg = np.asarray(adj.sum(axis=1), dtype=np.float64).ravel()[:, None]
        in_deg = np.asarray(adj.sum(axis=0), dtype=np.float64).ravel()[:, None]
        return np.column_stack([features, out_deg, in_deg])

compute

compute(
    adj: CSR,
    features: ndarray[tuple[int, int], dtype[float64]],
) -> ndarray[tuple[int, int], dtype[float64]]

Augment node features with in-degree and out-degree computed from sparse adjacency.

Source code in src/seroverse/core/graph/align.py
def compute(
    self,
    adj: CSR,
    features: np.ndarray[tuple[int, int], np.dtype[np.float64]],
) -> np.ndarray[tuple[int, int], np.dtype[np.float64]]:
    """Augment node features with in-degree and out-degree computed from sparse adjacency."""
    n_nodes = adj.shape[0]
    if n_nodes == 0:
        return features
    out_deg = np.asarray(adj.sum(axis=1), dtype=np.float64).ravel()[:, None]
    in_deg = np.asarray(adj.sum(axis=0), dtype=np.float64).ravel()[:, None]
    return np.column_stack([features, out_deg, in_deg])

CostStrategy

Bases: ABC

Abstract base strategy for computing semantic node compatibility cost matrices.

Methods:

Name Description
compute_cost

Compute (N, M) pairwise node compatibility cost matrix.

Source code in src/seroverse/core/graph/align.py
class CostStrategy(ABC):
    """Abstract base strategy for computing semantic node compatibility cost matrices."""

    @abstractmethod
    def compute_cost(
        self,
        labels_a: np.ndarray[tuple[int], np.dtype[Any]],
        labels_b: np.ndarray[tuple[int], np.dtype[Any]],
        penalty: float = 1e6,
        graph_a: Graph | None = None,
        graph_b: Graph | None = None,
    ) -> np.ndarray[tuple[int, int], np.dtype[np.float64]]:
        """Compute `(N, M)` pairwise node compatibility cost matrix."""
        ...

compute_cost abstractmethod

compute_cost(
    labels_a: ndarray[tuple[int], dtype[Any]],
    labels_b: ndarray[tuple[int], dtype[Any]],
    penalty: float = 1000000.0,
    graph_a: Graph | None = None,
    graph_b: Graph | None = None,
) -> ndarray[tuple[int, int], dtype[float64]]

Compute (N, M) pairwise node compatibility cost matrix.

Source code in src/seroverse/core/graph/align.py
@abstractmethod
def compute_cost(
    self,
    labels_a: np.ndarray[tuple[int], np.dtype[Any]],
    labels_b: np.ndarray[tuple[int], np.dtype[Any]],
    penalty: float = 1e6,
    graph_a: Graph | None = None,
    graph_b: Graph | None = None,
) -> np.ndarray[tuple[int, int], np.dtype[np.float64]]:
    """Compute `(N, M)` pairwise node compatibility cost matrix."""
    ...

ExactMatchCostStrategy dataclass

Bases: CostStrategy

Isomorphic cost model: 0.0 for identical node types, infinite penalty for mismatches.

Methods:

Name Description
compute_cost

Compute exact equality cost matrix using vectorized broadcasting.

Source code in src/seroverse/core/graph/align.py
@dataclass(frozen=True, slots=True)
class ExactMatchCostStrategy(CostStrategy):
    """Isomorphic cost model: 0.0 for identical node types, infinite penalty for mismatches."""

    def compute_cost(
        self,
        labels_a: np.ndarray[tuple[int], np.dtype[Any]],
        labels_b: np.ndarray[tuple[int], np.dtype[Any]],
        penalty: float = 1e6,
        graph_a: Graph | None = None,
        graph_b: Graph | None = None,
    ) -> np.ndarray[tuple[int, int], np.dtype[np.float64]]:
        """Compute exact equality cost matrix using vectorized broadcasting."""
        if len(labels_a) == 0 or len(labels_b) == 0:
            return np.empty((len(labels_a), len(labels_b)), dtype=np.float64)
        return np.where(labels_a[:, None] == labels_b[None, :], 0.0, np.inf)

compute_cost

compute_cost(
    labels_a: ndarray[tuple[int], dtype[Any]],
    labels_b: ndarray[tuple[int], dtype[Any]],
    penalty: float = 1000000.0,
    graph_a: Graph | None = None,
    graph_b: Graph | None = None,
) -> ndarray[tuple[int, int], dtype[float64]]

Compute exact equality cost matrix using vectorized broadcasting.

Source code in src/seroverse/core/graph/align.py
def compute_cost(
    self,
    labels_a: np.ndarray[tuple[int], np.dtype[Any]],
    labels_b: np.ndarray[tuple[int], np.dtype[Any]],
    penalty: float = 1e6,
    graph_a: Graph | None = None,
    graph_b: Graph | None = None,
) -> np.ndarray[tuple[int, int], np.dtype[np.float64]]:
    """Compute exact equality cost matrix using vectorized broadcasting."""
    if len(labels_a) == 0 or len(labels_b) == 0:
        return np.empty((len(labels_a), len(labels_b)), dtype=np.float64)
    return np.where(labels_a[:, None] == labels_b[None, :], 0.0, np.inf)

CallableCostStrategy dataclass

Bases: CostStrategy

Adapter wrapping custom user-defined cost functions.

Methods:

Name Description
compute_cost

Delegate cost computation to wrapped callable.

Source code in src/seroverse/core/graph/align.py
@dataclass(frozen=True, slots=True)
class CallableCostStrategy(CostStrategy):
    """Adapter wrapping custom user-defined cost functions."""

    fn: Callable[..., np.ndarray[tuple[int, int], np.dtype[np.float64]]]

    def compute_cost(
        self,
        labels_a: np.ndarray[tuple[int], np.dtype[Any]],
        labels_b: np.ndarray[tuple[int], np.dtype[Any]],
        penalty: float = 1e6,
        graph_a: Graph | None = None,
        graph_b: Graph | None = None,
    ) -> np.ndarray[tuple[int, int], np.dtype[np.float64]]:
        """Delegate cost computation to wrapped callable."""
        return np.asarray(self.fn(labels_a, labels_b), dtype=np.float64)

compute_cost

compute_cost(
    labels_a: ndarray[tuple[int], dtype[Any]],
    labels_b: ndarray[tuple[int], dtype[Any]],
    penalty: float = 1000000.0,
    graph_a: Graph | None = None,
    graph_b: Graph | None = None,
) -> ndarray[tuple[int, int], dtype[float64]]

Delegate cost computation to wrapped callable.

Source code in src/seroverse/core/graph/align.py
def compute_cost(
    self,
    labels_a: np.ndarray[tuple[int], np.dtype[Any]],
    labels_b: np.ndarray[tuple[int], np.dtype[Any]],
    penalty: float = 1e6,
    graph_a: Graph | None = None,
    graph_b: Graph | None = None,
) -> np.ndarray[tuple[int, int], np.dtype[np.float64]]:
    """Delegate cost computation to wrapped callable."""
    return np.asarray(self.fn(labels_a, labels_b), dtype=np.float64)

Alignment dataclass

Outcome of bipartite graph alignment containing matched node pairs, costs, and unmatched nodes.

Source code in src/seroverse/core/graph/align.py
@dataclass(frozen=True, slots=True)
class Alignment:
    """Outcome of bipartite graph alignment containing matched node pairs, costs, and unmatched nodes."""

    matches: dict[int, int]
    costs: dict[int, float]
    total_cost: float
    graph_a: Graph
    graph_b: Graph
    unmatched_a: tuple[int, ...]
    unmatched_b: tuple[int, ...]

GraphAligner dataclass

Configurable bipartite graph alignment solver using Hungarian assignment.

Methods:

Name Description
align

Execute bipartite graph alignment between two Graph instances.

Source code in src/seroverse/core/graph/align.py
@dataclass(frozen=True, slots=True)
class GraphAligner:
    """Configurable bipartite graph alignment solver using Hungarian assignment."""

    cost_strategy: CostStrategy = ExactMatchCostStrategy()
    embedding_strategy: EmbeddingStrategy = WeisfeilerLehmanEmbedding()
    config: AlignmentConfig = AlignmentConfig.default()

    def align(self, graph_a: Graph, graph_b: Graph) -> Alignment:
        """Execute bipartite graph alignment between two [`Graph`][seroverse.core.graph.models.Graph] instances."""
        is_sym = self.config.symmetrize_embedding
        adj_a, labels_a = graph_a.as_graph(as_bipartite=self.config.as_bipartite, symmetrize=is_sym)
        adj_b, labels_b = graph_b.as_graph(as_bipartite=self.config.as_bipartite, symmetrize=is_sym)

        n_a, n_b = len(labels_a), len(labels_b)
        if n_a == 0 or n_b == 0:
            return Alignment(
                matches={},
                costs={},
                total_cost=0.0,
                graph_a=graph_a,
                graph_b=graph_b,
                unmatched_a=tuple(range(n_a)),
                unmatched_b=tuple(range(n_b)),
            )

        c_base = self.cost_strategy.compute_cost(
            labels_a,
            labels_b,
            penalty=self.config.penalty_unmatched,
            graph_a=graph_a,
            graph_b=graph_b,
        )

        deg_a = np.asarray(adj_a.sum(axis=1), dtype=np.float64).ravel()[:, None]
        deg_b = np.asarray(adj_b.sum(axis=1), dtype=np.float64).ravel()[:, None]

        emb_a = self.embedding_strategy.compute(adj_a, deg_a)
        emb_b = self.embedding_strategy.compute(adj_b, deg_b)

        c_topo = np.linalg.norm(emb_a[:, None, :] - emb_b[None, :, :], axis=2) * self.config.topo_weight

        c_total = c_base + c_topo
        c_safe = np.where(np.isinf(c_total), self.config.penalty_unmatched, c_total)

        row_ind, col_ind = _linear_sum_assignment_kernel(c_safe)

        matches: dict[int, int] = {}
        costs: dict[int, float] = {}
        total_cost = 0.0

        for r, c in zip(row_ind, col_ind, strict=False):
            cost_val = float(c_total[r, c])
            if cost_val < self.config.penalty_unmatched and not np.isinf(cost_val):
                matches[int(r)] = int(c)
                costs[int(r)] = cost_val
                total_cost += cost_val

        unmatched_a = tuple(i for i in range(n_a) if i not in matches)
        unmatched_b = tuple(j for j in range(n_b) if j not in matches.values())

        return Alignment(
            matches=matches,
            costs=costs,
            total_cost=total_cost,
            graph_a=graph_a,
            graph_b=graph_b,
            unmatched_a=unmatched_a,
            unmatched_b=unmatched_b,
        )

align

align(graph_a: Graph, graph_b: Graph) -> Alignment

Execute bipartite graph alignment between two Graph instances.

Source code in src/seroverse/core/graph/align.py
def align(self, graph_a: Graph, graph_b: Graph) -> Alignment:
    """Execute bipartite graph alignment between two [`Graph`][seroverse.core.graph.models.Graph] instances."""
    is_sym = self.config.symmetrize_embedding
    adj_a, labels_a = graph_a.as_graph(as_bipartite=self.config.as_bipartite, symmetrize=is_sym)
    adj_b, labels_b = graph_b.as_graph(as_bipartite=self.config.as_bipartite, symmetrize=is_sym)

    n_a, n_b = len(labels_a), len(labels_b)
    if n_a == 0 or n_b == 0:
        return Alignment(
            matches={},
            costs={},
            total_cost=0.0,
            graph_a=graph_a,
            graph_b=graph_b,
            unmatched_a=tuple(range(n_a)),
            unmatched_b=tuple(range(n_b)),
        )

    c_base = self.cost_strategy.compute_cost(
        labels_a,
        labels_b,
        penalty=self.config.penalty_unmatched,
        graph_a=graph_a,
        graph_b=graph_b,
    )

    deg_a = np.asarray(adj_a.sum(axis=1), dtype=np.float64).ravel()[:, None]
    deg_b = np.asarray(adj_b.sum(axis=1), dtype=np.float64).ravel()[:, None]

    emb_a = self.embedding_strategy.compute(adj_a, deg_a)
    emb_b = self.embedding_strategy.compute(adj_b, deg_b)

    c_topo = np.linalg.norm(emb_a[:, None, :] - emb_b[None, :, :], axis=2) * self.config.topo_weight

    c_total = c_base + c_topo
    c_safe = np.where(np.isinf(c_total), self.config.penalty_unmatched, c_total)

    row_ind, col_ind = _linear_sum_assignment_kernel(c_safe)

    matches: dict[int, int] = {}
    costs: dict[int, float] = {}
    total_cost = 0.0

    for r, c in zip(row_ind, col_ind, strict=False):
        cost_val = float(c_total[r, c])
        if cost_val < self.config.penalty_unmatched and not np.isinf(cost_val):
            matches[int(r)] = int(c)
            costs[int(r)] = cost_val
            total_cost += cost_val

    unmatched_a = tuple(i for i in range(n_a) if i not in matches)
    unmatched_b = tuple(j for j in range(n_b) if j not in matches.values())

    return Alignment(
        matches=matches,
        costs=costs,
        total_cost=total_cost,
        graph_a=graph_a,
        graph_b=graph_b,
        unmatched_a=unmatched_a,
        unmatched_b=unmatched_b,
    )

IsomorphicGraphAligner

Bases: GraphAligner

Specialized aligner pre-configured for same-domain structural isomorphism matching.

Source code in src/seroverse/core/graph/align.py
class IsomorphicGraphAligner(GraphAligner):
    """Specialized aligner pre-configured for same-domain structural isomorphism matching."""

    def __init__(self, config: AlignmentConfig | None = None) -> None:
        """Initialize `IsomorphicGraphAligner`."""
        super().__init__(
            cost_strategy=ExactMatchCostStrategy(),
            embedding_strategy=WeisfeilerLehmanEmbedding(iterations=3),
            config=config or AlignmentConfig(as_bipartite=False, symmetrize_embedding=False),
        )

layout

Force-directed and spectral graph layout algorithms with Numba-accelerated kernels.

Provides LayoutEngine for computing 2D and \(D\)-dimensional node coordinates for Graph instances, Graphs pan-graphs, and sparse adjacency matrices using the Fruchterman-Reingold spring-electrical algorithm.

Classes:

Name Description
LayoutEngine

Configurable graph layout engine with Numba-accelerated Fruchterman-Reingold physics.

LayoutEngine dataclass

Configurable graph layout engine with Numba-accelerated Fruchterman-Reingold physics.

Supports laying out single Graph instances, Graphs collections (via their unified .as_pan_graph() representation), or raw SciPy sparse / NumPy adjacency matrices.

Attributes:

Name Type Description
mode LayoutMode

Layout algorithm ("fruchterman_reingold" or shorthand "fr").

dim int

Coordinate dimensionality (\(D \ge 1\)). Defaults to 2.

iterations int

Number of simulated annealing iterations. Defaults to 50.

k float | None

Optimal inter-node spring distance. If None, defaults to \(1 / \sqrt[\text{dim}]{N}\).

scale float | None

Bounding radius for output rescaling ([-scale, +scale]), or None to disable rescaling.

center Sequence[float] | ndarray | None

Optional \(D\)-dimensional center offset. Defaults to origin.

gravity float

Central gravity coefficient keeping disconnected components compact. Defaults to 0.05.

tol float

Early stopping threshold on maximum node displacement. Defaults to 1e-4.

init InitMode

Coordinate initialization strategy ("random", "circular", or "spectral").

use_weights bool

Whether edge weights scale attractive forces. Defaults to True.

as_bipartite bool

Whether to expand edges as bipartite nodes when laying out a Graph.

node_mask int | None

Optional bitmask passed to Graphs.as_pan_graph(node_mask=...).

seed int

Deterministic random seed for coordinate initialization. Defaults to 42.

Methods:

Name Description
rescale

Rescale node coordinates so the largest centered coordinate magnitude equals scale.

layout

Compute (N, dim) layout coordinates for a graph, batch pan-graph, or adjacency matrix.

Source code in src/seroverse/core/graph/layout.py
@dataclass(frozen=True, slots=True)
class LayoutEngine:
    r"""Configurable graph layout engine with Numba-accelerated Fruchterman-Reingold physics.

    Supports laying out single [`Graph`][seroverse.core.graph.models.Graph] instances,
    [`Graphs`][seroverse.core.graph.models.Graphs] collections (via their unified
    [`.as_pan_graph()`][seroverse.core.graph.models.Graphs.as_pan_graph] representation),
    or raw SciPy sparse / NumPy adjacency matrices.

    Attributes:
        mode: Layout algorithm (`"fruchterman_reingold"` or shorthand `"fr"`).
        dim: Coordinate dimensionality ($D \ge 1$). Defaults to `2`.
        iterations: Number of simulated annealing iterations. Defaults to `50`.
        k: Optimal inter-node spring distance. If `None`, defaults to $1 / \sqrt[\text{dim}]{N}$.
        scale: Bounding radius for output rescaling (`[-scale, +scale]`), or `None` to disable rescaling.
        center: Optional $D$-dimensional center offset. Defaults to origin.
        gravity: Central gravity coefficient keeping disconnected components compact. Defaults to `0.05`.
        tol: Early stopping threshold on maximum node displacement. Defaults to `1e-4`.
        init: Coordinate initialization strategy (`"random"`, `"circular"`, or `"spectral"`).
        use_weights: Whether edge weights scale attractive forces. Defaults to `True`.
        as_bipartite: Whether to expand edges as bipartite nodes when laying out a `Graph`.
        node_mask: Optional bitmask passed to `Graphs.as_pan_graph(node_mask=...)`.
        seed: Deterministic random seed for coordinate initialization. Defaults to `42`.
    """

    mode: LayoutMode = "fruchterman_reingold"
    dim: int = 2
    iterations: int = 50
    k: float | None = None
    scale: float | None = 1.0
    center: Sequence[float] | np.ndarray | None = None
    gravity: float = 0.05
    tol: float = 1e-4
    init: InitMode = "random"
    use_weights: bool = True
    as_bipartite: bool = False
    node_mask: int | None = None
    seed: int = 42

    def __post_init__(self) -> None:
        r"""Validate layout hyperparameters."""
        if self.mode not in ("fruchterman_reingold", "fr"):
            raise ValueError(f"Invalid mode {self.mode!r}. Expected 'fruchterman_reingold' or 'fr'.")
        if self.dim < 1:
            raise ValueError(f"dim must be >= 1, got {self.dim}.")
        if self.iterations < 0:
            raise ValueError(f"iterations must be >= 0, got {self.iterations}.")
        if self.k is not None and self.k <= 0.0:
            raise ValueError(f"k must be > 0 when specified, got {self.k}.")
        if self.gravity < 0.0:
            raise ValueError(f"gravity must be >= 0, got {self.gravity}.")
        if self.tol < 0.0:
            raise ValueError(f"tol must be >= 0, got {self.tol}.")
        if self.init not in ("random", "circular", "spectral"):
            raise ValueError(f"Invalid init strategy {self.init!r}. Expected 'random', 'circular', or 'spectral'.")

    def __call__(
        self,
        graph: Graph | Graphs[Any, Any] | CSR | np.ndarray,
        *,
        pos: np.ndarray | None = None,
        fixed: Sequence[int] | np.ndarray | None = None,
    ) -> np.ndarray[tuple[int, int], np.dtype[np.float64]]:
        r"""Compute layout coordinates for `graph` (alias for [`layout`][seroverse.core.graph.layout.LayoutEngine.layout])."""
        return self.layout(graph, pos=pos, fixed=fixed)

    def rescale(
        self,
        pos: np.ndarray[tuple[int, int], np.dtype[np.float64]],
    ) -> np.ndarray[tuple[int, int], np.dtype[np.float64]]:
        r"""Rescale node coordinates so the largest centered coordinate magnitude equals `scale`.

        Args:
            pos: `(N, dim)` coordinate array.

        Returns:
            Rescaled `(N, dim)` coordinate array (`float64`).
        """
        n_nodes, dim = pos.shape
        out = pos.astype(np.float64, copy=True)
        if n_nodes == 0:
            return out

        target_center = (
            np.zeros(dim, dtype=np.float64) if self.center is None else np.asarray(self.center, dtype=np.float64)
        )
        if len(target_center) != dim:
            raise ValueError(f"center dimension ({len(target_center)}) must match layout dim ({dim}).")

        if n_nodes == 1:
            out[0] = target_center
            return out

        out -= np.mean(out, axis=0, keepdims=True)
        max_lim = float(np.max(np.abs(out)))
        if max_lim > 0.0 and self.scale is not None:
            out *= float(self.scale) / max_lim
        out += target_center[None, :]
        return out

    def layout(
        self,
        graph: Graph | Graphs[Any, Any] | CSR | np.ndarray,
        *,
        pos: np.ndarray | None = None,
        fixed: Sequence[int] | np.ndarray | None = None,
    ) -> np.ndarray[tuple[int, int], np.dtype[np.float64]]:
        r"""Compute `(N, dim)` layout coordinates for a graph, batch pan-graph, or adjacency matrix.

        Args:
            graph: Input [`Graph`][seroverse.core.graph.models.Graph],
                [`Graphs`][seroverse.core.graph.models.Graphs] (laid out via `.as_pan_graph()`),
                sparse [`CSR`][seroverse.core.graph.csr.CSR], or dense 2D adjacency array.
            pos: Optional initial `(N, dim)` node coordinates for warm-starting or pinning.
            fixed: Optional sequence/array of node indices (or boolean mask of length `N`)
                whose positions should remain fixed during optimization.

        Returns:
            2D NumPy array of shape `(N, dim)` with `float64` coordinates.
        """
        adj = self._extract_adjacency(graph)
        n_nodes = int(adj.shape[0])

        center_arr = (
            np.zeros(self.dim, dtype=np.float64) if self.center is None else np.asarray(self.center, dtype=np.float64)
        )
        if len(center_arr) != self.dim:
            raise ValueError(f"center length ({len(center_arr)}) must match dim ({self.dim}).")

        if n_nodes == 0:
            return np.empty((0, self.dim), dtype=np.float64)

        if n_nodes == 1:
            out = np.empty((1, self.dim), dtype=np.float64)
            out[0] = pos[0] if pos is not None else center_arr
            return out

        init_pos = self._init_positions(adj, n_nodes, pos)
        fixed_mask = self._resolve_fixed_mask(n_nodes, fixed, has_pos=pos is not None)
        edge_u, edge_v, edge_w = adj.extract_undirected_edges(use_weights=self.use_weights)

        k_eff = float(self.k) if self.k is not None else float(1.0 / (n_nodes ** (1.0 / self.dim)))
        span = np.max(init_pos, axis=0) - np.min(init_pos, axis=0)
        t0 = max(float(np.max(span)) * 0.1, 0.1)
        tol_eff = float(self.tol) * max(float(np.max(span)), 1.0)

        coords = _fruchterman_reingold_kernel(
            init_pos,
            edge_u,
            edge_v,
            edge_w,
            fixed_mask,
            k=k_eff,
            iterations=self.iterations,
            t0=t0,
            gravity=float(self.gravity),
            tol=tol_eff,
        )

        if self.scale is not None and not np.any(fixed_mask):
            return self.rescale(coords)
        if self.center is not None and not np.any(fixed_mask):
            coords = coords - np.mean(coords, axis=0, keepdims=True) + center_arr[None, :]
        return coords

    def _extract_adjacency(
        self,
        graph: Graph | Graphs[Any, Any] | CSR | np.ndarray,
    ) -> CSR:
        r"""Normalize input `Graph`, `Graphs`, or matrix into a square `CSR`."""
        if isinstance(graph, Graphs):
            adj, _, _ = graph.as_pan_graph(node_mask=self.node_mask)
            return adj
        if hasattr(graph, "as_graph"):
            adj, _ = cast(Graph, graph).as_graph(as_bipartite=self.as_bipartite, symmetrize=False)
            return adj
        if isinstance(graph, CSR):
            if graph.ndim != 2 or graph.shape[0] != graph.shape[1]:
                raise ValueError(f"Adjacency matrix must be square 2D, got shape {graph.shape}.")
            return graph
        if isinstance(graph, np.ndarray):
            if graph.ndim != 2 or graph.shape[0] != graph.shape[1]:
                raise ValueError(f"Adjacency array must be square 2D, got shape {graph.shape}.")
            return CSR.from_dense(graph)
        raise TypeError(
            f"Unsupported graph type {type(graph).__name__!r}. "
            "Expected Graph, Graphs, seroverse.core.graph.csr.CSR, or 2D numpy.ndarray."
        )

    def _init_positions(
        self,
        adj: CSR,
        n_nodes: int,
        pos: np.ndarray | None,
    ) -> np.ndarray[tuple[int, int], np.dtype[np.float64]]:
        r"""Initialize `(N, dim)` node coordinates from `pos` or `self.init` strategy."""
        rng = np.random.default_rng(self.seed)
        if pos is not None:
            pos_arr = np.asarray(pos, dtype=np.float64)
            if pos_arr.shape != (n_nodes, self.dim):
                raise ValueError(f"pos shape {pos_arr.shape} must match (n_nodes, dim) = ({n_nodes}, {self.dim}).")
            return np.ascontiguousarray(pos_arr, dtype=np.float64)

        if self.init == "circular":
            out = np.zeros((n_nodes, self.dim), dtype=np.float64)
            angles = np.linspace(0.0, 2.0 * np.pi, n_nodes, endpoint=False)
            out[:, 0] = np.cos(angles)
            if self.dim >= 2:
                out[:, 1] = np.sin(angles)
            if self.dim > 2:
                out[:, 2:] = rng.uniform(-0.1, 0.1, size=(n_nodes, self.dim - 2))
            return out

        if self.init == "spectral" and n_nodes > self.dim and adj.nnz > 0:
            if not self.use_weights:
                lap_dense = adj.laplacian(normed=True, symmetrize_unweighted=True)
            else:
                adj_f = adj.astype(np.float64)
                lap_dense = (adj_f + adj_f.T).laplacian(normed=True)
            _, vecs = np.linalg.eigh(lap_dense)
            # Skip the trivial constant eigenvector at index 0
            spec = np.ascontiguousarray(vecs[:, 1 : 1 + self.dim], dtype=np.float64)
            # Add tiny deterministic jitter to break exact spectral degeneracies
            spec += rng.normal(0.0, 1e-3, size=spec.shape)
            return self.rescale(spec)

        return np.ascontiguousarray(rng.uniform(-1.0, 1.0, size=(n_nodes, self.dim)), dtype=np.float64)

    @staticmethod
    def _resolve_fixed_mask(
        n_nodes: int,
        fixed: Sequence[int] | np.ndarray | None,
        *,
        has_pos: bool,
    ) -> np.ndarray[tuple[int], np.dtype[np.bool_]]:
        r"""Convert `fixed` indices or boolean array into a 1D boolean mask of length `n_nodes`."""
        mask = np.zeros(n_nodes, dtype=np.bool_)
        if fixed is None:
            return mask
        if not has_pos:
            raise ValueError("fixed nodes require initial coordinates `pos` to be provided.")

        fixed_arr = np.asarray(fixed)
        if fixed_arr.dtype == np.bool_:
            if len(fixed_arr) != n_nodes:
                raise ValueError(f"Boolean fixed mask length ({len(fixed_arr)}) must match n_nodes ({n_nodes}).")
            return fixed_arr.copy()

        indices = fixed_arr.astype(np.int64, copy=False).ravel()
        if len(indices) > 0:
            if np.any((indices < 0) | (indices >= n_nodes)):
                raise IndexError(f"fixed node indices out of bounds for graph with {n_nodes} nodes.")
            mask[indices] = True
        return mask

rescale

rescale(
    pos: ndarray[tuple[int, int], dtype[float64]],
) -> ndarray[tuple[int, int], dtype[float64]]

Rescale node coordinates so the largest centered coordinate magnitude equals scale.

Parameters:

Name Type Description Default
pos ndarray[tuple[int, int], dtype[float64]]

(N, dim) coordinate array.

required

Returns:

Type Description
ndarray[tuple[int, int], dtype[float64]]

Rescaled (N, dim) coordinate array (float64).

Source code in src/seroverse/core/graph/layout.py
def rescale(
    self,
    pos: np.ndarray[tuple[int, int], np.dtype[np.float64]],
) -> np.ndarray[tuple[int, int], np.dtype[np.float64]]:
    r"""Rescale node coordinates so the largest centered coordinate magnitude equals `scale`.

    Args:
        pos: `(N, dim)` coordinate array.

    Returns:
        Rescaled `(N, dim)` coordinate array (`float64`).
    """
    n_nodes, dim = pos.shape
    out = pos.astype(np.float64, copy=True)
    if n_nodes == 0:
        return out

    target_center = (
        np.zeros(dim, dtype=np.float64) if self.center is None else np.asarray(self.center, dtype=np.float64)
    )
    if len(target_center) != dim:
        raise ValueError(f"center dimension ({len(target_center)}) must match layout dim ({dim}).")

    if n_nodes == 1:
        out[0] = target_center
        return out

    out -= np.mean(out, axis=0, keepdims=True)
    max_lim = float(np.max(np.abs(out)))
    if max_lim > 0.0 and self.scale is not None:
        out *= float(self.scale) / max_lim
    out += target_center[None, :]
    return out

layout

layout(
    graph: Graph | Graphs[Any, Any] | CSR | ndarray,
    *,
    pos: ndarray | None = None,
    fixed: Sequence[int] | ndarray | None = None,
) -> ndarray[tuple[int, int], dtype[float64]]

Compute (N, dim) layout coordinates for a graph, batch pan-graph, or adjacency matrix.

Parameters:

Name Type Description Default
graph Graph | Graphs[Any, Any] | CSR | ndarray

Input Graph, Graphs (laid out via .as_pan_graph()), sparse CSR, or dense 2D adjacency array.

required
pos ndarray | None

Optional initial (N, dim) node coordinates for warm-starting or pinning.

None
fixed Sequence[int] | ndarray | None

Optional sequence/array of node indices (or boolean mask of length N) whose positions should remain fixed during optimization.

None

Returns:

Type Description
ndarray[tuple[int, int], dtype[float64]]

2D NumPy array of shape (N, dim) with float64 coordinates.

Source code in src/seroverse/core/graph/layout.py
def layout(
    self,
    graph: Graph | Graphs[Any, Any] | CSR | np.ndarray,
    *,
    pos: np.ndarray | None = None,
    fixed: Sequence[int] | np.ndarray | None = None,
) -> np.ndarray[tuple[int, int], np.dtype[np.float64]]:
    r"""Compute `(N, dim)` layout coordinates for a graph, batch pan-graph, or adjacency matrix.

    Args:
        graph: Input [`Graph`][seroverse.core.graph.models.Graph],
            [`Graphs`][seroverse.core.graph.models.Graphs] (laid out via `.as_pan_graph()`),
            sparse [`CSR`][seroverse.core.graph.csr.CSR], or dense 2D adjacency array.
        pos: Optional initial `(N, dim)` node coordinates for warm-starting or pinning.
        fixed: Optional sequence/array of node indices (or boolean mask of length `N`)
            whose positions should remain fixed during optimization.

    Returns:
        2D NumPy array of shape `(N, dim)` with `float64` coordinates.
    """
    adj = self._extract_adjacency(graph)
    n_nodes = int(adj.shape[0])

    center_arr = (
        np.zeros(self.dim, dtype=np.float64) if self.center is None else np.asarray(self.center, dtype=np.float64)
    )
    if len(center_arr) != self.dim:
        raise ValueError(f"center length ({len(center_arr)}) must match dim ({self.dim}).")

    if n_nodes == 0:
        return np.empty((0, self.dim), dtype=np.float64)

    if n_nodes == 1:
        out = np.empty((1, self.dim), dtype=np.float64)
        out[0] = pos[0] if pos is not None else center_arr
        return out

    init_pos = self._init_positions(adj, n_nodes, pos)
    fixed_mask = self._resolve_fixed_mask(n_nodes, fixed, has_pos=pos is not None)
    edge_u, edge_v, edge_w = adj.extract_undirected_edges(use_weights=self.use_weights)

    k_eff = float(self.k) if self.k is not None else float(1.0 / (n_nodes ** (1.0 / self.dim)))
    span = np.max(init_pos, axis=0) - np.min(init_pos, axis=0)
    t0 = max(float(np.max(span)) * 0.1, 0.1)
    tol_eff = float(self.tol) * max(float(np.max(span)), 1.0)

    coords = _fruchterman_reingold_kernel(
        init_pos,
        edge_u,
        edge_v,
        edge_w,
        fixed_mask,
        k=k_eff,
        iterations=self.iterations,
        t0=t0,
        gravity=float(self.gravity),
        tol=tol_eff,
    )

    if self.scale is not None and not np.any(fixed_mask):
        return self.rescale(coords)
    if self.center is not None and not np.any(fixed_mask):
        coords = coords - np.mean(coords, axis=0, keepdims=True) + center_arr[None, :]
    return coords

gnn

Numba-accelerated Relational Message-Passing Neural Network (RelationalMPNN).

Provides compiled @njit(parallel=True, fastmath=True) message-passing forward/backward kernels, factorized bilinear directed edge/slot scorers, and the RelationalMPNN model operating over RelationalCSR graphs.

Classes:

Name Description
RelationalMPNN

Numba-powered Relational Message-Passing Neural Network (R-MPNN) for node & directed tree inference.

RelationalMPNN

Numba-powered Relational Message-Passing Neural Network (R-MPNN) for node & directed tree inference.

Architecture
  1. Input projection: \(h^{(0)} = \text{RMSNorm}(X W_{\text{in}} + b_{\text{in}})\).
  2. \(L\) Relational Message-Passing layers with residual connections, ReLU, RMSNorm, and optional clamped-node anchor injection (clamped_mask) so pre-existing annotations anchor contextual propagation without drifting.
  3. Node readout head: \(Z_{\text{node}} = h^{(L)} W_{\text{node}} + b_{\text{node}}\) ((N, num_node_classes)).
  4. Factorized bilinear multi-slot directed edge readout head: \(S[u, v, c] = \frac{1}{\sqrt{K}} \sum_{k=1}^K (h_u^{(L)} W_{\text{src}})_k (h_v^{(L)} W_{\text{dst}})_k W_{\text{chan}}[c, k] + b_{\text{chan}}[c]\) producing (M, M, num_edge_channels) logits suitable for decoding via ArborescenceSolver or Arborescence.maximum.

Methods:

Name Description
encode

Run the \(L\)-layer R-MPNN encoder and return final node embeddings (N, hidden_dim).

predict_node_logits

Compute pre-softmax node class logits (N, num_node_classes).

predict_node_proba

Compute softmax node class probabilities (N, num_node_classes).

score_edges

Score all directed candidate edges (u -> v, channel) among h_nodes (or h_nodes[node_indices]).

fit_step

Execute one complete forward + backward + AdamW optimization step on a graph or batch.

save

Save model weights and hyperparameters to a compressed .npz archive (allow_pickle=False).

load

Load a RelationalMPNN from a compressed .npz archive.

Source code in src/seroverse/core/graph/gnn.py
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
class RelationalMPNN:
    r"""Numba-powered Relational Message-Passing Neural Network (R-MPNN) for node & directed tree inference.

    Architecture:
        1. Input projection: $h^{(0)} = \text{RMSNorm}(X W_{\text{in}} + b_{\text{in}})$.
        2. $L$ Relational Message-Passing layers with residual connections, ReLU, RMSNorm, and
           optional clamped-node anchor injection (`clamped_mask`) so pre-existing annotations
           anchor contextual propagation without drifting.
        3. Node readout head: $Z_{\text{node}} = h^{(L)} W_{\text{node}} + b_{\text{node}}$ (`(N, num_node_classes)`).
        4. Factorized bilinear multi-slot directed edge readout head:
           $S[u, v, c] = \frac{1}{\sqrt{K}} \sum_{k=1}^K (h_u^{(L)} W_{\text{src}})_k (h_v^{(L)} W_{\text{dst}})_k W_{\text{chan}}[c, k] + b_{\text{chan}}[c]$
           producing `(M, M, num_edge_channels)` logits suitable for decoding via
           [`ArborescenceSolver`][seroverse.core.graph.arborescence.ArborescenceSolver] or
           [`Arborescence.maximum`][seroverse.core.graph.arborescence.Arborescence.maximum].
    """

    def __init__(
        self,
        in_dim: int,
        hidden_dim: int = 64,
        num_node_classes: int = 32,
        num_edge_channels: int = 8,
        num_relations: int = 8,
        num_layers: int = 3,
        bilinear_rank: int = 32,
        learning_rate: float = 2e-3,
        weight_decay: float = 1e-4,
        random_state: int | None = 42,
    ) -> None:
        self.in_dim = int(in_dim)
        self.hidden_dim = int(hidden_dim)
        self.num_node_classes = int(num_node_classes)
        self.num_edge_channels = int(num_edge_channels)
        self.num_relations = int(num_relations)
        self.num_layers = int(num_layers)
        self.bilinear_rank = int(bilinear_rank)
        self.learning_rate = float(learning_rate)
        self.weight_decay = float(weight_decay)
        self.random_state = random_state

        self._step = 0
        self.loss_history_: list[float] = []
        self._init_weights()

    def _init_weights(self) -> None:
        rng = np.random.default_rng(self.random_state)
        d_in = self.in_dim
        d_hid = self.hidden_dim
        n_rel = self.num_relations
        n_lay = self.num_layers
        n_cls = self.num_node_classes
        n_chan = self.num_edge_channels
        rank_k = self.bilinear_rank

        self.w_in_ = rng.normal(0.0, np.sqrt(2.0 / max(d_in, 1)), size=(d_in, d_hid)).astype(np.float32)
        self.b_in_ = np.zeros(d_hid, dtype=np.float32)

        self.w_rel_ = rng.normal(
            0.0,
            np.sqrt(1.0 / max(d_hid * n_rel, 1)),
            size=(n_lay, n_rel, d_hid, d_hid),
        ).astype(np.float32)
        self.b_rel_ = np.zeros((n_lay, d_hid), dtype=np.float32)

        self.w_node_ = rng.normal(0.0, np.sqrt(1.0 / max(d_hid, 1)), size=(d_hid, n_cls)).astype(np.float32)
        self.b_node_ = np.zeros(n_cls, dtype=np.float32)

        self.w_edge_src_ = rng.normal(0.0, np.sqrt(1.0 / max(d_hid, 1)), size=(d_hid, rank_k)).astype(np.float32)
        self.w_edge_dst_ = rng.normal(0.0, np.sqrt(1.0 / max(d_hid, 1)), size=(d_hid, rank_k)).astype(np.float32)
        self.w_edge_chan_ = rng.normal(0.0, np.sqrt(1.0 / max(rank_k, 1)), size=(n_chan, rank_k)).astype(np.float32)
        self.b_edge_chan_ = np.zeros(n_chan, dtype=np.float32)

        self._m_w_in = np.zeros_like(self.w_in_)
        self._v_w_in = np.zeros_like(self.w_in_)
        self._m_b_in = np.zeros_like(self.b_in_)
        self._v_b_in = np.zeros_like(self.b_in_)

        self._m_w_rel = np.zeros_like(self.w_rel_)
        self._v_w_rel = np.zeros_like(self.w_rel_)
        self._m_b_rel = np.zeros_like(self.b_rel_)
        self._v_b_rel = np.zeros_like(self.b_rel_)

        self._m_w_node = np.zeros_like(self.w_node_)
        self._v_w_node = np.zeros_like(self.w_node_)
        self._m_b_node = np.zeros_like(self.b_node_)
        self._v_b_node = np.zeros_like(self.b_node_)

        self._m_w_esrc = np.zeros_like(self.w_edge_src_)
        self._v_w_esrc = np.zeros_like(self.w_edge_src_)
        self._m_w_edst = np.zeros_like(self.w_edge_dst_)
        self._v_w_edst = np.zeros_like(self.w_edge_dst_)
        self._m_w_echan = np.zeros_like(self.w_edge_chan_)
        self._v_w_echan = np.zeros_like(self.w_edge_chan_)
        self._m_b_echan = np.zeros_like(self.b_edge_chan_)
        self._v_b_echan = np.zeros_like(self.b_edge_chan_)

    @staticmethod
    def _coerce_csr(csr: RelationalCSR | Graph | Graphs[Any, Any]) -> RelationalCSR:
        """Coerce a `Graph` or `Graphs` into a `RelationalCSR` if needed."""
        if isinstance(csr, RelationalCSR):
            return csr
        if isinstance(csr, (Graph, Graphs)):
            return csr.as_relational_csr()
        raise TypeError(f"Expected RelationalCSR, Graph, or Graphs; got {type(csr).__name__}.")

    def _prepare_features(
        self,
        x: np.ndarray,
        csr: RelationalCSR,
        clamped_mask: np.ndarray | None = None,
    ) -> tuple[np.ndarray, np.ndarray]:
        """Pad virtual pool rows if `x` only has real vertices, and normalize `clamped_mask`."""
        x_arr = np.asarray(x, dtype=np.float32)
        if x_arr.ndim != 2 or x_arr.shape[1] != self.in_dim:
            raise ValueError(f"Expected 2D input features of width {self.in_dim}; got {x_arr.shape}.")
        n_total = csr.n_nodes
        if x_arr.shape[0] < n_total:
            pad = np.zeros((n_total - x_arr.shape[0], self.in_dim), dtype=np.float32)
            x_full = np.ascontiguousarray(np.vstack([x_arr, pad]))
        elif x_arr.shape[0] == n_total:
            x_full = np.ascontiguousarray(x_arr)
        else:
            raise ValueError(f"x has {x_arr.shape[0]} rows, exceeding csr.n_nodes ({n_total}).")

        if clamped_mask is None:
            c_mask = np.zeros(n_total, dtype=np.uint8)
        else:
            cm = np.asarray(clamped_mask, dtype=np.uint8)
            if len(cm) < n_total:
                c_mask = np.zeros(n_total, dtype=np.uint8)
                c_mask[: len(cm)] = cm
            else:
                c_mask = np.ascontiguousarray(cm[:n_total])
        return x_full, c_mask

    def encode(
        self,
        x: np.ndarray,
        csr: RelationalCSR | Graph | Graphs[Any, Any],
        *,
        clamped_mask: np.ndarray | None = None,
    ) -> np.ndarray[tuple[int, int], np.dtype[np.float32]]:
        r"""Run the $L$-layer R-MPNN encoder and return final node embeddings `(N, hidden_dim)`."""
        rel_csr = self._coerce_csr(csr)
        x_full, c_mask = self._prepare_features(x, rel_csr, clamped_mask)
        h0 = np.ascontiguousarray(x_full @ self.w_in_ + self.b_in_[None, :], dtype=np.float32)
        h = h0
        for lay in range(self.num_layers):
            h, _, _ = _mpnn_layer_forward(
                h,
                rel_csr.indptr,
                rel_csr.src_indices,
                rel_csr.rel_types,
                rel_csr.edge_weights,
                self.w_rel_[lay],
                self.b_rel_[lay],
                c_mask,
                h0,
            )
        return h

    def predict_node_logits(
        self,
        x: np.ndarray,
        csr: RelationalCSR | Graph | Graphs[Any, Any],
        *,
        clamped_mask: np.ndarray | None = None,
    ) -> np.ndarray[tuple[int, int], np.dtype[np.float32]]:
        r"""Compute pre-softmax node class logits `(N, num_node_classes)`."""
        h = self.encode(x, csr, clamped_mask=clamped_mask)
        return np.ascontiguousarray(h @ self.w_node_ + self.b_node_[None, :], dtype=np.float32)

    def predict_node_proba(
        self,
        x: np.ndarray,
        csr: RelationalCSR | Graph | Graphs[Any, Any],
        *,
        clamped_mask: np.ndarray | None = None,
    ) -> np.ndarray[tuple[int, int], np.dtype[np.float32]]:
        r"""Compute softmax node class probabilities `(N, num_node_classes)`."""
        logits = self.predict_node_logits(x, csr, clamped_mask=clamped_mask)
        max_l = np.max(logits, axis=1, keepdims=True)
        exp_l = np.exp(logits - max_l)
        return (exp_l / np.maximum(np.sum(exp_l, axis=1, keepdims=True), np.float32(1e-12))).astype(np.float32)

    def score_edges(
        self,
        h_nodes: np.ndarray,
        node_indices: Sequence[int] | np.ndarray | None = None,
    ) -> np.ndarray[tuple[int, int, int], np.dtype[np.float32]]:
        r"""Score all directed candidate edges `(u -> v, channel)` among `h_nodes` (or `h_nodes[node_indices]`).

        Returns:
            np.ndarray: 3D `float32` tensor of shape `(M, M, num_edge_channels)`.
        """
        h_sub = (
            np.ascontiguousarray(h_nodes[np.asarray(node_indices, dtype=np.int32)], dtype=np.float32)
            if node_indices is not None
            else np.ascontiguousarray(h_nodes, dtype=np.float32)
        )
        u_proj = np.ascontiguousarray(h_sub @ self.w_edge_src_, dtype=np.float32)
        v_proj = np.ascontiguousarray(h_sub @ self.w_edge_dst_, dtype=np.float32)
        return _bilinear_edge_scores_forward(u_proj, v_proj, self.w_edge_chan_, self.b_edge_chan_)

    def fit_step(
        self,
        x: np.ndarray,
        csr: RelationalCSR | Graph | Graphs[Any, Any],
        *,
        node_targets: np.ndarray | None = None,
        node_weights: np.ndarray | None = None,
        edge_node_indices: np.ndarray | None = None,
        edge_parent_targets: np.ndarray | None = None,
        edge_slot_targets: np.ndarray | None = None,
        clamped_mask: np.ndarray | None = None,
        lr_scale: float = 1.0,
    ) -> float:
        r"""Execute one complete forward + backward + AdamW optimization step on a graph or batch.

        Args:
            x: Input node feature matrix `(N_real, in_dim)` or `(N_total, in_dim)`.
            csr: [`RelationalCSR`][seroverse.core.graph.csr.RelationalCSR], [`Graph`][seroverse.core.graph.models.Graph],
                or [`Graphs`][seroverse.core.graph.models.Graphs] connectivity structure.
            node_targets: Optional 1D `int32` array of ground-truth node class indices (`-1` = ignore).
            node_weights: Optional 1D `float32` array of per-node loss weights.
            edge_node_indices: Optional 1D `int32` array of active tree vertices `[v_0, ..., v_{M-1}]`.
            edge_parent_targets: Optional 1D `int32` array of length `M` giving target parent index in `[0, M-1]`
                for each active tree vertex (`-1` for root or unsupervised vertices).
            edge_slot_targets: Optional 1D `int32` array of length `M` giving target slot/channel in
                `[0, num_edge_channels - 1]` for each active tree vertex (`-1` = marginalize over slots).
            clamped_mask: Optional 1D `uint8` / boolean mask of clamped vertices.
            lr_scale: Cosine/schedule multiplier on `self.learning_rate`.

        Returns:
            float: Total scalar loss for this step.
        """
        rel_csr = self._coerce_csr(csr)
        x_full, c_mask = self._prepare_features(x, rel_csr, clamped_mask)
        n_total = rel_csr.n_nodes

        # 1. Forward pass with layer caches
        h0 = np.ascontiguousarray(x_full @ self.w_in_ + self.b_in_[None, :], dtype=np.float32)
        h_inputs: list[np.ndarray] = []
        pre_norms: list[np.ndarray] = []
        rms_invs: list[np.ndarray] = []

        h_curr = h0
        for lay in range(self.num_layers):
            h_inputs.append(h_curr)
            h_curr, pn, ri = _mpnn_layer_forward(
                h_curr,
                rel_csr.indptr,
                rel_csr.src_indices,
                rel_csr.rel_types,
                rel_csr.edge_weights,
                self.w_rel_[lay],
                self.b_rel_[lay],
                c_mask,
                h0,
            )
            pre_norms.append(pn)
            rms_invs.append(ri)

        total_loss = 0.0
        d_h_final = np.zeros_like(h_curr)

        # 2a. Node classification cross-entropy loss & gradient
        d_w_node = np.zeros_like(self.w_node_)
        d_b_node = np.zeros_like(self.b_node_)
        if node_targets is not None:
            y_node = np.asarray(node_targets, dtype=np.int32)
            n_sup = min(len(y_node), n_total)
            w_n = (
                np.asarray(node_weights, dtype=np.float32)
                if node_weights is not None
                else np.ones(n_sup, dtype=np.float32)
            )
            valid_n = np.where((y_node[:n_sup] >= 0) & (y_node[:n_sup] < self.num_node_classes))[0]
            if len(valid_n) > 0:
                logits_n = h_curr[valid_n] @ self.w_node_ + self.b_node_[None, :]
                max_l = np.max(logits_n, axis=1, keepdims=True)
                exp_l = np.exp(logits_n - max_l)
                probs_n = exp_l / np.maximum(np.sum(exp_l, axis=1, keepdims=True), np.float32(1e-12))

                targets_v = y_node[valid_n]
                weights_v = w_n[valid_n] / np.float32(max(len(valid_n), 1))
                picked_p = probs_n[np.arange(len(valid_n)), targets_v]
                total_loss += float(np.sum(-weights_v * np.log(picked_p + np.float32(1e-10))))

                d_logits_n = probs_n.copy()
                d_logits_n[np.arange(len(valid_n)), targets_v] -= np.float32(1.0)
                d_logits_n *= weights_v[:, None]

                d_w_node = h_curr[valid_n].T @ d_logits_n
                d_b_node = np.sum(d_logits_n, axis=0)
                d_h_final[valid_n] += d_logits_n @ self.w_node_.T

        # 2b. Directed arborescence parent+slot cross-entropy loss & gradient
        d_w_esrc = np.zeros_like(self.w_edge_src_)
        d_w_edst = np.zeros_like(self.w_edge_dst_)
        d_w_echan = np.zeros_like(self.w_edge_chan_)
        d_b_echan = np.zeros_like(self.b_edge_chan_)

        if edge_parent_targets is not None:
            e_idx = (
                np.asarray(edge_node_indices, dtype=np.int32)
                if edge_node_indices is not None
                else np.arange(len(edge_parent_targets), dtype=np.int32)
            )
            p_targets = np.asarray(edge_parent_targets, dtype=np.int32)
            s_targets = (
                np.asarray(edge_slot_targets, dtype=np.int32)
                if edge_slot_targets is not None
                else np.zeros(len(p_targets), dtype=np.int32)
            )
            m_sub = len(e_idx)
            if m_sub >= 2:
                h_sub = np.ascontiguousarray(h_curr[e_idx], dtype=np.float32)
                u_proj = np.ascontiguousarray(h_sub @ self.w_edge_src_, dtype=np.float32)
                v_proj = np.ascontiguousarray(h_sub @ self.w_edge_dst_, dtype=np.float32)
                scores = _bilinear_edge_scores_forward(u_proj, v_proj, self.w_edge_chan_, self.b_edge_chan_)
                d_scores = np.zeros_like(scores)
                sup_children = np.where((p_targets >= 0) & (p_targets < m_sub))[0]
                if len(sup_children) > 0:
                    inv_m = np.float32(1.0 / len(sup_children))
                    for v_loc in sup_children:
                        u_true = int(p_targets[v_loc])
                        if u_true == v_loc:
                            continue
                        s_true = int(s_targets[v_loc])
                        if s_true < 0 or s_true >= self.num_edge_channels:
                            s_true = 0
                        col_logits = scores[:, v_loc, :].reshape(-1)
                        max_c = float(np.max(col_logits))
                        exp_c = np.exp(col_logits - max_c)
                        sum_c = float(np.sum(exp_c)) + 1e-12
                        probs_c = (exp_c / sum_c).reshape(m_sub, self.num_edge_channels)

                        p_gold = float(probs_c[u_true, s_true])
                        total_loss += float(-inv_m * np.log(max(p_gold, 1e-10)))

                        grad_slice = probs_c * inv_m
                        grad_slice[u_true, s_true] -= inv_m
                        grad_slice[v_loc, :] = 0.0
                        d_scores[:, v_loc, :] += grad_slice

                    d_u_proj, d_v_proj, d_w_echan, d_b_echan = _bilinear_edge_scores_backward(
                        d_scores,
                        u_proj,
                        v_proj,
                        self.w_edge_chan_,
                    )
                    d_w_esrc = h_sub.T @ d_u_proj
                    d_w_edst = h_sub.T @ d_v_proj
                    d_h_final[e_idx] += d_u_proj @ self.w_edge_src_.T + d_v_proj @ self.w_edge_dst_.T

        # 3. Backward pass through L relational MPNN layers
        d_w_rel_all = np.zeros_like(self.w_rel_)
        d_b_rel_all = np.zeros_like(self.b_rel_)
        d_h_curr = d_h_final
        d_h0_accum = np.zeros_like(h0)
        clamped_idx = np.flatnonzero(c_mask)
        inv_d = np.float32(1.0 / max(self.hidden_dim, 1))

        for lay in range(self.num_layers - 1, -1, -1):
            if len(clamped_idx) > 0:
                ir = rms_invs[lay][clamped_idx, None]
                pn_c = pre_norms[lay][clamped_idx]
                dh_c = d_h_curr[clamped_idx]
                dot_val = np.sum(dh_c * pn_c, axis=1, keepdims=True)
                d_h0_accum[clamped_idx] += np.float32(0.5) * (dh_c * ir - pn_c * (ir * ir * ir * inv_d) * dot_val)

            d_h_curr, dw_l, db_l = _mpnn_layer_backward(
                d_h_curr,
                h_inputs[lay],
                pre_norms[lay],
                rms_invs[lay],
                rel_csr.indptr,
                rel_csr.src_indices,
                rel_csr.rel_types,
                rel_csr.edge_weights,
                self.w_rel_[lay],
                c_mask,
            )
            d_w_rel_all[lay] = dw_l
            d_b_rel_all[lay] = db_l

        d_h0_total = d_h_curr + d_h0_accum
        d_w_in = x_full.T @ d_h0_total
        d_b_in = np.sum(d_h0_total, axis=0)

        # 4. AdamW parameter update
        self._step += 1
        bc1 = 1.0 - (0.9**self._step)
        bc2 = 1.0 - (0.999**self._step)
        lr_t = float(self.learning_rate * lr_scale)
        step_size = float(lr_t * np.sqrt(bc2) / bc1)
        wd_factor = float(1.0 - lr_t * self.weight_decay)

        _adamw_step(self.w_in_, d_w_in, self._m_w_in, self._v_w_in, step_size, wd_factor, True)
        _adamw_step(self.b_in_, d_b_in, self._m_b_in, self._v_b_in, step_size, wd_factor, False)
        _adamw_step(self.w_rel_, d_w_rel_all, self._m_w_rel, self._v_w_rel, step_size, wd_factor, True)
        _adamw_step(self.b_rel_, d_b_rel_all, self._m_b_rel, self._v_b_rel, step_size, wd_factor, False)

        if node_targets is not None:
            _adamw_step(self.w_node_, d_w_node, self._m_w_node, self._v_w_node, step_size, wd_factor, True)
            _adamw_step(self.b_node_, d_b_node, self._m_b_node, self._v_b_node, step_size, wd_factor, False)

        if edge_parent_targets is not None:
            _adamw_step(self.w_edge_src_, d_w_esrc, self._m_w_esrc, self._v_w_esrc, step_size, wd_factor, True)
            _adamw_step(self.w_edge_dst_, d_w_edst, self._m_w_edst, self._v_w_edst, step_size, wd_factor, True)
            _adamw_step(self.w_edge_chan_, d_w_echan, self._m_w_echan, self._v_w_echan, step_size, wd_factor, True)
            _adamw_step(self.b_edge_chan_, d_b_echan, self._m_b_echan, self._v_b_echan, step_size, wd_factor, False)

        self.loss_history_.append(total_loss)
        return total_loss

    def save(self, path: Path | str) -> Path:
        r"""Save model weights and hyperparameters to a compressed `.npz` archive (`allow_pickle=False`)."""
        p = Path(path)
        if not p.suffix:
            p = p.with_suffix(".npz")
        p.parent.mkdir(parents=True, exist_ok=True)
        meta = np.array(
            [
                self.in_dim,
                self.hidden_dim,
                self.num_node_classes,
                self.num_edge_channels,
                self.num_relations,
                self.num_layers,
                self.bilinear_rank,
                -1 if self.random_state is None else int(self.random_state),
            ],
            dtype=np.int64,
        )
        float_meta = np.array([self.learning_rate, self.weight_decay], dtype=np.float64)
        np.savez_compressed(
            p,
            meta=meta,
            float_meta=float_meta,
            w_in=self.w_in_,
            b_in=self.b_in_,
            w_rel=self.w_rel_,
            b_rel=self.b_rel_,
            w_node=self.w_node_,
            b_node=self.b_node_,
            w_edge_src=self.w_edge_src_,
            w_edge_dst=self.w_edge_dst_,
            w_edge_chan=self.w_edge_chan_,
            b_edge_chan=self.b_edge_chan_,
            loss_history=np.array(self.loss_history_, dtype=np.float32),
        )
        return p

    @classmethod
    def load(cls, path: Path | str) -> Self:
        r"""Load a [`RelationalMPNN`][seroverse.core.graph.gnn.RelationalMPNN] from a compressed `.npz` archive."""
        p = Path(path)
        if not p.suffix and not p.exists():
            p = p.with_suffix(".npz")
        with np.load(p, allow_pickle=False) as data:
            meta = data["meta"]
            float_meta = data["float_meta"]
            rs = None if int(meta[7]) < 0 else int(meta[7])
            inst = cls(
                in_dim=int(meta[0]),
                hidden_dim=int(meta[1]),
                num_node_classes=int(meta[2]),
                num_edge_channels=int(meta[3]),
                num_relations=int(meta[4]),
                num_layers=int(meta[5]),
                bilinear_rank=int(meta[6]),
                learning_rate=float(float_meta[0]),
                weight_decay=float(float_meta[1]),
                random_state=rs,
            )
            inst.w_in_ = data["w_in"].astype(np.float32)
            inst.b_in_ = data["b_in"].astype(np.float32)
            inst.w_rel_ = data["w_rel"].astype(np.float32)
            inst.b_rel_ = data["b_rel"].astype(np.float32)
            inst.w_node_ = data["w_node"].astype(np.float32)
            inst.b_node_ = data["b_node"].astype(np.float32)
            inst.w_edge_src_ = data["w_edge_src"].astype(np.float32)
            inst.w_edge_dst_ = data["w_edge_dst"].astype(np.float32)
            inst.w_edge_chan_ = data["w_edge_chan"].astype(np.float32)
            inst.b_edge_chan_ = data["b_edge_chan"].astype(np.float32)
            inst.loss_history_ = [float(x) for x in data["loss_history"].tolist()]
            return inst

encode

encode(
    x: ndarray,
    csr: RelationalCSR | Graph | Graphs[Any, Any],
    *,
    clamped_mask: ndarray | None = None,
) -> ndarray[tuple[int, int], dtype[float32]]

Run the \(L\)-layer R-MPNN encoder and return final node embeddings (N, hidden_dim).

Source code in src/seroverse/core/graph/gnn.py
def encode(
    self,
    x: np.ndarray,
    csr: RelationalCSR | Graph | Graphs[Any, Any],
    *,
    clamped_mask: np.ndarray | None = None,
) -> np.ndarray[tuple[int, int], np.dtype[np.float32]]:
    r"""Run the $L$-layer R-MPNN encoder and return final node embeddings `(N, hidden_dim)`."""
    rel_csr = self._coerce_csr(csr)
    x_full, c_mask = self._prepare_features(x, rel_csr, clamped_mask)
    h0 = np.ascontiguousarray(x_full @ self.w_in_ + self.b_in_[None, :], dtype=np.float32)
    h = h0
    for lay in range(self.num_layers):
        h, _, _ = _mpnn_layer_forward(
            h,
            rel_csr.indptr,
            rel_csr.src_indices,
            rel_csr.rel_types,
            rel_csr.edge_weights,
            self.w_rel_[lay],
            self.b_rel_[lay],
            c_mask,
            h0,
        )
    return h

predict_node_logits

predict_node_logits(
    x: ndarray,
    csr: RelationalCSR | Graph | Graphs[Any, Any],
    *,
    clamped_mask: ndarray | None = None,
) -> ndarray[tuple[int, int], dtype[float32]]

Compute pre-softmax node class logits (N, num_node_classes).

Source code in src/seroverse/core/graph/gnn.py
def predict_node_logits(
    self,
    x: np.ndarray,
    csr: RelationalCSR | Graph | Graphs[Any, Any],
    *,
    clamped_mask: np.ndarray | None = None,
) -> np.ndarray[tuple[int, int], np.dtype[np.float32]]:
    r"""Compute pre-softmax node class logits `(N, num_node_classes)`."""
    h = self.encode(x, csr, clamped_mask=clamped_mask)
    return np.ascontiguousarray(h @ self.w_node_ + self.b_node_[None, :], dtype=np.float32)

predict_node_proba

predict_node_proba(
    x: ndarray,
    csr: RelationalCSR | Graph | Graphs[Any, Any],
    *,
    clamped_mask: ndarray | None = None,
) -> ndarray[tuple[int, int], dtype[float32]]

Compute softmax node class probabilities (N, num_node_classes).

Source code in src/seroverse/core/graph/gnn.py
def predict_node_proba(
    self,
    x: np.ndarray,
    csr: RelationalCSR | Graph | Graphs[Any, Any],
    *,
    clamped_mask: np.ndarray | None = None,
) -> np.ndarray[tuple[int, int], np.dtype[np.float32]]:
    r"""Compute softmax node class probabilities `(N, num_node_classes)`."""
    logits = self.predict_node_logits(x, csr, clamped_mask=clamped_mask)
    max_l = np.max(logits, axis=1, keepdims=True)
    exp_l = np.exp(logits - max_l)
    return (exp_l / np.maximum(np.sum(exp_l, axis=1, keepdims=True), np.float32(1e-12))).astype(np.float32)

score_edges

score_edges(
    h_nodes: ndarray,
    node_indices: Sequence[int] | ndarray | None = None,
) -> ndarray[tuple[int, int, int], dtype[float32]]

Score all directed candidate edges (u -> v, channel) among h_nodes (or h_nodes[node_indices]).

Returns:

Type Description
ndarray[tuple[int, int, int], dtype[float32]]

np.ndarray: 3D float32 tensor of shape (M, M, num_edge_channels).

Source code in src/seroverse/core/graph/gnn.py
def score_edges(
    self,
    h_nodes: np.ndarray,
    node_indices: Sequence[int] | np.ndarray | None = None,
) -> np.ndarray[tuple[int, int, int], np.dtype[np.float32]]:
    r"""Score all directed candidate edges `(u -> v, channel)` among `h_nodes` (or `h_nodes[node_indices]`).

    Returns:
        np.ndarray: 3D `float32` tensor of shape `(M, M, num_edge_channels)`.
    """
    h_sub = (
        np.ascontiguousarray(h_nodes[np.asarray(node_indices, dtype=np.int32)], dtype=np.float32)
        if node_indices is not None
        else np.ascontiguousarray(h_nodes, dtype=np.float32)
    )
    u_proj = np.ascontiguousarray(h_sub @ self.w_edge_src_, dtype=np.float32)
    v_proj = np.ascontiguousarray(h_sub @ self.w_edge_dst_, dtype=np.float32)
    return _bilinear_edge_scores_forward(u_proj, v_proj, self.w_edge_chan_, self.b_edge_chan_)

fit_step

fit_step(
    x: ndarray,
    csr: RelationalCSR | Graph | Graphs[Any, Any],
    *,
    node_targets: ndarray | None = None,
    node_weights: ndarray | None = None,
    edge_node_indices: ndarray | None = None,
    edge_parent_targets: ndarray | None = None,
    edge_slot_targets: ndarray | None = None,
    clamped_mask: ndarray | None = None,
    lr_scale: float = 1.0,
) -> float

Execute one complete forward + backward + AdamW optimization step on a graph or batch.

Parameters:

Name Type Description Default
x ndarray

Input node feature matrix (N_real, in_dim) or (N_total, in_dim).

required
csr RelationalCSR | Graph | Graphs[Any, Any]

RelationalCSR, Graph, or Graphs connectivity structure.

required
node_targets ndarray | None

Optional 1D int32 array of ground-truth node class indices (-1 = ignore).

None
node_weights ndarray | None

Optional 1D float32 array of per-node loss weights.

None
edge_node_indices ndarray | None

Optional 1D int32 array of active tree vertices [v_0, ..., v_{M-1}].

None
edge_parent_targets ndarray | None

Optional 1D int32 array of length M giving target parent index in [0, M-1] for each active tree vertex (-1 for root or unsupervised vertices).

None
edge_slot_targets ndarray | None

Optional 1D int32 array of length M giving target slot/channel in [0, num_edge_channels - 1] for each active tree vertex (-1 = marginalize over slots).

None
clamped_mask ndarray | None

Optional 1D uint8 / boolean mask of clamped vertices.

None
lr_scale float

Cosine/schedule multiplier on self.learning_rate.

1.0

Returns:

Name Type Description
float float

Total scalar loss for this step.

Source code in src/seroverse/core/graph/gnn.py
def fit_step(
    self,
    x: np.ndarray,
    csr: RelationalCSR | Graph | Graphs[Any, Any],
    *,
    node_targets: np.ndarray | None = None,
    node_weights: np.ndarray | None = None,
    edge_node_indices: np.ndarray | None = None,
    edge_parent_targets: np.ndarray | None = None,
    edge_slot_targets: np.ndarray | None = None,
    clamped_mask: np.ndarray | None = None,
    lr_scale: float = 1.0,
) -> float:
    r"""Execute one complete forward + backward + AdamW optimization step on a graph or batch.

    Args:
        x: Input node feature matrix `(N_real, in_dim)` or `(N_total, in_dim)`.
        csr: [`RelationalCSR`][seroverse.core.graph.csr.RelationalCSR], [`Graph`][seroverse.core.graph.models.Graph],
            or [`Graphs`][seroverse.core.graph.models.Graphs] connectivity structure.
        node_targets: Optional 1D `int32` array of ground-truth node class indices (`-1` = ignore).
        node_weights: Optional 1D `float32` array of per-node loss weights.
        edge_node_indices: Optional 1D `int32` array of active tree vertices `[v_0, ..., v_{M-1}]`.
        edge_parent_targets: Optional 1D `int32` array of length `M` giving target parent index in `[0, M-1]`
            for each active tree vertex (`-1` for root or unsupervised vertices).
        edge_slot_targets: Optional 1D `int32` array of length `M` giving target slot/channel in
            `[0, num_edge_channels - 1]` for each active tree vertex (`-1` = marginalize over slots).
        clamped_mask: Optional 1D `uint8` / boolean mask of clamped vertices.
        lr_scale: Cosine/schedule multiplier on `self.learning_rate`.

    Returns:
        float: Total scalar loss for this step.
    """
    rel_csr = self._coerce_csr(csr)
    x_full, c_mask = self._prepare_features(x, rel_csr, clamped_mask)
    n_total = rel_csr.n_nodes

    # 1. Forward pass with layer caches
    h0 = np.ascontiguousarray(x_full @ self.w_in_ + self.b_in_[None, :], dtype=np.float32)
    h_inputs: list[np.ndarray] = []
    pre_norms: list[np.ndarray] = []
    rms_invs: list[np.ndarray] = []

    h_curr = h0
    for lay in range(self.num_layers):
        h_inputs.append(h_curr)
        h_curr, pn, ri = _mpnn_layer_forward(
            h_curr,
            rel_csr.indptr,
            rel_csr.src_indices,
            rel_csr.rel_types,
            rel_csr.edge_weights,
            self.w_rel_[lay],
            self.b_rel_[lay],
            c_mask,
            h0,
        )
        pre_norms.append(pn)
        rms_invs.append(ri)

    total_loss = 0.0
    d_h_final = np.zeros_like(h_curr)

    # 2a. Node classification cross-entropy loss & gradient
    d_w_node = np.zeros_like(self.w_node_)
    d_b_node = np.zeros_like(self.b_node_)
    if node_targets is not None:
        y_node = np.asarray(node_targets, dtype=np.int32)
        n_sup = min(len(y_node), n_total)
        w_n = (
            np.asarray(node_weights, dtype=np.float32)
            if node_weights is not None
            else np.ones(n_sup, dtype=np.float32)
        )
        valid_n = np.where((y_node[:n_sup] >= 0) & (y_node[:n_sup] < self.num_node_classes))[0]
        if len(valid_n) > 0:
            logits_n = h_curr[valid_n] @ self.w_node_ + self.b_node_[None, :]
            max_l = np.max(logits_n, axis=1, keepdims=True)
            exp_l = np.exp(logits_n - max_l)
            probs_n = exp_l / np.maximum(np.sum(exp_l, axis=1, keepdims=True), np.float32(1e-12))

            targets_v = y_node[valid_n]
            weights_v = w_n[valid_n] / np.float32(max(len(valid_n), 1))
            picked_p = probs_n[np.arange(len(valid_n)), targets_v]
            total_loss += float(np.sum(-weights_v * np.log(picked_p + np.float32(1e-10))))

            d_logits_n = probs_n.copy()
            d_logits_n[np.arange(len(valid_n)), targets_v] -= np.float32(1.0)
            d_logits_n *= weights_v[:, None]

            d_w_node = h_curr[valid_n].T @ d_logits_n
            d_b_node = np.sum(d_logits_n, axis=0)
            d_h_final[valid_n] += d_logits_n @ self.w_node_.T

    # 2b. Directed arborescence parent+slot cross-entropy loss & gradient
    d_w_esrc = np.zeros_like(self.w_edge_src_)
    d_w_edst = np.zeros_like(self.w_edge_dst_)
    d_w_echan = np.zeros_like(self.w_edge_chan_)
    d_b_echan = np.zeros_like(self.b_edge_chan_)

    if edge_parent_targets is not None:
        e_idx = (
            np.asarray(edge_node_indices, dtype=np.int32)
            if edge_node_indices is not None
            else np.arange(len(edge_parent_targets), dtype=np.int32)
        )
        p_targets = np.asarray(edge_parent_targets, dtype=np.int32)
        s_targets = (
            np.asarray(edge_slot_targets, dtype=np.int32)
            if edge_slot_targets is not None
            else np.zeros(len(p_targets), dtype=np.int32)
        )
        m_sub = len(e_idx)
        if m_sub >= 2:
            h_sub = np.ascontiguousarray(h_curr[e_idx], dtype=np.float32)
            u_proj = np.ascontiguousarray(h_sub @ self.w_edge_src_, dtype=np.float32)
            v_proj = np.ascontiguousarray(h_sub @ self.w_edge_dst_, dtype=np.float32)
            scores = _bilinear_edge_scores_forward(u_proj, v_proj, self.w_edge_chan_, self.b_edge_chan_)
            d_scores = np.zeros_like(scores)
            sup_children = np.where((p_targets >= 0) & (p_targets < m_sub))[0]
            if len(sup_children) > 0:
                inv_m = np.float32(1.0 / len(sup_children))
                for v_loc in sup_children:
                    u_true = int(p_targets[v_loc])
                    if u_true == v_loc:
                        continue
                    s_true = int(s_targets[v_loc])
                    if s_true < 0 or s_true >= self.num_edge_channels:
                        s_true = 0
                    col_logits = scores[:, v_loc, :].reshape(-1)
                    max_c = float(np.max(col_logits))
                    exp_c = np.exp(col_logits - max_c)
                    sum_c = float(np.sum(exp_c)) + 1e-12
                    probs_c = (exp_c / sum_c).reshape(m_sub, self.num_edge_channels)

                    p_gold = float(probs_c[u_true, s_true])
                    total_loss += float(-inv_m * np.log(max(p_gold, 1e-10)))

                    grad_slice = probs_c * inv_m
                    grad_slice[u_true, s_true] -= inv_m
                    grad_slice[v_loc, :] = 0.0
                    d_scores[:, v_loc, :] += grad_slice

                d_u_proj, d_v_proj, d_w_echan, d_b_echan = _bilinear_edge_scores_backward(
                    d_scores,
                    u_proj,
                    v_proj,
                    self.w_edge_chan_,
                )
                d_w_esrc = h_sub.T @ d_u_proj
                d_w_edst = h_sub.T @ d_v_proj
                d_h_final[e_idx] += d_u_proj @ self.w_edge_src_.T + d_v_proj @ self.w_edge_dst_.T

    # 3. Backward pass through L relational MPNN layers
    d_w_rel_all = np.zeros_like(self.w_rel_)
    d_b_rel_all = np.zeros_like(self.b_rel_)
    d_h_curr = d_h_final
    d_h0_accum = np.zeros_like(h0)
    clamped_idx = np.flatnonzero(c_mask)
    inv_d = np.float32(1.0 / max(self.hidden_dim, 1))

    for lay in range(self.num_layers - 1, -1, -1):
        if len(clamped_idx) > 0:
            ir = rms_invs[lay][clamped_idx, None]
            pn_c = pre_norms[lay][clamped_idx]
            dh_c = d_h_curr[clamped_idx]
            dot_val = np.sum(dh_c * pn_c, axis=1, keepdims=True)
            d_h0_accum[clamped_idx] += np.float32(0.5) * (dh_c * ir - pn_c * (ir * ir * ir * inv_d) * dot_val)

        d_h_curr, dw_l, db_l = _mpnn_layer_backward(
            d_h_curr,
            h_inputs[lay],
            pre_norms[lay],
            rms_invs[lay],
            rel_csr.indptr,
            rel_csr.src_indices,
            rel_csr.rel_types,
            rel_csr.edge_weights,
            self.w_rel_[lay],
            c_mask,
        )
        d_w_rel_all[lay] = dw_l
        d_b_rel_all[lay] = db_l

    d_h0_total = d_h_curr + d_h0_accum
    d_w_in = x_full.T @ d_h0_total
    d_b_in = np.sum(d_h0_total, axis=0)

    # 4. AdamW parameter update
    self._step += 1
    bc1 = 1.0 - (0.9**self._step)
    bc2 = 1.0 - (0.999**self._step)
    lr_t = float(self.learning_rate * lr_scale)
    step_size = float(lr_t * np.sqrt(bc2) / bc1)
    wd_factor = float(1.0 - lr_t * self.weight_decay)

    _adamw_step(self.w_in_, d_w_in, self._m_w_in, self._v_w_in, step_size, wd_factor, True)
    _adamw_step(self.b_in_, d_b_in, self._m_b_in, self._v_b_in, step_size, wd_factor, False)
    _adamw_step(self.w_rel_, d_w_rel_all, self._m_w_rel, self._v_w_rel, step_size, wd_factor, True)
    _adamw_step(self.b_rel_, d_b_rel_all, self._m_b_rel, self._v_b_rel, step_size, wd_factor, False)

    if node_targets is not None:
        _adamw_step(self.w_node_, d_w_node, self._m_w_node, self._v_w_node, step_size, wd_factor, True)
        _adamw_step(self.b_node_, d_b_node, self._m_b_node, self._v_b_node, step_size, wd_factor, False)

    if edge_parent_targets is not None:
        _adamw_step(self.w_edge_src_, d_w_esrc, self._m_w_esrc, self._v_w_esrc, step_size, wd_factor, True)
        _adamw_step(self.w_edge_dst_, d_w_edst, self._m_w_edst, self._v_w_edst, step_size, wd_factor, True)
        _adamw_step(self.w_edge_chan_, d_w_echan, self._m_w_echan, self._v_w_echan, step_size, wd_factor, True)
        _adamw_step(self.b_edge_chan_, d_b_echan, self._m_b_echan, self._v_b_echan, step_size, wd_factor, False)

    self.loss_history_.append(total_loss)
    return total_loss

save

save(path: Path | str) -> Path

Save model weights and hyperparameters to a compressed .npz archive (allow_pickle=False).

Source code in src/seroverse/core/graph/gnn.py
def save(self, path: Path | str) -> Path:
    r"""Save model weights and hyperparameters to a compressed `.npz` archive (`allow_pickle=False`)."""
    p = Path(path)
    if not p.suffix:
        p = p.with_suffix(".npz")
    p.parent.mkdir(parents=True, exist_ok=True)
    meta = np.array(
        [
            self.in_dim,
            self.hidden_dim,
            self.num_node_classes,
            self.num_edge_channels,
            self.num_relations,
            self.num_layers,
            self.bilinear_rank,
            -1 if self.random_state is None else int(self.random_state),
        ],
        dtype=np.int64,
    )
    float_meta = np.array([self.learning_rate, self.weight_decay], dtype=np.float64)
    np.savez_compressed(
        p,
        meta=meta,
        float_meta=float_meta,
        w_in=self.w_in_,
        b_in=self.b_in_,
        w_rel=self.w_rel_,
        b_rel=self.b_rel_,
        w_node=self.w_node_,
        b_node=self.b_node_,
        w_edge_src=self.w_edge_src_,
        w_edge_dst=self.w_edge_dst_,
        w_edge_chan=self.w_edge_chan_,
        b_edge_chan=self.b_edge_chan_,
        loss_history=np.array(self.loss_history_, dtype=np.float32),
    )
    return p

load classmethod

load(path: Path | str) -> Self

Load a RelationalMPNN from a compressed .npz archive.

Source code in src/seroverse/core/graph/gnn.py
@classmethod
def load(cls, path: Path | str) -> Self:
    r"""Load a [`RelationalMPNN`][seroverse.core.graph.gnn.RelationalMPNN] from a compressed `.npz` archive."""
    p = Path(path)
    if not p.suffix and not p.exists():
        p = p.with_suffix(".npz")
    with np.load(p, allow_pickle=False) as data:
        meta = data["meta"]
        float_meta = data["float_meta"]
        rs = None if int(meta[7]) < 0 else int(meta[7])
        inst = cls(
            in_dim=int(meta[0]),
            hidden_dim=int(meta[1]),
            num_node_classes=int(meta[2]),
            num_edge_channels=int(meta[3]),
            num_relations=int(meta[4]),
            num_layers=int(meta[5]),
            bilinear_rank=int(meta[6]),
            learning_rate=float(float_meta[0]),
            weight_decay=float(float_meta[1]),
            random_state=rs,
        )
        inst.w_in_ = data["w_in"].astype(np.float32)
        inst.b_in_ = data["b_in"].astype(np.float32)
        inst.w_rel_ = data["w_rel"].astype(np.float32)
        inst.b_rel_ = data["b_rel"].astype(np.float32)
        inst.w_node_ = data["w_node"].astype(np.float32)
        inst.b_node_ = data["b_node"].astype(np.float32)
        inst.w_edge_src_ = data["w_edge_src"].astype(np.float32)
        inst.w_edge_dst_ = data["w_edge_dst"].astype(np.float32)
        inst.w_edge_chan_ = data["w_edge_chan"].astype(np.float32)
        inst.b_edge_chan_ = data["b_edge_chan"].astype(np.float32)
        inst.loss_history_ = [float(x) for x in data["loss_history"].tolist()]
        return inst

arborescence

Directed spanning arborescence solvers and Structure-of-Arrays (SoA) containers.

Provides compiled Numba branch-and-bound algorithms encapsulated in an object-oriented solver and ragged SoA container hierarchy:

  • Arborescence: Scalar view of a single directed spanning arborescence.
  • Arborescences: Ragged Structure-of-Arrays container of arborescences.
  • ArborescenceSolver: Configurable solver for maximum/minimum and \(k\)-best directed spanning arborescences.

Classes:

Name Description
Arborescence

Scalar view of a single directed spanning arborescence.

Arborescences

Ragged Structure-of-Arrays (SoA) container of Arborescence trees.

ArborescenceSolver

Configurable solver for directed spanning arborescences and \(k\)-best arborescence ensembles.

Arborescence dataclass

Bases: ScalarView

Scalar view of a single directed spanning arborescence.

Attributes:

Name Type Description
parents Int32Array

1D int32 array of shape (N,) where parents[v] is the parent node \(u\) of \(v\) (-1 for root or excluded nodes).

score float

Total weight (or cost) of the selected arborescence edges.

slots Int32Array

1D int32 array of shape (N,) giving the selected channel/slot index \(s\) on the incoming edge \((u \to v, s)\) (0 for 2D inputs, -1 for root).

root int

Root vertex index (0 <= root < N). Defaults to 0.

Methods:

Name Description
maximum

Find the maximum-weight directed spanning arborescence rooted at root.

minimum

Find the minimum-cost directed spanning arborescence rooted at root.

Source code in src/seroverse/core/graph/arborescence.py
@dataclass(slots=True, frozen=True)
class Arborescence(ScalarView):
    r"""Scalar view of a single directed spanning arborescence.

    Attributes:
        parents: 1D `int32` array of shape `(N,)` where `parents[v]` is the parent node $u$ of $v$
            (`-1` for `root` or excluded nodes).
        score: Total weight (or cost) of the selected arborescence edges.
        slots: 1D `int32` array of shape `(N,)` giving the selected channel/slot index $s$ on the
            incoming edge $(u \to v, s)$ (`0` for 2D inputs, `-1` for `root`).
        root: Root vertex index (`0 <= root < N`). Defaults to `0`.
    """

    parents: Int32Array
    score: float
    slots: Int32Array
    root: int = 0

    @property
    def n_nodes(self) -> int:
        r"""Total number of vertices $N$ in the parent array."""
        return len(self.parents)

    @property
    def edges(self) -> np.ndarray[tuple[int, int], np.dtype[np.int32]]:
        r"""2D `int32` array of shape `(M, 3)` containing `(parent_u, child_v, slot_s)` for each tree edge."""
        n = len(self.parents)
        if n <= 1:
            return np.empty((0, 3), dtype=np.int32)
        v_idx = np.arange(n, dtype=np.int32)
        valid = (v_idx != self.root) & (self.parents >= 0)
        if not np.any(valid):
            return np.empty((0, 3), dtype=np.int32)
        v_sel = v_idx[valid]
        return np.column_stack((self.parents[v_sel], v_sel, np.maximum(self.slots[v_sel], np.int32(0)))).astype(
            np.int32, copy=False
        )

    @classmethod
    def maximum(
        cls,
        weights: np.ndarray,
        root: int = 0,
        *,
        edge_groups: np.ndarray | None = None,
        required_nodes: np.ndarray | None = None,
        slot_exclusivity: bool = True,
        max_backtracks: int = 250_000,
    ) -> Self:
        r"""Find the maximum-weight directed spanning arborescence rooted at `root`."""
        res = ArborescenceSolver(
            mode="max", root=root, k=1, slot_exclusivity=slot_exclusivity, max_backtracks=max_backtracks
        ).solve(weights, root=root, edge_groups=edge_groups, required_nodes=required_nodes)
        return cls(parents=res.parents, score=res.score, slots=res.slots, root=res.root)

    @classmethod
    def minimum(
        cls,
        costs: np.ndarray,
        root: int = 0,
        *,
        edge_groups: np.ndarray | None = None,
        required_nodes: np.ndarray | None = None,
        slot_exclusivity: bool = True,
        max_backtracks: int = 250_000,
    ) -> Self:
        r"""Find the minimum-cost directed spanning arborescence rooted at `root`."""
        res = ArborescenceSolver(
            mode="min", root=root, k=1, slot_exclusivity=slot_exclusivity, max_backtracks=max_backtracks
        ).solve(costs, root=root, edge_groups=edge_groups, required_nodes=required_nodes)
        return cls(parents=res.parents, score=res.score, slots=res.slots, root=res.root)

n_nodes property

n_nodes: int

Total number of vertices \(N\) in the parent array.

edges property

edges: ndarray[tuple[int, int], dtype[int32]]

2D int32 array of shape (M, 3) containing (parent_u, child_v, slot_s) for each tree edge.

maximum classmethod

maximum(
    weights: ndarray,
    root: int = 0,
    *,
    edge_groups: ndarray | None = None,
    required_nodes: ndarray | None = None,
    slot_exclusivity: bool = True,
    max_backtracks: int = 250000,
) -> Self

Find the maximum-weight directed spanning arborescence rooted at root.

Source code in src/seroverse/core/graph/arborescence.py
@classmethod
def maximum(
    cls,
    weights: np.ndarray,
    root: int = 0,
    *,
    edge_groups: np.ndarray | None = None,
    required_nodes: np.ndarray | None = None,
    slot_exclusivity: bool = True,
    max_backtracks: int = 250_000,
) -> Self:
    r"""Find the maximum-weight directed spanning arborescence rooted at `root`."""
    res = ArborescenceSolver(
        mode="max", root=root, k=1, slot_exclusivity=slot_exclusivity, max_backtracks=max_backtracks
    ).solve(weights, root=root, edge_groups=edge_groups, required_nodes=required_nodes)
    return cls(parents=res.parents, score=res.score, slots=res.slots, root=res.root)

minimum classmethod

minimum(
    costs: ndarray,
    root: int = 0,
    *,
    edge_groups: ndarray | None = None,
    required_nodes: ndarray | None = None,
    slot_exclusivity: bool = True,
    max_backtracks: int = 250000,
) -> Self

Find the minimum-cost directed spanning arborescence rooted at root.

Source code in src/seroverse/core/graph/arborescence.py
@classmethod
def minimum(
    cls,
    costs: np.ndarray,
    root: int = 0,
    *,
    edge_groups: np.ndarray | None = None,
    required_nodes: np.ndarray | None = None,
    slot_exclusivity: bool = True,
    max_backtracks: int = 250_000,
) -> Self:
    r"""Find the minimum-cost directed spanning arborescence rooted at `root`."""
    res = ArborescenceSolver(
        mode="min", root=root, k=1, slot_exclusivity=slot_exclusivity, max_backtracks=max_backtracks
    ).solve(costs, root=root, edge_groups=edge_groups, required_nodes=required_nodes)
    return cls(parents=res.parents, score=res.score, slots=res.slots, root=res.root)

Arborescences dataclass

Bases: RaggedArrayContainer[Arborescence, 'Arborescences']

Ragged Structure-of-Arrays (SoA) container of Arborescence trees.

Attributes:

Name Type Description
parents Int32Array

Flat 1D int32 array of concatenated parent pointers across all trees.

scores Float64Array

1D float64 array of total tree scores/costs of shape (B,).

slots Int32Array

Flat 1D int32 array of concatenated incoming edge slot indices across all trees.

roots Int32Array

1D int32 array of root vertex indices of shape (B,).

offsets Int32Array

1D int32 array of starting indices into parents and slots per tree.

lengths Int32Array

1D int32 array of vertex counts per tree.

Methods:

Name Description
k_best

Enumerate the top-k optimal directed spanning arborescences rooted at root.

Source code in src/seroverse/core/graph/arborescence.py
@dataclass(slots=True, frozen=True)
class Arborescences(RaggedArrayContainer[Arborescence, "Arborescences"]):
    r"""Ragged Structure-of-Arrays (SoA) container of [`Arborescence`][seroverse.core.graph.arborescence.Arborescence] trees.

    Attributes:
        parents: Flat 1D `int32` array of concatenated parent pointers across all trees.
        scores: 1D `float64` array of total tree scores/costs of shape `(B,)`.
        slots: Flat 1D `int32` array of concatenated incoming edge slot indices across all trees.
        roots: 1D `int32` array of root vertex indices of shape `(B,)`.
        offsets: 1D `int32` array of starting indices into `parents` and `slots` per tree.
        lengths: 1D `int32` array of vertex counts per tree.
    """

    _ragged_fields: ClassVar[tuple[str, ...]] = ("parents", "slots")

    parents: Int32Array
    scores: Float64Array
    slots: Int32Array
    roots: Int32Array
    offsets: Int32Array
    lengths: Int32Array

    @classmethod
    def k_best(
        cls,
        weights: np.ndarray,
        root: int = 0,
        k: int = 5,
        *,
        mode: Literal["max", "min"] = "max",
        edge_groups: np.ndarray | None = None,
        required_nodes: np.ndarray | None = None,
        slot_exclusivity: bool = True,
        max_backtracks: int = 250_000,
    ) -> Self:
        r"""Enumerate the top-`k` optimal directed spanning arborescences rooted at `root`."""
        res = ArborescenceSolver(
            mode=mode, root=root, k=k, slot_exclusivity=slot_exclusivity, max_backtracks=max_backtracks
        ).solve_k_best(weights, root=root, k=k, edge_groups=edge_groups, required_nodes=required_nodes)
        return cls(
            scores=res.scores,
            roots=res.roots,
            parents=res.parents,
            slots=res.slots,
            offsets=res.offsets,
            lengths=res.lengths,
        )

k_best classmethod

k_best(
    weights: ndarray,
    root: int = 0,
    k: int = 5,
    *,
    mode: Literal["max", "min"] = "max",
    edge_groups: ndarray | None = None,
    required_nodes: ndarray | None = None,
    slot_exclusivity: bool = True,
    max_backtracks: int = 250000,
) -> Self

Enumerate the top-k optimal directed spanning arborescences rooted at root.

Source code in src/seroverse/core/graph/arborescence.py
@classmethod
def k_best(
    cls,
    weights: np.ndarray,
    root: int = 0,
    k: int = 5,
    *,
    mode: Literal["max", "min"] = "max",
    edge_groups: np.ndarray | None = None,
    required_nodes: np.ndarray | None = None,
    slot_exclusivity: bool = True,
    max_backtracks: int = 250_000,
) -> Self:
    r"""Enumerate the top-`k` optimal directed spanning arborescences rooted at `root`."""
    res = ArborescenceSolver(
        mode=mode, root=root, k=k, slot_exclusivity=slot_exclusivity, max_backtracks=max_backtracks
    ).solve_k_best(weights, root=root, k=k, edge_groups=edge_groups, required_nodes=required_nodes)
    return cls(
        scores=res.scores,
        roots=res.roots,
        parents=res.parents,
        slots=res.slots,
        offsets=res.offsets,
        lengths=res.lengths,
    )

ArborescenceSolver dataclass

Configurable solver for directed spanning arborescences and \(k\)-best arborescence ensembles.

Uses compiled Numba branch-and-bound with regret-ordered vertex branching and suffix upper-bound pruning. Supports 2D (N, N) and 3D (N, N, S) edge tensors, valence/slot exclusivity (slot_exclusivity=True), custom edge_groups constraints, and subset required_nodes.

Attributes:

Name Type Description
mode Literal['max', 'min']

Optimization sense ("max" for maximum weight or "min" for minimum cost). Defaults to "max".

root int

Default root vertex index (0 <= root < N). Defaults to 0.

k int

Default number of top arborescences to enumerate (k >= 1). Defaults to 1.

slot_exclusivity bool

If True (default), enforces that at most one outgoing edge from parent \(u\) may occupy slot \(s\) on 3D (N, N, S) weight tensors when edge_groups is None.

max_backtracks int

Maximum branch-and-bound backtracks before returning best found solutions. Defaults to 250_000.

Methods:

Name Description
solve_k_best

Enumerate the top-k optimal arborescences in a ragged Arborescences container.

solve

Solve for the single optimal Arborescence.

Source code in src/seroverse/core/graph/arborescence.py
@dataclass(slots=True, frozen=True)
class ArborescenceSolver:
    r"""Configurable solver for directed spanning arborescences and $k$-best arborescence ensembles.

    Uses compiled Numba branch-and-bound with regret-ordered vertex branching and suffix upper-bound
    pruning. Supports 2D `(N, N)` and 3D `(N, N, S)` edge tensors, valence/slot exclusivity
    (`slot_exclusivity=True`), custom `edge_groups` constraints, and subset `required_nodes`.

    Attributes:
        mode: Optimization sense (`"max"` for maximum weight or `"min"` for minimum cost). Defaults to `"max"`.
        root: Default root vertex index (`0 <= root < N`). Defaults to `0`.
        k: Default number of top arborescences to enumerate (`k >= 1`). Defaults to `1`.
        slot_exclusivity: If `True` (default), enforces that at most one outgoing edge from parent $u$
            may occupy slot $s$ on 3D `(N, N, S)` weight tensors when `edge_groups` is `None`.
        max_backtracks: Maximum branch-and-bound backtracks before returning best found solutions. Defaults to `250_000`.
    """

    mode: Literal["max", "min"] = "max"
    root: int = 0
    k: int = 1
    slot_exclusivity: bool = True
    max_backtracks: int = 250_000

    def __post_init__(self) -> None:
        r"""Validate solver hyperparameters."""
        if self.mode not in ("max", "min"):
            raise ValueError(f"mode must be 'max' or 'min'; got {self.mode!r}.")
        if self.k < 1:
            raise ValueError(f"k must be >= 1; got {self.k}.")
        if self.max_backtracks < 1:
            raise ValueError(f"max_backtracks must be >= 1; got {self.max_backtracks}.")

    def _prepare_inputs(
        self,
        weights: np.ndarray,
        root: int,
        edge_groups: np.ndarray | None,
        required_nodes: np.ndarray | None,
    ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
        """Normalize 2D `(N, N)` or 3D `(N, N, S)` weights, active vertex mask, and exclusivity groups."""
        w_arr = np.asarray(weights, dtype=np.float64)
        if self.mode == "min":
            w_arr = np.where(np.isinf(w_arr) & (w_arr > 0), _NEG_INF, -w_arr)

        if w_arr.ndim == 2:
            n, n2 = w_arr.shape
            if n != n2:
                raise ValueError(f"2D weights matrix must be square (N, N); got {w_arr.shape}.")
            w_3d = np.ascontiguousarray(w_arr[:, :, None])
            s_dim = 1
        elif w_arr.ndim == 3:
            n, n2, s_dim = w_arr.shape
            if n != n2:
                raise ValueError(f"3D weights tensor must have shape (N, N, S); got {w_arr.shape}.")
            w_3d = np.ascontiguousarray(w_arr)
        else:
            raise ValueError(f"weights must be 2D (N, N) or 3D (N, N, S); got {w_arr.ndim}D.")

        if n > 0 and (root < 0 or root >= n):
            raise ValueError(f"root index {root} out of range [0, {n - 1}].")

        active_mask = np.ones(n, dtype=np.uint8)
        if required_nodes is not None:
            req = np.asarray(required_nodes)
            if req.dtype == np.bool_:
                if len(req) != n:
                    raise ValueError(f"Boolean required_nodes mask length {len(req)} != {n}.")
                active_mask = req.astype(np.uint8)
            else:
                active_mask.fill(0)
                active_mask[req.astype(np.int32)] = 1
            if n > 0:
                active_mask[root] = 1

        if edge_groups is not None:
            g_arr = np.asarray(edge_groups, dtype=np.int32)
            if g_arr.ndim == 2 and w_arr.ndim == 2:
                g_3d = np.ascontiguousarray(g_arr[:, :, None])
            elif g_arr.shape == w_3d.shape:
                g_3d = np.ascontiguousarray(g_arr)
            else:
                raise ValueError(f"edge_groups shape {g_arr.shape} incompatible with weights shape {w_arr.shape}.")
        elif w_arr.ndim == 3 and self.slot_exclusivity and s_dim > 1:
            g_3d = np.broadcast_to(
                (np.arange(n, dtype=np.int32)[:, None, None] * s_dim) + np.arange(s_dim, dtype=np.int32)[None, None, :],
                (n, n, s_dim),
            ).copy()
        else:
            g_3d = np.full((n, n, s_dim), -1, dtype=np.int32)

        return w_3d, g_3d, active_mask

    def solve_k_best(
        self,
        weights: np.ndarray,
        *,
        root: int | None = None,
        k: int | None = None,
        edge_groups: np.ndarray | None = None,
        required_nodes: np.ndarray | None = None,
    ) -> Arborescences:
        r"""Enumerate the top-`k` optimal arborescences in a ragged [`Arborescences`][seroverse.core.graph.arborescence.Arborescences] container."""
        r_idx = self.root if root is None else int(root)
        k_eff = self.k if k is None else int(k)
        if k_eff < 1:
            raise ValueError(f"k must be >= 1; got {k_eff}.")

        w_3d, g_3d, active_mask = self._prepare_inputs(weights, r_idx, edge_groups, required_nodes)
        n = w_3d.shape[0]
        if n <= 1:
            return Arborescences(
                scores=np.zeros(1, dtype=np.float64),
                roots=np.zeros(1, dtype=np.int32),
                parents=np.full(n, -1, dtype=np.int32),
                slots=np.full(n, -1, dtype=np.int32),
                offsets=np.zeros(1, dtype=np.int32),
                lengths=np.array([n], dtype=np.int32),
            )

        out_p, out_s, out_sc, found = _branch_and_bound_k_best(
            w_3d, g_3d, active_mask, r_idx, k_eff, self.max_backtracks
        )
        if found == 0:
            return Arborescences.empty()

        scores = (
            np.where(out_sc > _NEG_INF * 0.1, -out_sc, np.inf).astype(np.float64)
            if self.mode == "min"
            else out_sc.astype(np.float64, copy=False)
        )
        lengths = np.full(found, n, dtype=np.int32)
        return Arborescences(
            scores=scores,
            roots=np.full(found, r_idx, dtype=np.int32),
            parents=np.ascontiguousarray(out_p.reshape(-1), dtype=np.int32),
            slots=np.ascontiguousarray(out_s.reshape(-1), dtype=np.int32),
            offsets=Arborescences.lengths_to_offsets(lengths),
            lengths=lengths,
        )

    def solve(
        self,
        weights: np.ndarray,
        *,
        root: int | None = None,
        edge_groups: np.ndarray | None = None,
        required_nodes: np.ndarray | None = None,
    ) -> Arborescence:
        r"""Solve for the single optimal [`Arborescence`][seroverse.core.graph.arborescence.Arborescence]."""
        r_idx = self.root if root is None else int(root)
        batch = self.solve_k_best(weights, root=r_idx, k=1, edge_groups=edge_groups, required_nodes=required_nodes)
        if len(batch) > 0:
            return batch[0]
        n = np.asarray(weights).shape[0]
        return Arborescence(
            parents=np.full(n, -1, dtype=np.int32),
            score=float("inf") if self.mode == "min" else float(_NEG_INF),
            slots=np.full(n, -1, dtype=np.int32),
            root=r_idx,
        )

    def __call__(
        self,
        weights: np.ndarray,
        *,
        root: int | None = None,
        edge_groups: np.ndarray | None = None,
        required_nodes: np.ndarray | None = None,
    ) -> Arborescence | Arborescences:
        r"""Run `.solve()` when `self.k == 1`, or `.solve_k_best()` when `self.k > 1`."""
        if self.k == 1:
            return self.solve(weights, root=root, edge_groups=edge_groups, required_nodes=required_nodes)
        return self.solve_k_best(weights, root=root, k=self.k, edge_groups=edge_groups, required_nodes=required_nodes)

solve_k_best

solve_k_best(
    weights: ndarray,
    *,
    root: int | None = None,
    k: int | None = None,
    edge_groups: ndarray | None = None,
    required_nodes: ndarray | None = None,
) -> Arborescences

Enumerate the top-k optimal arborescences in a ragged Arborescences container.

Source code in src/seroverse/core/graph/arborescence.py
def solve_k_best(
    self,
    weights: np.ndarray,
    *,
    root: int | None = None,
    k: int | None = None,
    edge_groups: np.ndarray | None = None,
    required_nodes: np.ndarray | None = None,
) -> Arborescences:
    r"""Enumerate the top-`k` optimal arborescences in a ragged [`Arborescences`][seroverse.core.graph.arborescence.Arborescences] container."""
    r_idx = self.root if root is None else int(root)
    k_eff = self.k if k is None else int(k)
    if k_eff < 1:
        raise ValueError(f"k must be >= 1; got {k_eff}.")

    w_3d, g_3d, active_mask = self._prepare_inputs(weights, r_idx, edge_groups, required_nodes)
    n = w_3d.shape[0]
    if n <= 1:
        return Arborescences(
            scores=np.zeros(1, dtype=np.float64),
            roots=np.zeros(1, dtype=np.int32),
            parents=np.full(n, -1, dtype=np.int32),
            slots=np.full(n, -1, dtype=np.int32),
            offsets=np.zeros(1, dtype=np.int32),
            lengths=np.array([n], dtype=np.int32),
        )

    out_p, out_s, out_sc, found = _branch_and_bound_k_best(
        w_3d, g_3d, active_mask, r_idx, k_eff, self.max_backtracks
    )
    if found == 0:
        return Arborescences.empty()

    scores = (
        np.where(out_sc > _NEG_INF * 0.1, -out_sc, np.inf).astype(np.float64)
        if self.mode == "min"
        else out_sc.astype(np.float64, copy=False)
    )
    lengths = np.full(found, n, dtype=np.int32)
    return Arborescences(
        scores=scores,
        roots=np.full(found, r_idx, dtype=np.int32),
        parents=np.ascontiguousarray(out_p.reshape(-1), dtype=np.int32),
        slots=np.ascontiguousarray(out_s.reshape(-1), dtype=np.int32),
        offsets=Arborescences.lengths_to_offsets(lengths),
        lengths=lengths,
    )

solve

solve(
    weights: ndarray,
    *,
    root: int | None = None,
    edge_groups: ndarray | None = None,
    required_nodes: ndarray | None = None,
) -> Arborescence

Solve for the single optimal Arborescence.

Source code in src/seroverse/core/graph/arborescence.py
def solve(
    self,
    weights: np.ndarray,
    *,
    root: int | None = None,
    edge_groups: np.ndarray | None = None,
    required_nodes: np.ndarray | None = None,
) -> Arborescence:
    r"""Solve for the single optimal [`Arborescence`][seroverse.core.graph.arborescence.Arborescence]."""
    r_idx = self.root if root is None else int(root)
    batch = self.solve_k_best(weights, root=r_idx, k=1, edge_groups=edge_groups, required_nodes=required_nodes)
    if len(batch) > 0:
        return batch[0]
    n = np.asarray(weights).shape[0]
    return Arborescence(
        parents=np.full(n, -1, dtype=np.int32),
        score=float("inf") if self.mode == "min" else float(_NEG_INF),
        slots=np.full(n, -1, dtype=np.int32),
        root=r_idx,
    )

Comments