Skip to content

serogen.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 (serogen.core.collections)

collections

Protocol contracts and Numba kernels for Structure-of-Arrays (SoA) scalar views and batched containers.

This module defines formal protocols (ScalarView, BatchedContainer, and RaggedArrayContainer) and shared offset/ragged-slicing mechanics that govern high-performance, vectorized containers across SeroGen.

Classes:

Name Description
ScalarView

Formal structural contract for immutable scalar views into a BatchedContainer.

BatchedContainer

A formal contract for Structure-of-Arrays (SoA) and batched containers.

RaggedArrayContainer

A formal contract for ragged Structure-of-Arrays (SoA) containers.

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

Bases: Protocol

Formal structural contract for immutable scalar views into a BatchedContainer.

All scalar views must be @dataclass(slots=True, frozen=True) records representing a single element extracted from a Structure-of-Arrays (SoA) batch container.

Source code in src/serogen/core/collections.py
class ScalarView(Protocol):
    r"""Formal structural contract for immutable scalar views into a [`BatchedContainer`][serogen.core.collections.BatchedContainer].

    All scalar views must be `@dataclass(slots=True, frozen=True)` records representing a
    single element extracted from a Structure-of-Arrays (SoA) batch container.
    """

    __dataclass_fields__: ClassVar[dict[str, Field[Any]]]

BatchedContainer

Bases: Protocol

A formal contract for Structure-of-Arrays (SoA) and batched containers.

This protocol ensures that all high-performance vectorized collections provide a standard interface for instantiation, iteration, concatenation, and indexing without relying on slow dynamic mixins or runtime type introspection.

Class Type Parameters:

Name Bound or Constraints Description Default
T ScalarView

The ScalarView dataclass returned when accessing a single index.

required
S BatchedContainer[Any, Any]

The batched container type returned when indexing with slices or array masks.

required

Methods:

Name Description
empty

Create an empty, 0-length collection with correctly typed arrays.

concat

Concatenate multiple collections into a single, larger collection.

Source code in src/serogen/core/collections.py
class BatchedContainer[T: ScalarView, S: BatchedContainer[Any, Any]](Protocol):
    r"""A formal contract for Structure-of-Arrays (SoA) and batched containers.

    This protocol ensures that all high-performance vectorized collections provide a standard
    interface for instantiation, iteration, concatenation, and indexing without relying on slow
    dynamic mixins or runtime type introspection.

    Type Parameters:
        T: The [`ScalarView`][serogen.core.collections.ScalarView] dataclass returned when accessing a single index.
        S: The batched container type returned when indexing with slices or array masks.
    """

    def __len__(self) -> int:
        r"""Return the number of records in the batch.

        Returns:
            int: The total count of items in the container.
        """
        ...

    @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 records by index, slice, or boolean/integer array mask.

        Args:
            item (int | slice | npt.NDArray | list): An integer index, slice, or mask/indices array.

        Returns:
            T | S: A single scalar record (`T`) if an integer index is provided, or a new
                batched collection (`S`) if a slice or mask is provided.

        Raises:
            IndexError: If an integer index is out of bounds.
        """
        ...

    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[i]

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

    @classmethod
    def empty(cls) -> Self:
        r"""Create an empty, 0-length collection with correctly typed arrays.

        Returns:
            S: An empty instance of the container.
        """
        ...

    @classmethod
    def concat(cls, batches: Iterable[Self]) -> Self:
        r"""Concatenate multiple collections into a single, larger collection.

        Args:
            batches (Iterable[S]): An iterable of collections of the same type.

        Returns:
            S: A new, combined collection.

        Raises:
            ValueError: If the input iterable is empty or contains incompatible batches.
        """
        ...

empty classmethod

empty() -> Self

Create an empty, 0-length collection with correctly typed arrays.

Returns:

Name Type Description
S Self

An empty instance of the container.

Source code in src/serogen/core/collections.py
@classmethod
def empty(cls) -> Self:
    r"""Create an empty, 0-length collection with correctly typed arrays.

    Returns:
        S: An empty instance of the container.
    """
    ...

concat classmethod

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

Concatenate multiple collections into a single, larger collection.

Parameters:

Name Type Description Default
batches Iterable[S]

An iterable of collections of the same type.

required

Returns:

Name Type Description
S Self

A new, combined collection.

Raises:

Type Description
ValueError

If the input iterable is empty or contains incompatible batches.

Source code in src/serogen/core/collections.py
@classmethod
def concat(cls, batches: Iterable[Self]) -> Self:
    r"""Concatenate multiple collections into a single, larger collection.

    Args:
        batches (Iterable[S]): An iterable of collections of the same type.

    Returns:
        S: A new, combined collection.

    Raises:
        ValueError: If the input iterable is empty or contains incompatible batches.
    """
    ...

RaggedArrayContainer

Bases: BatchedContainer[T, S], Protocol

A formal contract for ragged Structure-of-Arrays (SoA) containers.

Ragged containers store variable-length data (such as sequences, cluster members, or CIGAR operations) in a flat, contiguous memory layout, managing element boundaries using offsets and lengths arrays.

Methods:

Name Description
lengths_to_offsets

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

Attributes:

Name Type Description
offsets ndarray[tuple[int], dtype[int32]]

Starting indices into the flat data array for each record.

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

Lengths of each record.

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

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

Source code in src/serogen/core/collections.py
class RaggedArrayContainer[T: ScalarView, S: BatchedContainer[Any, Any]](BatchedContainer[T, S], Protocol):
    r"""A formal contract for ragged Structure-of-Arrays (SoA) containers.

    Ragged containers store variable-length data (such as sequences, cluster members, or CIGAR
    operations) in a flat, contiguous memory layout, managing element boundaries using
    `offsets` and `lengths` arrays.
    """

    @property
    def offsets(self) -> np.ndarray[tuple[int], np.dtype[np.int32]]:
        r"""Starting indices into the flat data array for each record."""
        ...

    @property
    def lengths(self) -> np.ndarray[tuple[int], np.dtype[np.int32]]:
        r"""Lengths of each record."""
        ...

    @property
    def indptr(self) -> np.ndarray[tuple[int], np.dtype[np.int32]]:
        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: np.ndarray[tuple[int], np.dtype[np.int32]],
    ) -> np.ndarray[tuple[int], np.dtype[np.int32]]:
        r"""Compute 0-based starting offsets from a 1D `lengths` array."""
        return lengths_to_offsets(lengths)

    def _slice_ragged(
        self,
        item: slice | np.ndarray[Any, Any] | list[Any],
        *arrays: np.ndarray[tuple[int], Any],
    ) -> tuple[
        np.ndarray[tuple[int], np.dtype[np.int32]],
        np.ndarray[tuple[int], np.dtype[np.int32]],
        np.ndarray[tuple[int], np.dtype[np.int32]],
        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

offsets property

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

Starting indices into the flat data array for each record.

lengths property

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

Lengths of each record.

indptr property

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

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

lengths_to_offsets staticmethod

lengths_to_offsets(lengths: ndarray[tuple[int], dtype[int32]]) -> ndarray[tuple[int], dtype[int32]]

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

Source code in src/serogen/core/collections.py
@staticmethod
def lengths_to_offsets(
    lengths: np.ndarray[tuple[int], np.dtype[np.int32]],
) -> np.ndarray[tuple[int], np.dtype[np.int32]]:
    r"""Compute 0-based starting offsets from a 1D `lengths` array."""
    return lengths_to_offsets(lengths)

lengths_to_offsets

lengths_to_offsets(lengths: ndarray[tuple[int], dtype[int32]]) -> ndarray[tuple[int], dtype[int32]]

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

Parameters:

Name Type Description Default
lengths ndarray[tuple[int], dtype[int32]]

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

required

Returns:

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

np.ndarray[tuple[int], np.dtype[np.int32]]: 1D int32 array of starting offsets.

Source code in src/serogen/core/collections.py
def lengths_to_offsets(
    lengths: np.ndarray[tuple[int], np.dtype[np.int32]],
) -> np.ndarray[tuple[int], np.dtype[np.int32]]:
    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:
        np.ndarray[tuple[int], np.dtype[np.int32]]: 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) -> ndarray[tuple[int], dtype[int32]]

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:

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

np.ndarray[tuple[int], np.dtype[np.int32]]: Resolved 0-based int32 indices.

Source code in src/serogen/core/collections.py
def normalize_indices(
    item: slice | np.ndarray[Any, Any] | list[Any],
    n: int,
) -> np.ndarray[tuple[int], np.dtype[np.int32]]:
    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:
        np.ndarray[tuple[int], np.dtype[np.int32]]: 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 (serogen.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/serogen/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 strings, bytes, or integers to a valid [`Strand`][serogen.core.interval.Strand] instance.

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

        Returns:
            Strand: Corresponding [`Strand`][serogen.core.interval.Strand] enum member.
        """
        if isinstance(value, bytes):
            value = value.decode("ascii")
        if isinstance(value, str):
            if value in ("+", "1", "+1"):
                return Strand.FORWARD
            elif value in ("-", "-1"):
                return Strand.REVERSE
        return Strand.UNSTRANDED

    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

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/serogen/core/interval.py
@dataclass(frozen=True, slots=True)
class Interval:
    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`][serogen.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 ndarray[tuple[int], dtype[int32]]

1D array of 0-based start coordinates.

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

1D array of 0-based end coordinates.

strands ndarray[tuple[int], dtype[int8]]

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

Methods:

Name Description
empty

Create an empty Intervals object with zero-length arrays.

concat

Concatenate multiple Intervals collections into a single batch.

Source code in src/serogen/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 (np.ndarray[tuple[int], np.dtype[np.int32]]): 1D array of 0-based start coordinates.
        ends (np.ndarray[tuple[int], np.dtype[np.int32]]): 1D array of 0-based end coordinates.
        strands (np.ndarray[tuple[int], np.dtype[np.int8]]): 1D array of strand values (+1, -1, 0).
    """

    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]]

    @classmethod
    def empty(cls) -> Self:
        r"""Create an empty [`Intervals`][serogen.core.interval.Intervals] object with zero-length arrays.

        Returns:
            Intervals: Empty [`Intervals`][serogen.core.interval.Intervals] collection.
        """
        return cls(
            np.empty(0, dtype=np.int32),
            np.empty(0, dtype=np.int32),
            np.empty(0, dtype=np.int8),
        )

    def __len__(self) -> int:
        r"""Return the number of intervals in the batch.

        Returns:
            int: Total interval count.
        """
        return len(self.starts)

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

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

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

    def __getitem__(
        self, item: int | np.integer[Any] | slice | np.ndarray[tuple[int], Any] | list[Any]
    ) -> Interval | Intervals:
        r"""Access intervals by integer index, slice, or boolean/integer NumPy array.

        Args:
            item (int | slice | np.ndarray | list): Integer index, slice, or NumPy mask array.

        Returns:
            Interval | Intervals: A single [`Interval`][serogen.core.interval.Interval] or a
                sliced [`Intervals`][serogen.core.interval.Intervals] collection.

        Raises:
            IndexError: If integer index is out of bounds.
        """
        if isinstance(item, (int, np.integer)):
            item_idx = int(item)
            if item_idx < 0:
                item_idx += len(self)
            if item_idx < 0 or item_idx >= len(self):
                raise IndexError("Batch index out of range")
            return Interval(self.starts[item_idx], self.ends[item_idx], self.strands[item_idx])

        return Intervals(
            self.starts[item],
            self.ends[item],
            self.strands[item],
        )

    @classmethod
    def concat(cls, batches: Iterable[Self]) -> Self:
        r"""Concatenate multiple [`Intervals`][serogen.core.interval.Intervals] collections into a single batch.

        Args:
            batches (Iterable[Intervals]): Iterable of [`Intervals`][serogen.core.interval.Intervals] objects to merge.

        Returns:
            Intervals: Concatenated [`Intervals`][serogen.core.interval.Intervals] collection.

        Raises:
            ValueError: If `batches` is empty.
        """
        batches_list = list(batches)
        if not batches_list:
            raise ValueError("Cannot concatenate empty list of batches")
        return cls(
            np.concatenate([b.starts for b in batches_list]),
            np.concatenate([b.ends for b in batches_list]),
            np.concatenate([b.strands for b in batches_list]),
        )

    @property
    def lengths(self) -> np.ndarray[tuple[int], np.dtype[np.int32]]:
        r"""Compute the lengths of all intervals in base pairs (`ends - starts`).

        Returns:
            np.ndarray[tuple[int], np.dtype[np.int32]]: 1D array of interval lengths.
        """
        return self.ends - self.starts

lengths property

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

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

Returns:

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

np.ndarray[tuple[int], np.dtype[np.int32]]: 1D array of interval lengths.

empty classmethod

empty() -> Self

Create an empty Intervals object with zero-length arrays.

Returns:

Name Type Description
Intervals Self

Empty Intervals collection.

Source code in src/serogen/core/interval.py
@classmethod
def empty(cls) -> Self:
    r"""Create an empty [`Intervals`][serogen.core.interval.Intervals] object with zero-length arrays.

    Returns:
        Intervals: Empty [`Intervals`][serogen.core.interval.Intervals] collection.
    """
    return cls(
        np.empty(0, dtype=np.int32),
        np.empty(0, dtype=np.int32),
        np.empty(0, dtype=np.int8),
    )

concat classmethod

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

Concatenate multiple Intervals collections into a single batch.

Parameters:

Name Type Description Default
batches Iterable[Intervals]

Iterable of Intervals objects to merge.

required

Returns:

Name Type Description
Intervals Self

Concatenated Intervals collection.

Raises:

Type Description
ValueError

If batches is empty.

Source code in src/serogen/core/interval.py
@classmethod
def concat(cls, batches: Iterable[Self]) -> Self:
    r"""Concatenate multiple [`Intervals`][serogen.core.interval.Intervals] collections into a single batch.

    Args:
        batches (Iterable[Intervals]): Iterable of [`Intervals`][serogen.core.interval.Intervals] objects to merge.

    Returns:
        Intervals: Concatenated [`Intervals`][serogen.core.interval.Intervals] collection.

    Raises:
        ValueError: If `batches` is empty.
    """
    batches_list = list(batches)
    if not batches_list:
        raise ValueError("Cannot concatenate empty list of batches")
    return cls(
        np.concatenate([b.starts for b in batches_list]),
        np.concatenate([b.ends for b in batches_list]),
        np.concatenate([b.strands for b in batches_list]),
    )

Genome Assemblies & FASTA I/O (serogen.core.genome, serogen.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/serogen/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`][serogen.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`][serogen.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`][serogen.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 | str = 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 | str): 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`][serogen.core.genome.GenomeAssembly] instance.
        """
        seqs = FastaReader.from_bytes(data, truncate_id=truncate_id, uppercase=uppercase).load()
        id_bytes = id_.encode("utf-8") if isinstance(id_, str) else bytes(id_)
        return cls(seqs.ids, seqs.seqs, seqs.offsets, seqs.lengths, id=id_bytes)

    @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`][serogen.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 | str | None = None) -> Self:
        r"""Load a genome assembly from an open binary stream.

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

        Returns:
            GenomeAssembly: Loaded [`GenomeAssembly`][serogen.core.genome.GenomeAssembly] instance.
        """
        if id_ is None:
            raw_name = getattr(handle, "name", "unknown")
            id_bytes = raw_name.encode("utf-8") if isinstance(raw_name, str) else bytes(raw_name)
        elif isinstance(id_, str):
            id_bytes = id_.encode("utf-8")
        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 | str = b"unknown",
    ) -> Self:
        r"""Construct a [`GenomeAssembly`][serogen.core.genome.GenomeAssembly] from sequence records.

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

        Returns:
            GenomeAssembly: Constructed [`GenomeAssembly`][serogen.core.genome.GenomeAssembly] instance.
        """
        seqs = Sequences.from_records(records)
        id_bytes = id_.encode("utf-8") if isinstance(id_, str) else bytes(id_)
        return cls(seqs.ids, seqs.seqs, seqs.offsets, seqs.lengths, id=id_bytes)

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/serogen/core/genome.py
@classmethod
def ensure(cls, genome: Self | str | Path | IO[bytes]) -> Self:
    r"""Ensure the input object is coerced into a [`GenomeAssembly`][serogen.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`][serogen.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 | str = 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 | str

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/serogen/core/genome.py
@classmethod
def from_fasta(
    cls,
    data: bytes | bytearray | memoryview,
    id_: bytes | str = 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 | str): 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`][serogen.core.genome.GenomeAssembly] instance.
    """
    seqs = FastaReader.from_bytes(data, truncate_id=truncate_id, uppercase=uppercase).load()
    id_bytes = id_.encode("utf-8") if isinstance(id_, str) else bytes(id_)
    return cls(seqs.ids, seqs.seqs, seqs.offsets, seqs.lengths, id=id_bytes)

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/serogen/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`][serogen.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 | str | 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 | str | 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/serogen/core/genome.py
@classmethod
def from_stream(cls, handle: IO[bytes], id_: bytes | str | None = None) -> Self:
    r"""Load a genome assembly from an open binary stream.

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

    Returns:
        GenomeAssembly: Loaded [`GenomeAssembly`][serogen.core.genome.GenomeAssembly] instance.
    """
    if id_ is None:
        raw_name = getattr(handle, "name", "unknown")
        id_bytes = raw_name.encode("utf-8") if isinstance(raw_name, str) else bytes(raw_name)
    elif isinstance(id_, str):
        id_bytes = id_.encode("utf-8")
    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 | str = b'unknown') -> Self

Construct a GenomeAssembly from sequence records.

Parameters:

Name Type Description Default
records Iterable[Sequence]

Iterable of Sequence objects.

required
id_ bytes | str

Genome assembly identifier.

b'unknown'

Returns:

Name Type Description
GenomeAssembly Self

Constructed GenomeAssembly instance.

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

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

    Returns:
        GenomeAssembly: Constructed [`GenomeAssembly`][serogen.core.genome.GenomeAssembly] instance.
    """
    seqs = Sequences.from_records(records)
    id_bytes = id_.encode("utf-8") if isinstance(id_, str) else bytes(id_)
    return cls(seqs.ids, seqs.seqs, seqs.offsets, seqs.lengths, id=id_bytes)

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/serogen/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`][serogen.core.seq.seq.Sequence] instances
    with $O(1)$ memory overhead, or loads an entire stream at once into a contiguous
    [`Sequences`][serogen.core.seq.seq.Sequences] Structure-of-Arrays container via [`load`][serogen.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`][serogen.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`][serogen.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 += 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`][serogen.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/serogen/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`][serogen.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/serogen/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/serogen/core/io/fasta.py
def load(self) -> Sequences:
    r"""Load the FASTA stream into memory as a contiguous [`Sequences`][serogen.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 (serogen.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`][serogen.core.seq.alphabet.Alphabet]

ASCII biological alphabet representation with dual LUTs.

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

Translation table and genetic code representation.

[`AlphabetError`][serogen.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/serogen/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/serogen/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: str | 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 | str,
        complement: bytes | str | None = None,
        aliases: Mapping[bytes | str, bytes | str] | None = None,
        groups: Mapping[bytes | str, Iterable[bytes | str]] | None = None,
        default_k: int | None = None,
        supports_canonical: bool | None = None,
        name: str | None = None,
    ) -> Self:
        r"""Create or return a cached singleton instance of an Alphabet."""
        sym_bytes = (symbols.encode("ascii") if isinstance(symbols, str) else symbols).upper()
        comp_bytes = (
            (complement.encode("ascii") if isinstance(complement, str) else complement).upper()
            if complement is not None
            else None
        )

        norm_aliases: dict[bytes, bytes] | None = None
        if aliases:
            norm_aliases = {
                (k.encode("ascii") if isinstance(k, str) else k): (v.encode("ascii") if isinstance(v, str) else v)
                for k, v in aliases.items()
            }

        norm_groups: dict[bytes, tuple[bytes, ...]] | None = None
        if groups:
            norm_groups = {
                (k.encode("ascii") if isinstance(k, str) else k): tuple(
                    x.encode("ascii") if isinstance(x, str) else x for x in v
                )
                for k, v in groups.items()
            }

        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: str | 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]
                    kmer_lut[m_byte] = idx
                    kmer_lut[ord(chr(m_byte).lower())] = idx
                    ascii_lut[m_byte] = rep_byte
                    ascii_lut[ord(chr(m_byte).lower())] = rep_byte
        else:
            # Standard alphabet mapping
            for idx, b in enumerate(symbols):
                kmer_lut[b] = idx
                kmer_lut[ord(chr(b).lower())] = idx
                ascii_lut[b] = b
                ascii_lut[ord(chr(b).lower())] = b

        # Apply Aliases
        if aliases:
            for src, dst in aliases.items():
                src_b = src[0]
                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[ord(chr(src_b).lower())] = target_kmer_idx
                ascii_lut[src_b] = target_ascii
                ascii_lut[ord(chr(src_b).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[ord(chr(s).lower())] = ord(chr(c).lower())
            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[ord(chr(src_b).lower())] = comp_lut[ord(chr(dst_b).lower())]
        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 | str | int) -> bool:
        r"""Check if a character or byte value is a valid member of this alphabet."""
        if isinstance(item, int):
            return 0 <= item < 256 and self.kmer_lut[item] < len(self.symbols)
        if isinstance(item, str):
            if len(item) != 1:
                return False
            item_val = ord(item)
            return item_val < 256 and self.kmer_lut[item_val] < len(self.symbols)
        if isinstance(item, bytes):
            if len(item) != 1:
                return False
            return self.kmer_lut[item[0]] < 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}"
        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/serogen/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/serogen/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/serogen/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 str

Descriptive 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, 4, 11, 15, 25) or 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/serogen/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 (str): Descriptive 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: str
    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]
    viral: ClassVar[GeneticCode]
    viral_amber_q: ClassVar[GeneticCode]
    viral_opal_w: ClassVar[GeneticCode]
    viral_opal_g: ClassVar[GeneticCode]

    def __new__(
        cls,
        table_id: int,
        name: str,
        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: str,
        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 | str | GeneticCode) -> GeneticCode:
        r"""Resolve a `GeneticCode` instance by NCBI translation table ID (`1, 4, 11, 15, 25`) or name."""
        if isinstance(table, GeneticCode):
            return table
        if isinstance(table, int):
            by_id = {
                1: cls.standard,
                4: cls.viral_opal_w,
                11: cls.bacterial,
                15: cls.viral_amber_q,
                25: cls.viral_opal_g,
            }
            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("-", "_").replace(" ", "_")
        by_name = {
            "standard": cls.standard,
            "1": cls.standard,
            "bacterial": cls.bacterial,
            "11": cls.bacterial,
            "viral": cls.viral,
            "phage": cls.viral,
            "viral_amber_q": cls.viral_amber_q,
            "amber_q": cls.viral_amber_q,
            "15": cls.viral_amber_q,
            "viral_opal_w": cls.viral_opal_w,
            "opal_w": cls.viral_opal_w,
            "4": cls.viral_opal_w,
            "viral_opal_g": cls.viral_opal_g,
            "opal_g": cls.viral_opal_g,
            "25": cls.viral_opal_g,
        }
        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 | str | GeneticCode) -> GeneticCode

Resolve a GeneticCode instance by NCBI translation table ID (1, 4, 11, 15, 25) or name.

Source code in src/serogen/core/seq/alphabet.py
@classmethod
def from_table(cls, table: int | str | GeneticCode) -> GeneticCode:
    r"""Resolve a `GeneticCode` instance by NCBI translation table ID (`1, 4, 11, 15, 25`) or name."""
    if isinstance(table, GeneticCode):
        return table
    if isinstance(table, int):
        by_id = {
            1: cls.standard,
            4: cls.viral_opal_w,
            11: cls.bacterial,
            15: cls.viral_amber_q,
            25: cls.viral_opal_g,
        }
        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("-", "_").replace(" ", "_")
    by_name = {
        "standard": cls.standard,
        "1": cls.standard,
        "bacterial": cls.bacterial,
        "11": cls.bacterial,
        "viral": cls.viral,
        "phage": cls.viral,
        "viral_amber_q": cls.viral_amber_q,
        "amber_q": cls.viral_amber_q,
        "15": cls.viral_amber_q,
        "viral_opal_w": cls.viral_opal_w,
        "opal_w": cls.viral_opal_w,
        "4": cls.viral_opal_w,
        "viral_opal_g": cls.viral_opal_g,
        "opal_g": cls.viral_opal_g,
        "25": cls.viral_opal_g,
    }
    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/serogen/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/serogen/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/serogen/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 (serogen.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.

BacterialTranslationTable

NCBI Translation Table 11 utilities for bacterial codon translation and complementation.

Sequence dataclass

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/serogen/core/seq/seq.py
@dataclass(slots=True, frozen=True)
class Sequence:
    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">%b\n%b\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/serogen/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">%b\n%b\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/serogen/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 ndarray

1D byte string array of identifiers for each sequence.

seqs ndarray

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

offsets ndarray

1D array of start indices in seqs.

lengths ndarray

1D array of sequence lengths.

Methods:

Name Description
to_fasta

Format the entire batch as a single FASTA byte string.

empty

Create an empty Sequences object with zero-length arrays.

concat

Concatenate multiple Sequences containers into a single larger collection.

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.

Source code in src/serogen/core/seq/seq.py
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
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
@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 (np.ndarray): 1D byte string array of identifiers for each sequence.
        seqs (np.ndarray): Single flat 1D array containing all sequence byte data concatenated.
        offsets (np.ndarray): 1D array of start indices in `seqs`.
        lengths (np.ndarray): 1D array of sequence lengths.
    """

    ids: np.ndarray[tuple[int], np.dtype[np.bytes_]]
    seqs: np.ndarray[tuple[int], np.dtype[np.uint8]]  # concatenated sequences
    offsets: np.ndarray[tuple[int], np.dtype[np.int32]]  # start index per sequence
    lengths: np.ndarray[tuple[int], np.dtype[np.int32]]  # length per sequence

    def __len__(self) -> int:
        r"""Return the total number of sequences in the batch.

        Returns:
            int: Number of sequence records in the container.
        """
        return len(self.ids)

    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%b\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">%b\n%b\n" % (name, bytes(mv[o : o + length_val]))
            for name, o, length_val in zip(self.ids, self.offsets, self.lengths, strict=False)
        )

    @classmethod
    def empty(cls) -> Self:
        r"""Create an empty Sequences object with zero-length arrays.

        Returns:
            Sequences: An empty sequences container.
        """
        return cls(
            np.empty(0, dtype="S"),
            np.empty(0, dtype=np.uint8),
            np.empty(0, dtype=np.int32),
            np.empty(0, dtype=np.int32),
        )

    @classmethod
    def concat(cls, batches: Iterable[Self]) -> Self:
        r"""Concatenate multiple Sequences containers into a single larger collection.

        Args:
            batches (Iterable[Sequences]): An iterable of sequence batches to combine.

        Returns:
            Sequences: A combined sequences container.
        """
        batches_list = list(batches)
        if not batches_list:
            return cls.empty()

        all_ids = (
            np.concatenate([b.ids for b in batches_list])
            if any(len(b.ids) > 0 for b in batches_list)
            else np.empty(0, dtype="S")
        )
        all_seqs = np.concatenate([b.seqs for b in batches_list])
        all_lengths = np.concatenate([b.lengths for b in batches_list])
        offsets = cls.lengths_to_offsets(all_lengths)

        return cls(all_ids, all_seqs, offsets, all_lengths)

    @property
    def internal_stops(self) -> np.ndarray:
        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:
            np.ndarray: 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`][serogen.core.seq.seq.Sequence] if a single byte identifier
                is passed, or a sub-[`Sequences`][serogen.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).

        Args:
            item: An integer index, slice, array mask, list of indices, or byte identifier(s).

        Returns:
            Sequence | Sequences: A single [`Sequence`][serogen.core.seq.seq.Sequence] if integer index
                or single byte identifier is passed, or a sliced [`Sequences`][serogen.core.seq.seq.Sequences] collection.

        Raises:
            IndexError: If an integer index is out of bounds.
            KeyError: If a byte identifier is not found.
        """
        if isinstance(item, (int, np.integer)):
            item_idx = int(item)
            if item_idx < 0:
                item_idx += len(self)
            if item_idx < 0 or item_idx >= len(self):
                raise IndexError("Sequence index out of range")
            offset_val = self.offsets[item_idx]
            return Sequence(
                self.ids[item_idx],
                bytes(self.seqs.data[offset_val : offset_val + self.lengths[item_idx]]),
            )

        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))

        indices, new_offsets, new_lengths, (new_seqs,) = self._slice_ragged(item, self.seqs)
        if len(indices) == 0:
            return self.empty()
        return Sequences(self.ids[indices], new_seqs, new_offsets, new_lengths)

    def __iter__(self) -> Generator[Sequence]:
        r"""Iterate over the batch, yielding a Sequence for each sequence.

        Yields:
            Sequence: Scalar record for each sequence in the batch.
        """
        mv = self.seqs.data
        for i in range(len(self)):
            offset_val = self.offsets[i]
            yield Sequence(self.ids[i], bytes(mv[offset_val : offset_val + self.lengths[i]]))

    @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"%b_%i_%i_%i" % (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`][serogen.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)

internal_stops property

internal_stops: ndarray

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:

Type Description
ndarray

np.ndarray: 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/serogen/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%b\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">%b\n%b\n" % (name, bytes(mv[o : o + length_val]))
        for name, o, length_val in zip(self.ids, self.offsets, self.lengths, strict=False)
    )

empty classmethod

empty() -> Self

Create an empty Sequences object with zero-length arrays.

Returns:

Name Type Description
Sequences Self

An empty sequences container.

Source code in src/serogen/core/seq/seq.py
@classmethod
def empty(cls) -> Self:
    r"""Create an empty Sequences object with zero-length arrays.

    Returns:
        Sequences: An empty sequences container.
    """
    return cls(
        np.empty(0, dtype="S"),
        np.empty(0, dtype=np.uint8),
        np.empty(0, dtype=np.int32),
        np.empty(0, dtype=np.int32),
    )

concat classmethod

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

Concatenate multiple Sequences containers into a single larger collection.

Parameters:

Name Type Description Default
batches Iterable[Sequences]

An iterable of sequence batches to combine.

required

Returns:

Name Type Description
Sequences Self

A combined sequences container.

Source code in src/serogen/core/seq/seq.py
@classmethod
def concat(cls, batches: Iterable[Self]) -> Self:
    r"""Concatenate multiple Sequences containers into a single larger collection.

    Args:
        batches (Iterable[Sequences]): An iterable of sequence batches to combine.

    Returns:
        Sequences: A combined sequences container.
    """
    batches_list = list(batches)
    if not batches_list:
        return cls.empty()

    all_ids = (
        np.concatenate([b.ids for b in batches_list])
        if any(len(b.ids) > 0 for b in batches_list)
        else np.empty(0, dtype="S")
    )
    all_seqs = np.concatenate([b.seqs for b in batches_list])
    all_lengths = np.concatenate([b.lengths for b in batches_list])
    offsets = cls.lengths_to_offsets(all_lengths)

    return cls(all_ids, all_seqs, offsets, all_lengths)

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/serogen/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/serogen/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/serogen/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`][serogen.core.seq.seq.Sequence] if a single byte identifier
            is passed, or a sub-[`Sequences`][serogen.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/serogen/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/serogen/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/serogen/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"%b_%i_%i_%i" % (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/serogen/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/serogen/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/serogen/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`][serogen.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)

BacterialTranslationTable

NCBI Translation Table 11 utilities for bacterial codon translation and complementation.

Compatibility wrapper delegating to GeneticCode (GeneticCode.bacterial).

Methods:

Name Description
translate

Translate a nucleotide sequence array or byte string to amino acid byte values.

is_coding

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

Source code in src/serogen/core/seq/seq.py
class BacterialTranslationTable:
    r"""NCBI Translation Table 11 utilities for bacterial codon translation and complementation.

    Compatibility wrapper delegating to [`GeneticCode`][serogen.core.seq.alphabet.GeneticCode] (`GeneticCode.bacterial`).
    """

    _MAPPING = _BACTERIAL_MAPPING = {
        b"TTT": b"F",
        b"TTC": b"F",
        b"TTA": b"L",
        b"TTG": b"L",
        b"TCT": b"S",
        b"TCC": b"S",
        b"TCA": b"S",
        b"TCG": b"S",
        b"TAT": b"Y",
        b"TAC": b"Y",
        b"TAA": b"*",
        b"TAG": b"*",
        b"TGT": b"C",
        b"TGC": b"C",
        b"TGA": b"*",
        b"TGG": b"W",
        b"CTT": b"L",
        b"CTC": b"L",
        b"CTA": b"L",
        b"CTG": b"L",
        b"CCT": b"P",
        b"CCC": b"P",
        b"CCA": b"P",
        b"CCG": b"P",
        b"CAT": b"H",
        b"CAC": b"H",
        b"CAA": b"Q",
        b"CAG": b"Q",
        b"CGT": b"R",
        b"CGC": b"R",
        b"CGA": b"R",
        b"CGG": b"R",
        b"ATT": b"I",
        b"ATC": b"I",
        b"ATA": b"I",
        b"ATG": b"M",
        b"ACT": b"T",
        b"ACC": b"T",
        b"ACA": b"T",
        b"ACG": b"T",
        b"AAT": b"N",
        b"AAC": b"N",
        b"AAA": b"K",
        b"AAG": b"K",
        b"AGT": b"S",
        b"AGC": b"S",
        b"AGA": b"R",
        b"AGG": b"R",
        b"GTT": b"V",
        b"GTC": b"V",
        b"GTA": b"V",
        b"GTG": b"V",
        b"GCT": b"A",
        b"GCC": b"A",
        b"GCA": b"A",
        b"GCG": b"A",
        b"GAT": b"D",
        b"GAC": b"D",
        b"GAA": b"E",
        b"GAG": b"E",
        b"GGT": b"G",
        b"GGC": b"G",
        b"GGA": b"G",
        b"GGG": b"G",
    }
    _START_CODONS = GeneticCode.bacterial.start_codons
    _STOP_CODONS = GeneticCode.bacterial.stop_codons
    _CHAR_MAP = GeneticCode.bacterial._char_map
    _CODON_MAP = GeneticCode.bacterial._codon_map
    _COMP_MAP = (
        Alphabet.DNA.complement_lut if Alphabet.DNA.complement_lut is not None else np.arange(256, dtype=np.uint8)
    )

    @classmethod
    def translate(
        cls, seq: bytes | bytearray | memoryview | np.ndarray[tuple[int], np.dtype[np.uint8]], to_stop: bool = False
    ) -> np.ndarray[tuple[int], np.dtype[np.uint8]]:
        r"""Translate a nucleotide sequence array or byte string to amino acid byte values."""
        return GeneticCode.bacterial.translate(seq, to_stop=to_stop)

    @classmethod
    def is_coding(cls, seq: bytes) -> bool:
        r"""Check if a byte sequence starts with a valid bacterial start codon and ends with a stop codon."""
        return GeneticCode.bacterial.is_coding(seq)

translate classmethod

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

Translate a nucleotide sequence array or byte string to amino acid byte values.

Source code in src/serogen/core/seq/seq.py
@classmethod
def translate(
    cls, seq: bytes | bytearray | memoryview | np.ndarray[tuple[int], np.dtype[np.uint8]], to_stop: bool = False
) -> np.ndarray[tuple[int], np.dtype[np.uint8]]:
    r"""Translate a nucleotide sequence array or byte string to amino acid byte values."""
    return GeneticCode.bacterial.translate(seq, to_stop=to_stop)

is_coding classmethod

is_coding(seq: bytes) -> bool

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

Source code in src/serogen/core/seq/seq.py
@classmethod
def is_coding(cls, seq: bytes) -> bool:
    r"""Check if a byte sequence starts with a valid bacterial start codon and ends with a stop codon."""
    return GeneticCode.bacterial.is_coding(seq)

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

kmers

K-mer indexing, FracMinHash, Randstrobe sketch construction, and alignment seed management.

This module provides sequence sketch indices (FracMinHashIndex, RandstrobeIndex), alignment seed batch containers (Seeds), and Numba JIT parallel search kernels.

Classes:

Name Description
Seed

Alignment seed representing a potential matching region between query and target.

Seeds

Structure-of-Arrays (SoA) batch container for alignment seeds.

KmerRecords

Single-sequence sketch records slice holding parallel 1D arrays.

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.

Seed dataclass

Alignment seed representing a potential 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 (number of shared k-mer hashes or randstrobes).

offset int

Diagonal offset calculated as query_pos - target_pos.

Source code in src/serogen/core/seq/kmers.py
@dataclass(frozen=True, slots=True)
class Seed:
    r"""Alignment seed representing a potential 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 (number of shared k-mer hashes or randstrobes).
        offset (int): Diagonal offset calculated as `query_pos - target_pos`.
    """

    query_index: int
    target_index: int
    score: int
    offset: int

Seeds dataclass

Bases: BatchedContainer[Seed, 'Seeds']

Structure-of-Arrays (SoA) batch container for alignment seeds.

Stores query indices, target indices, scores, and diagonal offsets as 1D NumPy arrays.

Attributes:

Name Type Description
query_indices ndarray[tuple[int], dtype[uint32]]

1D array of query sequence indices.

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

1D array of target sequence indices.

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

1D array of alignment match scores.

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

1D array of diagonal offset values.

Methods:

Name Description
concat

Concatenate multiple Seeds collections into a single batch.

empty

Create an empty Seeds collection.

filter

Return a new Seeds collection containing only records where the mask is True.

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.

Source code in src/serogen/core/seq/kmers.py
@dataclass(frozen=True, slots=True)
class Seeds(BatchedContainer[Seed, "Seeds"]):
    r"""Structure-of-Arrays (SoA) batch container for alignment seeds.

    Stores query indices, target indices, scores, and diagonal offsets as 1D NumPy arrays.

    Attributes:
        query_indices (np.ndarray[tuple[int], np.dtype[np.uint32]]): 1D array of query sequence indices.
        target_indices (np.ndarray[tuple[int], np.dtype[np.uint32]]): 1D array of target sequence indices.
        scores (np.ndarray[tuple[int], np.dtype[np.uint32]]): 1D array of alignment match scores.
        offsets (np.ndarray[tuple[int], np.dtype[np.int32]]): 1D array of diagonal offset values.
    """

    query_indices: np.ndarray[tuple[int], np.dtype[np.uint32]]
    target_indices: np.ndarray[tuple[int], np.dtype[np.uint32]]
    scores: np.ndarray[tuple[int], np.dtype[np.uint32]]
    offsets: np.ndarray[tuple[int], np.dtype[np.int32]]

    def __len__(self) -> int:
        r"""Return the number of seeds in the batch.

        Returns:
            int: Total seed count.
        """
        return len(self.query_indices)

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

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

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

    def __getitem__(
        self, item: int | np.integer[Any] | slice | np.ndarray[tuple[int], Any] | list[Any]
    ) -> Seed | Seeds:
        r"""Access seeds by integer index, slice, or boolean/integer NumPy array.

        Args:
            item (Any): Integer index, slice, or mask array.

        Returns:
            Seed | Seeds: A single [`Seed`][serogen.core.seq.kmers.Seed] or a
                sliced [`Seeds`][serogen.core.seq.kmers.Seeds] collection.

        Raises:
            IndexError: If integer index is out of bounds.
        """
        if isinstance(item, (int, np.integer)):
            idx_val = int(item)
            if idx_val < 0:
                idx_val += len(self)
            if idx_val < 0 or idx_val >= len(self):
                raise IndexError("Batch index out of range")
            return Seed(
                int(self.query_indices[idx_val]),
                int(self.target_indices[idx_val]),
                int(self.scores[idx_val]),
                int(self.offsets[idx_val]),
            )

        if isinstance(item, slice):
            indices = np.arange(len(self))[item]
        else:
            item_arr = np.asarray(item)
            indices = np.nonzero(item_arr)[0] if item_arr.dtype.kind == "b" else item_arr

        return Seeds(
            self.query_indices[indices],
            self.target_indices[indices],
            self.scores[indices],
            self.offsets[indices],
        )

    @classmethod
    def concat(cls, batches: Iterable[Self]) -> Self:
        r"""Concatenate multiple [`Seeds`][serogen.core.seq.kmers.Seeds] collections into a single batch.

        Args:
            batches (Iterable[Seeds]): Iterable of [`Seeds`][serogen.core.seq.kmers.Seeds] objects.

        Returns:
            Seeds: Concatenated [`Seeds`][serogen.core.seq.kmers.Seeds] collection.
        """
        batches_list = list(batches)
        if not batches_list:
            return cls.empty()
        return cls(
            np.concatenate([b.query_indices for b in batches_list]),
            np.concatenate([b.target_indices for b in batches_list]),
            np.concatenate([b.scores for b in batches_list]),
            np.concatenate([b.offsets for b in batches_list]),
        )

    @classmethod
    def empty(cls) -> Self:
        r"""Create an empty [`Seeds`][serogen.core.seq.kmers.Seeds] collection.

        Returns:
            Seeds: Empty [`Seeds`][serogen.core.seq.kmers.Seeds] collection.
        """
        return cls(
            np.empty(0, dtype=np.uint32),
            np.empty(0, dtype=np.uint32),
            np.empty(0, dtype=np.uint32),
            np.empty(0, dtype=np.int32),
        )

    def filter(self, mask: np.ndarray[tuple[int], np.dtype[np.bool_]]) -> Seeds:
        r"""Return a new Seeds collection containing only records where the mask is True.

        Args:
            mask (np.ndarray[tuple[int], np.dtype[np.bool_]]): 1D boolean mask array.

        Returns:
            Seeds: Filtered [`Seeds`][serogen.core.seq.kmers.Seeds] collection.
        """
        return Seeds(
            self.query_indices[mask],
            self.target_indices[mask],
            self.scores[mask],
            self.offsets[mask],
        )

    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`][serogen.core.seq.kmers.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 = Seeds(
            self.query_indices[best_idx],
            self.target_indices[best_idx],
            self.scores[best_idx],
            self.offsets[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]

concat classmethod

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

Concatenate multiple Seeds collections into a single batch.

Parameters:

Name Type Description Default
batches Iterable[Seeds]

Iterable of Seeds objects.

required

Returns:

Name Type Description
Seeds Self

Concatenated Seeds collection.

Source code in src/serogen/core/seq/kmers.py
@classmethod
def concat(cls, batches: Iterable[Self]) -> Self:
    r"""Concatenate multiple [`Seeds`][serogen.core.seq.kmers.Seeds] collections into a single batch.

    Args:
        batches (Iterable[Seeds]): Iterable of [`Seeds`][serogen.core.seq.kmers.Seeds] objects.

    Returns:
        Seeds: Concatenated [`Seeds`][serogen.core.seq.kmers.Seeds] collection.
    """
    batches_list = list(batches)
    if not batches_list:
        return cls.empty()
    return cls(
        np.concatenate([b.query_indices for b in batches_list]),
        np.concatenate([b.target_indices for b in batches_list]),
        np.concatenate([b.scores for b in batches_list]),
        np.concatenate([b.offsets for b in batches_list]),
    )

empty classmethod

empty() -> Self

Create an empty Seeds collection.

Returns:

Name Type Description
Seeds Self

Empty Seeds collection.

Source code in src/serogen/core/seq/kmers.py
@classmethod
def empty(cls) -> Self:
    r"""Create an empty [`Seeds`][serogen.core.seq.kmers.Seeds] collection.

    Returns:
        Seeds: Empty [`Seeds`][serogen.core.seq.kmers.Seeds] collection.
    """
    return cls(
        np.empty(0, dtype=np.uint32),
        np.empty(0, dtype=np.uint32),
        np.empty(0, dtype=np.uint32),
        np.empty(0, dtype=np.int32),
    )

filter

filter(mask: ndarray[tuple[int], dtype[bool_]]) -> Seeds

Return a new Seeds collection containing only records where the mask is True.

Parameters:

Name Type Description Default
mask ndarray[tuple[int], dtype[bool_]]

1D boolean mask array.

required

Returns:

Name Type Description
Seeds Seeds

Filtered Seeds collection.

Source code in src/serogen/core/seq/kmers.py
def filter(self, mask: np.ndarray[tuple[int], np.dtype[np.bool_]]) -> Seeds:
    r"""Return a new Seeds collection containing only records where the mask is True.

    Args:
        mask (np.ndarray[tuple[int], np.dtype[np.bool_]]): 1D boolean mask array.

    Returns:
        Seeds: Filtered [`Seeds`][serogen.core.seq.kmers.Seeds] collection.
    """
    return Seeds(
        self.query_indices[mask],
        self.target_indices[mask],
        self.scores[mask],
        self.offsets[mask],
    )

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/serogen/core/seq/kmers.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`][serogen.core.seq.kmers.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 = Seeds(
        self.query_indices[best_idx],
        self.target_indices[best_idx],
        self.scores[best_idx],
        self.offsets[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/serogen/core/seq/kmers.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]

KmerRecords dataclass

Single-sequence sketch records slice holding parallel 1D arrays.

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 sequence indices.

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

1D array of sequence positions.

Source code in src/serogen/core/seq/kmers.py
@dataclass(frozen=True, slots=True, eq=False)
class KmerRecords:
    r"""Single-sequence sketch records slice holding parallel 1D arrays.

    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 sequence indices.
        positions (np.ndarray[tuple[int], np.dtype[np.uint32]]): 1D array of sequence positions.
    """

    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]]

    def __len__(self) -> int:
        r"""Return the number of k-mer sketch records in this view."""
        return len(self.hashes)

    def __eq__(self, other: object) -> bool:
        r"""Compare two KmerRecords views for exact array equality."""
        if not isinstance(other, KmerRecords):
            return NotImplemented
        return bool(
            np.array_equal(self.hashes, other.hashes)
            and np.array_equal(self.seq_indices, other.seq_indices)
            and np.array_equal(self.positions, other.positions)
        )

BaseKmerIndex dataclass

Bases: RaggedArrayContainer[KmerRecords, 'BaseKmerIndex']

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.

Methods:

Name Description
empty

Create an empty index instance.

concat

Concatenate multiple index batches into a single batch.

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 for each query sequence above a minimum score.

Source code in src/serogen/core/seq/kmers.py
@dataclass(frozen=True, slots=True, kw_only=True)
class BaseKmerIndex(RaggedArrayContainer[KmerRecords, "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.
    """

    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

    def __len__(self) -> int:
        r"""Return the number of sequences in the batch container."""
        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)

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

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

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

    def __getitem__(
        self, item: int | np.integer[Any] | slice | np.ndarray[tuple[int], Any] | list[Any]
    ) -> KmerRecords | Self:
        r"""Access records for an individual sequence, or slice/mask a sub-index of sequences."""
        if isinstance(item, (int, np.integer)):
            idx_val = int(item)
            if idx_val < 0:
                idx_val += self.n_seqs
            if idx_val < 0 or idx_val >= self.n_seqs:
                raise IndexError("Sequence index out of range")
            start = int(self.offsets[idx_val])
            end = start + int(self.lengths[idx_val])
            return KmerRecords(
                self.hashes[start:end],
                self.seq_indices[start:end],
                self.positions[start:end],
            )

        indices, new_offsets, new_lengths, (new_hashes, new_positions) = self._slice_ragged(
            item, self.hashes, self.positions
        )
        if len(indices) == 0:
            return self.empty()

        new_seq_indices = np.repeat(np.arange(len(indices), dtype=np.uint32), new_lengths)

        return replace(
            self,
            hashes=new_hashes,
            seq_indices=new_seq_indices,
            positions=new_positions,
            offsets=new_offsets,
            lengths=new_lengths,
            n_seqs=len(indices),
            is_sorted=False,
        )

    @classmethod
    def empty(cls) -> Self:
        r"""Create an empty index instance."""
        raise NotImplementedError

    @classmethod
    def concat(cls, batches: Iterable[Self]) -> Self:
        r"""Concatenate multiple index batches into a single batch."""
        batches_list = list(batches)
        if not batches_list:
            return cls.empty()

        total_seqs = sum(b.n_seqs for b in batches_list)
        all_lengths = np.concatenate([b.lengths for b in batches_list])
        all_offsets = cls.lengths_to_offsets(all_lengths)
        all_hashes = np.concatenate([b.hashes for b in batches_list])
        all_positions = np.concatenate([b.positions for b in batches_list])

        seq_idx_list = []
        seq_offset = 0
        for b in batches_list:
            if len(b.seq_indices) > 0:
                seq_idx_list.append(b.seq_indices + np.uint32(seq_offset))
            seq_offset += b.n_seqs
        all_seq_indices = np.concatenate(seq_idx_list) if seq_idx_list else np.empty(0, dtype=np.uint32)

        first = batches_list[0]
        return replace(
            first,
            hashes=all_hashes,
            seq_indices=all_seq_indices,
            positions=all_positions,
            offsets=all_offsets,
            lengths=all_lengths,
            n_seqs=total_seqs,
            is_sorted=False,
        )

    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 query index with matching parameters."""
        raise NotImplementedError

    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_hashes = self.hashes[order]
            q_positions = self.positions[order]
            q_seq_indices = self.seq_indices[order]
            q_offsets = _compute_query_offsets(q_seq_indices, self.n_seqs)
            return q_hashes, q_positions, self.hashes, self.seq_indices, self.positions, q_offsets
        else:
            q_offsets = self.indptr.astype(np.uint32)
            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, q_offsets

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

        Args:
            queries (BaseKmerIndex | Sequences | None): Query sequences. If None, queries itself.
            min_score (int): Minimum match score threshold. Defaults to 1.

        Returns:
            Seeds: Matched [`Seeds`][serogen.core.seq.kmers.Seeds] collection.
        """
        if (queries is not None and len(queries) == 0) or len(self.hashes) == 0:
            return Seeds.empty()

        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
        else:
            queries_idx = self._prep_queries(queries)
            q_hashes = queries_idx.hashes
            q_positions = queries_idx.positions
            t_hashes = self.hashes
            t_seq_indices = self.seq_indices
            t_positions = self.positions
            num_queries = queries_idx.n_seqs
            q_offsets = queries_idx.indptr.astype(np.uint32)

        seeds = Seeds(
            *_intersect_top_hit_kernel(
                q_hashes,
                q_positions,
                q_offsets,
                num_queries,
                t_hashes,
                t_seq_indices,
                t_positions,
                self.n_seqs,
            )
        )
        if min_score > 0:
            return seeds.filter(seeds.scores >= min_score)
        return seeds

    def hits(self, queries: BaseKmerIndex | Sequences, min_score: int = 1) -> Seeds:
        r"""Find all matching target sequences for each query sequence above a minimum score.

        Args:
            queries (BaseKmerIndex | Sequences): Query sequence index or raw sequences.
            min_score (int): Minimum match score threshold. Defaults to 1.

        Returns:
            Seeds: Matched [`Seeds`][serogen.core.seq.kmers.Seeds] collection containing all matches.
        """
        if len(queries) == 0 or len(self.hashes) == 0:
            return Seeds.empty()

        queries_idx = self._prep_queries(queries)
        num_queries = queries_idx.n_seqs
        q_offsets = queries_idx.indptr.astype(np.uint32)

        counts = _count_hits_kernel(
            queries_idx.hashes,
            queries_idx.positions,
            q_offsets,
            num_queries,
            self.hashes,
            self.seq_indices,
            self.positions,
            self.n_seqs,
            min_score,
        )
        total_hits = int(np.sum(counts))
        if total_hits == 0:
            return Seeds.empty()

        out_offsets = _compute_offsets_from_counts(counts)
        out_q = np.empty(total_hits, dtype=np.uint32)
        out_t = np.empty(total_hits, dtype=np.uint32)
        out_s = np.empty(total_hits, dtype=np.uint32)
        out_o = np.empty(total_hits, dtype=np.int32)

        _populate_hits_kernel(
            queries_idx.hashes,
            queries_idx.positions,
            q_offsets,
            num_queries,
            self.hashes,
            self.seq_indices,
            self.positions,
            self.n_seqs,
            min_score,
            out_offsets.astype(np.uint32),
            out_q,
            out_t,
            out_s,
            out_o,
        )
        return Seeds(out_q, out_t, out_s, out_o)

total_records property

total_records: int

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

empty classmethod

empty() -> Self

Create an empty index instance.

Source code in src/serogen/core/seq/kmers.py
@classmethod
def empty(cls) -> Self:
    r"""Create an empty index instance."""
    raise NotImplementedError

concat classmethod

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

Concatenate multiple index batches into a single batch.

Source code in src/serogen/core/seq/kmers.py
@classmethod
def concat(cls, batches: Iterable[Self]) -> Self:
    r"""Concatenate multiple index batches into a single batch."""
    batches_list = list(batches)
    if not batches_list:
        return cls.empty()

    total_seqs = sum(b.n_seqs for b in batches_list)
    all_lengths = np.concatenate([b.lengths for b in batches_list])
    all_offsets = cls.lengths_to_offsets(all_lengths)
    all_hashes = np.concatenate([b.hashes for b in batches_list])
    all_positions = np.concatenate([b.positions for b in batches_list])

    seq_idx_list = []
    seq_offset = 0
    for b in batches_list:
        if len(b.seq_indices) > 0:
            seq_idx_list.append(b.seq_indices + np.uint32(seq_offset))
        seq_offset += b.n_seqs
    all_seq_indices = np.concatenate(seq_idx_list) if seq_idx_list else np.empty(0, dtype=np.uint32)

    first = batches_list[0]
    return replace(
        first,
        hashes=all_hashes,
        seq_indices=all_seq_indices,
        positions=all_positions,
        offsets=all_offsets,
        lengths=all_lengths,
        n_seqs=total_seqs,
        is_sorted=False,
    )

to_sorted

to_sorted() -> Self

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

Source code in src/serogen/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) -> Seeds

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

Parameters:

Name Type Description Default
queries BaseKmerIndex | Sequences | None

Query sequences. If None, queries itself.

None
min_score int

Minimum match score threshold. Defaults to 1.

1

Returns:

Name Type Description
Seeds Seeds

Matched Seeds collection.

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

    Args:
        queries (BaseKmerIndex | Sequences | None): Query sequences. If None, queries itself.
        min_score (int): Minimum match score threshold. Defaults to 1.

    Returns:
        Seeds: Matched [`Seeds`][serogen.core.seq.kmers.Seeds] collection.
    """
    if (queries is not None and len(queries) == 0) or len(self.hashes) == 0:
        return Seeds.empty()

    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
    else:
        queries_idx = self._prep_queries(queries)
        q_hashes = queries_idx.hashes
        q_positions = queries_idx.positions
        t_hashes = self.hashes
        t_seq_indices = self.seq_indices
        t_positions = self.positions
        num_queries = queries_idx.n_seqs
        q_offsets = queries_idx.indptr.astype(np.uint32)

    seeds = Seeds(
        *_intersect_top_hit_kernel(
            q_hashes,
            q_positions,
            q_offsets,
            num_queries,
            t_hashes,
            t_seq_indices,
            t_positions,
            self.n_seqs,
        )
    )
    if min_score > 0:
        return seeds.filter(seeds.scores >= min_score)
    return seeds

hits

hits(queries: BaseKmerIndex | Sequences, min_score: int = 1) -> Seeds

Find all matching target sequences for each query sequence above a minimum score.

Parameters:

Name Type Description Default
queries BaseKmerIndex | Sequences

Query sequence index or raw sequences.

required
min_score int

Minimum match score threshold. Defaults to 1.

1

Returns:

Name Type Description
Seeds Seeds

Matched Seeds collection containing all matches.

Source code in src/serogen/core/seq/kmers.py
def hits(self, queries: BaseKmerIndex | Sequences, min_score: int = 1) -> Seeds:
    r"""Find all matching target sequences for each query sequence above a minimum score.

    Args:
        queries (BaseKmerIndex | Sequences): Query sequence index or raw sequences.
        min_score (int): Minimum match score threshold. Defaults to 1.

    Returns:
        Seeds: Matched [`Seeds`][serogen.core.seq.kmers.Seeds] collection containing all matches.
    """
    if len(queries) == 0 or len(self.hashes) == 0:
        return Seeds.empty()

    queries_idx = self._prep_queries(queries)
    num_queries = queries_idx.n_seqs
    q_offsets = queries_idx.indptr.astype(np.uint32)

    counts = _count_hits_kernel(
        queries_idx.hashes,
        queries_idx.positions,
        q_offsets,
        num_queries,
        self.hashes,
        self.seq_indices,
        self.positions,
        self.n_seqs,
        min_score,
    )
    total_hits = int(np.sum(counts))
    if total_hits == 0:
        return Seeds.empty()

    out_offsets = _compute_offsets_from_counts(counts)
    out_q = np.empty(total_hits, dtype=np.uint32)
    out_t = np.empty(total_hits, dtype=np.uint32)
    out_s = np.empty(total_hits, dtype=np.uint32)
    out_o = np.empty(total_hits, dtype=np.int32)

    _populate_hits_kernel(
        queries_idx.hashes,
        queries_idx.positions,
        q_offsets,
        num_queries,
        self.hashes,
        self.seq_indices,
        self.positions,
        self.n_seqs,
        min_score,
        out_offsets.astype(np.uint32),
        out_q,
        out_t,
        out_s,
        out_o,
    )
    return Seeds(out_q, out_t, out_s, out_o)

FracMinHashIndex dataclass

Bases: BaseKmerIndex

Specialized index for fast sequence comparisons using FracMinHash.

Attributes:

Name Type Description
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.

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

Optional custom lookup table.

Methods:

Name Description
empty

Create an empty FracMinHashIndex.

build

Build a FracMinHashIndex from sequence batch.

Source code in src/serogen/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:
        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.
        lut (np.ndarray[tuple[int], np.dtype[np.uint8]] | None): Optional custom lookup table.
    """

    scaled: int = 100
    canonical: bool = True
    bits_per_char: int = 2
    lut: np.ndarray[tuple[int], np.dtype[np.uint8]] | None = None

    @classmethod
    def empty(cls) -> Self:
        r"""Create an empty FracMinHashIndex."""
        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),
            n_seqs=0,
            is_sorted=False,
            k=21,
            alphabet=Alphabet.DNA,
            scaled=100,
            canonical=True,
            bits_per_char=2,
            reduce=False,
            lut=None,
        )

    @classmethod
    def build(
        cls,
        batch: Sequences,
        k: int | None = None,
        alphabet: Alphabet | str = 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 | str): Alphabet instance or name. 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`][serogen.core.seq.kmers.FracMinHashIndex] instance.
        """
        if isinstance(alphabet, str):
            alphabet_obj = (
                Alphabet.MMSEQS12
                if (reduce or alphabet == "mmseqs12")
                else (Alphabet.AMINO if alphabet in ("amino", "amino_acid") else Alphabet.DNA)
            )
        else:
            alphabet_obj = alphabet

        if len(batch) == 0:
            return cls.empty()

        n_seqs = len(batch)
        if canonical is True and not alphabet_obj.supports_canonical:
            raise ValueError(f"Canonical k-mers are not supported for alphabet {alphabet_obj}.")

        resolved_canonical = alphabet_obj.supports_canonical if canonical is None else canonical
        resolved_k = alphabet_obj.default_k if k is None else k
        resolved_bits = alphabet_obj.bits_per_char if bits_per_char is None else bits_per_char
        resolved_reduce = alphabet_obj 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."
            )

        kernel_lut = lut if lut is not None else alphabet_obj.kmer_lut

        counts = _count_fracminhash_kernel(
            batch.seqs, batch.offsets, batch.lengths, kernel_lut, resolved_k, scaled, resolved_canonical, resolved_bits
        )
        total_hashes = int(np.sum(counts))
        if total_hashes == 0:
            return cls.empty()

        out_offsets = _compute_offsets_from_counts(counts)
        out_hashes = np.empty(total_hashes, dtype=np.uint64)
        out_seq_indices = np.empty(total_hashes, dtype=np.uint32)
        out_positions = np.empty(total_hashes, dtype=np.uint32)

        _populate_fracminhash_kernel(
            batch.seqs,
            batch.offsets,
            batch.lengths,
            kernel_lut,
            resolved_k,
            scaled,
            resolved_canonical,
            resolved_bits,
            out_offsets.astype(np.uint32),
            out_hashes,
            out_seq_indices,
            out_positions,
        )

        if sort_by_hash:
            out_hashes, out_seq_indices, out_positions = _radix_sort_soa(out_hashes, out_seq_indices, out_positions)

        return cls(
            hashes=out_hashes,
            seq_indices=out_seq_indices,
            positions=out_positions,
            offsets=out_offsets,
            lengths=counts.astype(np.int32),
            n_seqs=n_seqs,
            is_sorted=sort_by_hash,
            k=resolved_k,
            alphabet=alphabet_obj,
            scaled=scaled,
            canonical=resolved_canonical,
            bits_per_char=resolved_bits,
            reduce=resolved_reduce,
            lut=lut,
        )

    def _build_queries(self, queries: Sequences) -> FracMinHashIndex:
        r"""Build query FracMinHashIndex from sequences matching current settings."""
        return self.build(
            queries,
            k=self.k,
            alphabet=self.alphabet,
            scaled=self.scaled,
            canonical=self.canonical,
            sort_by_hash=False,
            lut=self.lut,
            bits_per_char=self.bits_per_char,
            reduce=self.reduce,
        )

empty classmethod

empty() -> Self

Create an empty FracMinHashIndex.

Source code in src/serogen/core/seq/kmers.py
@classmethod
def empty(cls) -> Self:
    r"""Create an empty FracMinHashIndex."""
    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),
        n_seqs=0,
        is_sorted=False,
        k=21,
        alphabet=Alphabet.DNA,
        scaled=100,
        canonical=True,
        bits_per_char=2,
        reduce=False,
        lut=None,
    )

build classmethod

build(batch: Sequences, k: int | None = None, alphabet: Alphabet | str = 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 | str

Alphabet instance or name. 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/serogen/core/seq/kmers.py
@classmethod
def build(
    cls,
    batch: Sequences,
    k: int | None = None,
    alphabet: Alphabet | str = 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 | str): Alphabet instance or name. 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`][serogen.core.seq.kmers.FracMinHashIndex] instance.
    """
    if isinstance(alphabet, str):
        alphabet_obj = (
            Alphabet.MMSEQS12
            if (reduce or alphabet == "mmseqs12")
            else (Alphabet.AMINO if alphabet in ("amino", "amino_acid") else Alphabet.DNA)
        )
    else:
        alphabet_obj = alphabet

    if len(batch) == 0:
        return cls.empty()

    n_seqs = len(batch)
    if canonical is True and not alphabet_obj.supports_canonical:
        raise ValueError(f"Canonical k-mers are not supported for alphabet {alphabet_obj}.")

    resolved_canonical = alphabet_obj.supports_canonical if canonical is None else canonical
    resolved_k = alphabet_obj.default_k if k is None else k
    resolved_bits = alphabet_obj.bits_per_char if bits_per_char is None else bits_per_char
    resolved_reduce = alphabet_obj 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."
        )

    kernel_lut = lut if lut is not None else alphabet_obj.kmer_lut

    counts = _count_fracminhash_kernel(
        batch.seqs, batch.offsets, batch.lengths, kernel_lut, resolved_k, scaled, resolved_canonical, resolved_bits
    )
    total_hashes = int(np.sum(counts))
    if total_hashes == 0:
        return cls.empty()

    out_offsets = _compute_offsets_from_counts(counts)
    out_hashes = np.empty(total_hashes, dtype=np.uint64)
    out_seq_indices = np.empty(total_hashes, dtype=np.uint32)
    out_positions = np.empty(total_hashes, dtype=np.uint32)

    _populate_fracminhash_kernel(
        batch.seqs,
        batch.offsets,
        batch.lengths,
        kernel_lut,
        resolved_k,
        scaled,
        resolved_canonical,
        resolved_bits,
        out_offsets.astype(np.uint32),
        out_hashes,
        out_seq_indices,
        out_positions,
    )

    if sort_by_hash:
        out_hashes, out_seq_indices, out_positions = _radix_sort_soa(out_hashes, out_seq_indices, out_positions)

    return cls(
        hashes=out_hashes,
        seq_indices=out_seq_indices,
        positions=out_positions,
        offsets=out_offsets,
        lengths=counts.astype(np.int32),
        n_seqs=n_seqs,
        is_sorted=sort_by_hash,
        k=resolved_k,
        alphabet=alphabet_obj,
        scaled=scaled,
        canonical=resolved_canonical,
        bits_per_char=resolved_bits,
        reduce=resolved_reduce,
        lut=lut,
    )

RandstrobeIndex dataclass

Bases: BaseKmerIndex

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

Attributes:

Name Type Description
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.

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

Optional lookup table.

Methods:

Name Description
empty

Create an empty RandstrobeIndex.

build

Build a RandstrobeIndex from sequence batch.

Source code in src/serogen/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:
        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.
        lut (np.ndarray[tuple[int], np.dtype[np.uint8]] | None): Optional lookup table.
    """

    alphabet: Alphabet = Alphabet.MMSEQS12
    s: int = 5
    w_min: int = 1
    w_max: int = 5
    reduce: bool = True
    lut: np.ndarray[tuple[int], np.dtype[np.uint8]] | None = None

    @classmethod
    def empty(cls) -> RandstrobeIndex:
        r"""Create an empty RandstrobeIndex."""
        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),
            n_seqs=0,
            is_sorted=False,
            k=10,
            alphabet=Alphabet.MMSEQS12,
            s=5,
            w_min=1,
            w_max=5,
            reduce=True,
            lut=None,
        )

    @classmethod
    def build(
        cls,
        batch: Sequences,
        k: int = 10,
        s: int = 5,
        w_min: int = 1,
        w_max: int = 5,
        alphabet: Alphabet | str = 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 | str): Alphabet instance or name. 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`][serogen.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).")

        if len(batch) == 0:
            return cls.empty()

        if isinstance(alphabet, str):
            alphabet_obj = (
                Alphabet.MMSEQS12
                if (reduce is not False and alphabet in ("mmseqs12", "amino_acid", "amino"))
                else (Alphabet.DNA if alphabet == "dna" else Alphabet.AMINO)
            )
        else:
            alphabet_obj = alphabet

        resolved_reduce = alphabet_obj is Alphabet.MMSEQS12 if reduce is None else reduce
        kernel_lut = lut if lut is not None else alphabet_obj.kmer_lut

        counts = _count_randstrobes_kernel(batch.seqs, batch.offsets, batch.lengths, kernel_lut, k, s, w_min)
        total_randstrobes = int(np.sum(counts))
        if total_randstrobes == 0:
            return cls.empty()

        out_offsets = _compute_offsets_from_counts(counts)
        out_hashes = np.empty(total_randstrobes, dtype=np.uint64)
        out_seq_indices = np.empty(total_randstrobes, dtype=np.uint32)
        out_positions = np.empty(total_randstrobes, dtype=np.uint32)

        _populate_randstrobes_kernel(
            batch.seqs,
            batch.offsets,
            batch.lengths,
            kernel_lut,
            k,
            s,
            w_min,
            w_max,
            out_offsets.astype(np.uint32),
            out_hashes,
            out_seq_indices,
            out_positions,
        )

        if sort_by_hash:
            out_hashes, out_seq_indices, out_positions = _radix_sort_soa(out_hashes, out_seq_indices, out_positions)

        return cls(
            hashes=out_hashes,
            seq_indices=out_seq_indices,
            positions=out_positions,
            offsets=out_offsets,
            lengths=counts.astype(np.int32),
            n_seqs=len(batch),
            is_sorted=sort_by_hash,
            k=k,
            alphabet=alphabet_obj,
            s=s,
            w_min=w_min,
            w_max=w_max,
            reduce=resolved_reduce,
            lut=lut,
        )

    def _build_queries(self, queries: Sequences) -> RandstrobeIndex:
        r"""Build query RandstrobeIndex from sequences matching current settings."""
        return self.build(
            queries,
            k=self.k,
            s=self.s,
            w_min=self.w_min,
            w_max=self.w_max,
            alphabet=self.alphabet,
            sort_by_hash=False,
            lut=self.lut,
            reduce=self.reduce,
        )

empty classmethod

empty() -> RandstrobeIndex

Create an empty RandstrobeIndex.

Source code in src/serogen/core/seq/kmers.py
@classmethod
def empty(cls) -> RandstrobeIndex:
    r"""Create an empty RandstrobeIndex."""
    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),
        n_seqs=0,
        is_sorted=False,
        k=10,
        alphabet=Alphabet.MMSEQS12,
        s=5,
        w_min=1,
        w_max=5,
        reduce=True,
        lut=None,
    )

build classmethod

build(batch: Sequences, k: int = 10, s: int = 5, w_min: int = 1, w_max: int = 5, alphabet: Alphabet | str = 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 | str

Alphabet instance or name. 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/serogen/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 | str = 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 | str): Alphabet instance or name. 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`][serogen.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).")

    if len(batch) == 0:
        return cls.empty()

    if isinstance(alphabet, str):
        alphabet_obj = (
            Alphabet.MMSEQS12
            if (reduce is not False and alphabet in ("mmseqs12", "amino_acid", "amino"))
            else (Alphabet.DNA if alphabet == "dna" else Alphabet.AMINO)
        )
    else:
        alphabet_obj = alphabet

    resolved_reduce = alphabet_obj is Alphabet.MMSEQS12 if reduce is None else reduce
    kernel_lut = lut if lut is not None else alphabet_obj.kmer_lut

    counts = _count_randstrobes_kernel(batch.seqs, batch.offsets, batch.lengths, kernel_lut, k, s, w_min)
    total_randstrobes = int(np.sum(counts))
    if total_randstrobes == 0:
        return cls.empty()

    out_offsets = _compute_offsets_from_counts(counts)
    out_hashes = np.empty(total_randstrobes, dtype=np.uint64)
    out_seq_indices = np.empty(total_randstrobes, dtype=np.uint32)
    out_positions = np.empty(total_randstrobes, dtype=np.uint32)

    _populate_randstrobes_kernel(
        batch.seqs,
        batch.offsets,
        batch.lengths,
        kernel_lut,
        k,
        s,
        w_min,
        w_max,
        out_offsets.astype(np.uint32),
        out_hashes,
        out_seq_indices,
        out_positions,
    )

    if sort_by_hash:
        out_hashes, out_seq_indices, out_positions = _radix_sort_soa(out_hashes, out_seq_indices, out_positions)

    return cls(
        hashes=out_hashes,
        seq_indices=out_seq_indices,
        positions=out_positions,
        offsets=out_offsets,
        lengths=counts.astype(np.int32),
        n_seqs=len(batch),
        is_sorted=sort_by_hash,
        k=k,
        alphabet=alphabet_obj,
        s=s,
        w_min=w_min,
        w_max=w_max,
        reduce=resolved_reduce,
        lut=lut,
    )

Pairwise Alignment (serogen.core.seq.pairwise)

pairwise

Pairwise sequence alignment algorithms and containers.

This module provides high-performance pairwise sequence alignment capabilities using a parallelized, banded Smith-Waterman-Gotoh dynamic programming algorithm with affine gap penalties. Alignment results are stored in memory-efficient Structure-of-Arrays (SoA) containers (PairwiseAlignments) or scalar records (PairwiseAlignment).

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.

PairwiseAlignment dataclass

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 BLOSUM62 and gap penalties.

matches int

Total number of matching bases.

mismatches int

Total number of mismatched 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).

Source code in src/serogen/core/seq/pairwise.py
@dataclass(frozen=True, slots=True)
class PairwiseAlignment:
    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`][serogen.core.seq.pairwise.PairwiseAlignments] collection.

    Attributes:
        score (int): Final alignment score calculated using BLOSUM62 and gap penalties.
        matches (int): Total number of matching bases.
        mismatches (int): Total number of mismatched 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

    @property
    def bitscore(self) -> float:
        r"""Calculate the Karlin-Altschul BLOSUM62 bitscore of the alignment.

        Uses standard gapped BLOSUM62 parameters ($\lambda = 0.267, K = 0.041$):
        $B = (\lambda S - \ln K) / \ln 2$ for $S > 0$, else $0.0$.
        """
        if self.score <= 0:
            return 0.0
        return max(0.0, (_BLOSUM62_LAMBDA * float(self.score) - _BLOSUM62_LN_K) / _LN_2)

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 property

bitscore: float

Calculate the Karlin-Altschul BLOSUM62 bitscore of the alignment.

Uses standard gapped BLOSUM62 parameters (\(\lambda = 0.267, K = 0.041\)): \(B = (\lambda S - \ln K) / \ln 2\) for \(S > 0\), else \(0.0\).

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 ndarray[tuple[int], dtype[int32]]

1D array of alignment scores.

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

1D array of match counts.

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

1D array of mismatch counts.

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

1D array of gap counts.

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

1D array of query start coordinates.

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

1D array of query end coordinates.

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

1D array of target start coordinates.

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

1D array of target end coordinates.

Methods:

Name Description
empty

Create an empty PairwiseAlignments collection with zero-length int32 arrays.

concat

Concatenate multiple PairwiseAlignments collections into a single batch.

Source code in src/serogen/core/seq/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 (np.ndarray[tuple[int], np.dtype[np.int32]]): 1D array of alignment scores.
        matches (np.ndarray[tuple[int], np.dtype[np.int32]]): 1D array of match counts.
        mismatches (np.ndarray[tuple[int], np.dtype[np.int32]]): 1D array of mismatch counts.
        gaps (np.ndarray[tuple[int], np.dtype[np.int32]]): 1D array of gap counts.
        q_starts (np.ndarray[tuple[int], np.dtype[np.int32]]): 1D array of query start coordinates.
        q_ends (np.ndarray[tuple[int], np.dtype[np.int32]]): 1D array of query end coordinates.
        t_starts (np.ndarray[tuple[int], np.dtype[np.int32]]): 1D array of target start coordinates.
        t_ends (np.ndarray[tuple[int], np.dtype[np.int32]]): 1D array of target end coordinates.
    """

    scores: np.ndarray[tuple[int], np.dtype[np.int32]]
    matches: np.ndarray[tuple[int], np.dtype[np.int32]]
    mismatches: np.ndarray[tuple[int], np.dtype[np.int32]]
    gaps: np.ndarray[tuple[int], np.dtype[np.int32]]
    q_starts: np.ndarray[tuple[int], np.dtype[np.int32]]
    q_ends: np.ndarray[tuple[int], np.dtype[np.int32]]
    t_starts: np.ndarray[tuple[int], np.dtype[np.int32]]
    t_ends: np.ndarray[tuple[int], np.dtype[np.int32]]

    def __len__(self) -> int:
        r"""Return the number of alignments in the batch.

        Returns:
            int: Total count of alignments.
        """
        return len(self.scores)

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

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

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

    def __getitem__(
        self, item: int | np.integer[Any] | slice | np.ndarray[tuple[int], Any] | list[Any]
    ) -> PairwiseAlignment | PairwiseAlignments:
        r"""Access alignment results by index, slice, or boolean array mask.

        Args:
            item (Any): Integer index, slice, or boolean/integer array.

        Returns:
            PairwiseAlignment | PairwiseAlignments: Scalar record or sliced batch collection.

        Raises:
            IndexError: If integer index is out of range.
        """
        if isinstance(item, (int, np.integer)):
            if item < 0:
                item += len(self)
            if item < 0 or item >= len(self):
                raise IndexError("Batch index out of range")
            return PairwiseAlignment(
                score=int(self.scores[item]),
                matches=int(self.matches[item]),
                mismatches=int(self.mismatches[item]),
                gaps=int(self.gaps[item]),
                q_start=int(self.q_starts[item]),
                q_end=int(self.q_ends[item]),
                t_start=int(self.t_starts[item]),
                t_end=int(self.t_ends[item]),
            )
        return PairwiseAlignments(
            scores=self.scores[item],
            matches=self.matches[item],
            mismatches=self.mismatches[item],
            gaps=self.gaps[item],
            q_starts=self.q_starts[item],
            q_ends=self.q_ends[item],
            t_starts=self.t_starts[item],
            t_ends=self.t_ends[item],
        )

    @classmethod
    def empty(cls) -> Self:
        r"""Create an empty PairwiseAlignments collection with zero-length int32 arrays.

        Returns:
            PairwiseAlignments: Empty alignment collection.
        """
        return cls(
            np.empty(0, dtype=np.int32),
            np.empty(0, dtype=np.int32),
            np.empty(0, dtype=np.int32),
            np.empty(0, dtype=np.int32),
            np.empty(0, dtype=np.int32),
            np.empty(0, dtype=np.int32),
            np.empty(0, dtype=np.int32),
            np.empty(0, dtype=np.int32),
        )

    @classmethod
    def concat(cls, batches: Iterable[Self]) -> Self:
        r"""Concatenate multiple PairwiseAlignments collections into a single batch.

        Args:
            batches (Iterable[PairwiseAlignments]): Iterable of alignment collections.

        Returns:
            PairwiseAlignments: Single concatenated alignment collection.
        """
        batches_list = list(batches)
        if not batches_list:
            return cls.empty()
        return cls(
            scores=np.concatenate([b.scores for b in batches_list]),
            matches=np.concatenate([b.matches for b in batches_list]),
            mismatches=np.concatenate([b.mismatches for b in batches_list]),
            gaps=np.concatenate([b.gaps for b in batches_list]),
            q_starts=np.concatenate([b.q_starts for b in batches_list]),
            q_ends=np.concatenate([b.q_ends for b in batches_list]),
            t_starts=np.concatenate([b.t_starts for b in batches_list]),
            t_ends=np.concatenate([b.t_ends for b in batches_list]),
        )

    @property
    def pidents(self) -> np.ndarray[tuple[int], np.dtype[np.float32]]:
        r"""Calculate percent identity for all alignments in the batch in a vectorized manner.

        Returns:
            np.ndarray[tuple[int], np.float32]: 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)

    @property
    def bitscores(self) -> np.ndarray[tuple[int], np.dtype[np.float32]]:
        r"""Calculate Karlin-Altschul BLOSUM62 bitscores for all alignments in the batch.

        Returns:
            np.ndarray[tuple[int], np.dtype[np.float32]]: 1D array of bitscores ($\ge 0.0$).
        """
        raw = (_BLOSUM62_LAMBDA * self.scores.astype(np.float64) - _BLOSUM62_LN_K) / _LN_2
        return np.where(self.scores > 0, np.maximum(0.0, raw), 0.0).astype(np.float32)

pidents property

pidents: ndarray[tuple[int], dtype[float32]]

Calculate percent identity for all alignments in the batch in a vectorized manner.

Returns:

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

np.ndarray[tuple[int], np.float32]: 1D array of percent identity values.

bitscores property

bitscores: ndarray[tuple[int], dtype[float32]]

Calculate Karlin-Altschul BLOSUM62 bitscores for all alignments in the batch.

Returns:

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

np.ndarray[tuple[int], np.dtype[np.float32]]: 1D array of bitscores (\(\ge 0.0\)).

empty classmethod

empty() -> Self

Create an empty PairwiseAlignments collection with zero-length int32 arrays.

Returns:

Name Type Description
PairwiseAlignments Self

Empty alignment collection.

Source code in src/serogen/core/seq/pairwise.py
@classmethod
def empty(cls) -> Self:
    r"""Create an empty PairwiseAlignments collection with zero-length int32 arrays.

    Returns:
        PairwiseAlignments: Empty alignment collection.
    """
    return cls(
        np.empty(0, dtype=np.int32),
        np.empty(0, dtype=np.int32),
        np.empty(0, dtype=np.int32),
        np.empty(0, dtype=np.int32),
        np.empty(0, dtype=np.int32),
        np.empty(0, dtype=np.int32),
        np.empty(0, dtype=np.int32),
        np.empty(0, dtype=np.int32),
    )

concat classmethod

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

Concatenate multiple PairwiseAlignments collections into a single batch.

Parameters:

Name Type Description Default
batches Iterable[PairwiseAlignments]

Iterable of alignment collections.

required

Returns:

Name Type Description
PairwiseAlignments Self

Single concatenated alignment collection.

Source code in src/serogen/core/seq/pairwise.py
@classmethod
def concat(cls, batches: Iterable[Self]) -> Self:
    r"""Concatenate multiple PairwiseAlignments collections into a single batch.

    Args:
        batches (Iterable[PairwiseAlignments]): Iterable of alignment collections.

    Returns:
        PairwiseAlignments: Single concatenated alignment collection.
    """
    batches_list = list(batches)
    if not batches_list:
        return cls.empty()
    return cls(
        scores=np.concatenate([b.scores for b in batches_list]),
        matches=np.concatenate([b.matches for b in batches_list]),
        mismatches=np.concatenate([b.mismatches for b in batches_list]),
        gaps=np.concatenate([b.gaps for b in batches_list]),
        q_starts=np.concatenate([b.q_starts for b in batches_list]),
        q_ends=np.concatenate([b.q_ends for b in batches_list]),
        t_starts=np.concatenate([b.t_starts for b in batches_list]),
        t_ends=np.concatenate([b.t_ends for b in batches_list]),
    )

PairwiseAligner dataclass

A high-performance, batched pairwise sequence aligner.

Uses a parallelized, banded Smith-Waterman-Gotoh algorithm to align pairs of sequences.

Attributes:

Name Type Description
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.

Methods:

Name Description
align_seeds

Convenience method to extract and align specific sequence pairs mapped by seeds.

Source code in src/serogen/core/seq/pairwise.py
@dataclass(frozen=True, slots=True)
class PairwiseAligner:
    r"""A high-performance, batched pairwise sequence aligner.

    Uses a parallelized, banded Smith-Waterman-Gotoh algorithm to align pairs of sequences.

    Attributes:
        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.
    """

    gap_open: int = 11
    gap_extend: int = 1
    k: int = 20

    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.
        """
        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()

        # Handle the unified routing
        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)

        _batched_banded_gotoh(
            queries.seqs,
            queries.offsets,
            queries.lengths,
            targets.seqs,
            targets.offsets,
            targets.lengths,
            _blosum62_matrix(),
            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,
        )

        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.
        """
        paired_queries, paired_targets = seeds.extract_sequences(queries, targets)
        return self(paired_queries, paired_targets, seeds)

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/serogen/core/seq/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.
    """
    paired_queries, paired_targets = seeds.extract_sequences(queries, targets)
    return self(paired_queries, paired_targets, seeds)

Sequence Clustering (serogen.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

Scalar view of a single sequence cluster.

Attributes:

Name Type Description
representative int

Sequence index of the cluster centroid in the source collection.

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

1D array of sequence indices belonging to this cluster.

Source code in src/serogen/core/seq/clustering.py
@dataclass(slots=True, frozen=True, eq=False)
class Cluster:
    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: np.ndarray[tuple[int], np.dtype[np.int32]]

    def __len__(self) -> int:
        r"""Return the number of member sequences in this cluster."""
        return len(self.members)

    def __eq__(self, other: object) -> bool:
        r"""Compare two Cluster views for representative and member equality."""
        if not isinstance(other, Cluster):
            return NotImplemented
        return self.representative == other.representative and bool(np.array_equal(self.members, other.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 ndarray[tuple[int], dtype[int32]]

1D array of sequence indices representing the centroid of each cluster.

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

Flat 1D array of member sequence indices concatenated across all clusters.

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

Starting index into members for each cluster.

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

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.

empty

Construct an empty 0-length Clusters instance.

concat

Concatenate multiple Clusters containers into a single batch.

Source code in src/serogen/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.
    """

    representatives: np.ndarray[tuple[int], np.dtype[np.int32]]
    members: np.ndarray[tuple[int], np.dtype[np.int32]]
    offsets: np.ndarray[tuple[int], np.dtype[np.int32]]
    lengths: np.ndarray[tuple[int], np.dtype[np.int32]]

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

    @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) -> np.ndarray[tuple[int], np.dtype[np.int32]]:
        r"""1D array of member counts for each cluster (alias for `lengths`)."""
        return self.lengths

    @property
    def representative_indices(self) -> np.ndarray[tuple[int], np.dtype[np.int32]]:
        r"""1D array of centroid sequence indices (alias for `representatives`)."""
        return self.representatives

    @property
    def cluster_ids(self) -> np.ndarray[tuple[int], np.dtype[np.int32]]:
        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_array:
        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_array: 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_array((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_array((data, self.members, self.indptr), shape=(c, cols), copy=False)

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

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

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

    def __getitem__(
        self, item: int | np.integer[Any] | slice | np.ndarray[tuple[int], Any] | list[Any]
    ) -> Cluster | Clusters:
        r"""Access a single [`Cluster`][serogen.core.seq.clustering.Cluster] or a sliced [`Clusters`][serogen.core.seq.clustering.Clusters] subset."""
        if isinstance(item, (int, np.integer)):
            idx = int(item)
            if idx < 0:
                idx += len(self)
            if idx < 0 or idx >= len(self):
                raise IndexError(f"Cluster index {item} out of bounds [0, {len(self) - 1}]")
            off = int(self.offsets[idx])
            length = int(self.lengths[idx])
            return Cluster(
                representative=int(self.representatives[idx]),
                members=self.members[off : off + length],
            )

        indices, new_offsets, new_lengths, (new_members,) = self._slice_ragged(item, self.members)
        if len(indices) == 0:
            return self.empty()

        return type(self)(
            representatives=self.representatives[indices],
            members=new_members,
            offsets=new_offsets,
            lengths=new_lengths,
        )

    def __iter__(self) -> Iterator[Cluster]:
        r"""Iterate over all clusters in the container."""
        for i in range(len(self)):
            off = int(self.offsets[i])
            length = int(self.lengths[i])
            yield Cluster(
                representative=int(self.representatives[i]),
                members=self.members[off : off + length],
            )

    def get_members(self, cluster_id: int) -> np.ndarray[tuple[int], np.dtype[np.int32]]:
        r"""Return 1D array of original sequence indices assigned to the specified cluster ID.

        Args:
            cluster_id: 0-based integer cluster identifier.

        Returns:
            np.ndarray: 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`][serogen.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: np.ndarray[tuple[int], np.dtype[np.int32]],
        representatives: np.ndarray[tuple[int], np.dtype[np.int32]],
        sizes: np.ndarray[tuple[int], np.dtype[np.int32]],
    ) -> Self:
        r"""Construct a [`Clusters`][serogen.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,
        )

    @classmethod
    def empty(cls) -> Self:
        r"""Construct an empty 0-length [`Clusters`][serogen.core.seq.clustering.Clusters] instance."""
        return cls(
            representatives=np.empty(0, dtype=np.int32),
            members=np.empty(0, dtype=np.int32),
            offsets=np.empty(0, dtype=np.int32),
            lengths=np.empty(0, dtype=np.int32),
        )

    @classmethod
    def concat(cls, batches: Iterable[Self]) -> Self:
        r"""Concatenate multiple [`Clusters`][serogen.core.seq.clustering.Clusters] containers into a single batch."""
        batch_list = list(batches)
        if not batch_list:
            return cls.empty()

        non_empty = [b for b in batch_list if len(b) > 0]
        if not non_empty:
            return cls.empty()

        representatives = np.concatenate([b.representatives for b in non_empty])
        lengths = np.concatenate([b.lengths for b in non_empty])
        offsets = cls.lengths_to_offsets(lengths)
        members = np.concatenate([b.members for b in non_empty if len(b.members) > 0])
        return cls(
            representatives=representatives,
            members=members,
            offsets=offsets,
            lengths=lengths,
        )

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: ndarray[tuple[int], dtype[int32]]

1D array of member counts for each cluster (alias for lengths).

representative_indices property

representative_indices: ndarray[tuple[int], dtype[int32]]

1D array of centroid sequence indices (alias for representatives).

cluster_ids property

cluster_ids: ndarray[tuple[int], dtype[int32]]

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_array

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_array csr_array

Sparse \((C \times N)\) uint8 membership matrix.

Source code in src/serogen/core/seq/clustering.py
def as_csr(self, n_seqs: int | None = None) -> csr_array:
    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_array: 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_array((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_array((data, self.members, self.indptr), shape=(c, cols), copy=False)

get_members

get_members(cluster_id: int) -> ndarray[tuple[int], dtype[int32]]

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:

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

np.ndarray: Zero-copy slice of sequence indices belonging to this cluster.

Source code in src/serogen/core/seq/clustering.py
def get_members(self, cluster_id: int) -> np.ndarray[tuple[int], np.dtype[np.int32]]:
    r"""Return 1D array of original sequence indices assigned to the specified cluster ID.

    Args:
        cluster_id: 0-based integer cluster identifier.

    Returns:
        np.ndarray: 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/serogen/core/seq/clustering.py
def centroids(self, seqs: Sequences) -> Sequences:
    r"""Extract the representative centroid sequences from the source collection.

    Args:
        seqs: Source [`Sequences`][serogen.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: ndarray[tuple[int], dtype[int32]], representatives: ndarray[tuple[int], dtype[int32]], sizes: ndarray[tuple[int], dtype[int32]]) -> Self

Construct a Clusters container from flat cluster ID assignments.

Parameters:

Name Type Description Default
cluster_ids ndarray[tuple[int], dtype[int32]]

1D array mapping each sequence \(i \in [0, N-1]\) to cluster ID \(c \in [0, C-1]\).

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

1D array of centroid sequence indices (length \(C\)).

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

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/serogen/core/seq/clustering.py
@classmethod
def from_assignments(
    cls,
    cluster_ids: np.ndarray[tuple[int], np.dtype[np.int32]],
    representatives: np.ndarray[tuple[int], np.dtype[np.int32]],
    sizes: np.ndarray[tuple[int], np.dtype[np.int32]],
) -> Self:
    r"""Construct a [`Clusters`][serogen.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,
    )

empty classmethod

empty() -> Self

Construct an empty 0-length Clusters instance.

Source code in src/serogen/core/seq/clustering.py
@classmethod
def empty(cls) -> Self:
    r"""Construct an empty 0-length [`Clusters`][serogen.core.seq.clustering.Clusters] instance."""
    return cls(
        representatives=np.empty(0, dtype=np.int32),
        members=np.empty(0, dtype=np.int32),
        offsets=np.empty(0, dtype=np.int32),
        lengths=np.empty(0, dtype=np.int32),
    )

concat classmethod

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

Concatenate multiple Clusters containers into a single batch.

Source code in src/serogen/core/seq/clustering.py
@classmethod
def concat(cls, batches: Iterable[Self]) -> Self:
    r"""Concatenate multiple [`Clusters`][serogen.core.seq.clustering.Clusters] containers into a single batch."""
    batch_list = list(batches)
    if not batch_list:
        return cls.empty()

    non_empty = [b for b in batch_list if len(b) > 0]
    if not non_empty:
        return cls.empty()

    representatives = np.concatenate([b.representatives for b in non_empty])
    lengths = np.concatenate([b.lengths for b in non_empty])
    offsets = cls.lengths_to_offsets(lengths)
    members = np.concatenate([b.members for b in non_empty if len(b.members) > 0])
    return cls(
        representatives=representatives,
        members=members,
        offsets=offsets,
        lengths=lengths,
    )

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/SciPy 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 via SciPy.
  • "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
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/serogen/core/seq/clustering.py
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
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
@dataclass(slots=True, frozen=True)
class Clusterer:
    r"""High-performance sequence clustering engine.

    Combines sparse syncmer-linked randstrobe pre-filtering ([`RandstrobeIndex`][serogen.core.seq.kmers.RandstrobeIndex]),
    SIMD-parallel banded Smith-Waterman-Gotoh alignment ([`PairwiseAligner`][serogen.core.seq.pairwise.PairwiseAligner]),
    and compiled Numba/SciPy 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 via SciPy.
        - `"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.
        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
    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`][serogen.core.seq.pairwise.PairwiseAligner] instance."""
        return PairwiseAligner(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)
        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[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_q, sub_t = _mmseqs12_k4_ungapped_prefilter(
                            sub_seqs.seqs,
                            sub_seqs.offsets,
                            sub_seqs.lengths,
                            min_cov_eff,
                            42,
                            Alphabet.MMSEQS12.kmer_lut,
                            _blosum62_matrix(),
                        )
                        if len(sub_q) > 0:
                            gq = members_r[sub_q]
                            gt = members_r[sub_t]
                            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,
                            _blosum62_matrix(),
                            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_q, cand_t = _mmseqs12_k4_ungapped_prefilter(
                    seqs.seqs,
                    seqs.offsets,
                    seqs.lengths,
                    min_cov_eff,
                    42,
                    Alphabet.MMSEQS12.kmer_lut,
                    _blosum62_matrix(),
                )
                if len(cand_q) > 0:
                    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)
                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/serogen/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)
    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[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_q, sub_t = _mmseqs12_k4_ungapped_prefilter(
                        sub_seqs.seqs,
                        sub_seqs.offsets,
                        sub_seqs.lengths,
                        min_cov_eff,
                        42,
                        Alphabet.MMSEQS12.kmer_lut,
                        _blosum62_matrix(),
                    )
                    if len(sub_q) > 0:
                        gq = members_r[sub_q]
                        gt = members_r[sub_t]
                        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,
                        _blosum62_matrix(),
                        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_q, cand_t = _mmseqs12_k4_ungapped_prefilter(
                seqs.seqs,
                seqs.offsets,
                seqs.lengths,
                min_cov_eff,
                42,
                Alphabet.MMSEQS12.kmer_lut,
                _blosum62_matrix(),
            )
            if len(cand_q) > 0:
                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)
            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, Alignment & Layout (serogen.core.graph)

models

Core graph representations, bitfield packing, and vectorized SoA containers.

Classes:

Name Description
BitField

Descriptor defining a bitfield slice (shift, width, mask).

GraphNode

Abstract base protocol for all core nodes.

IntNode

Base model for core nodes represented as bit-packed primitive integers.

DirectedEdge

Abstract base class for all directed core edges.

GraphModel

Protocol and mixin providing core topological algorithms to scalar views representing graphs.

GraphBatch

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/serogen/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/serogen/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/serogen/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/serogen/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

GraphNode

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/serogen/core/graph/models.py
class GraphNode(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, GraphNode

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/serogen/core/graph/models.py
class IntNode(int, GraphNode):
    """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/serogen/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/serogen/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/serogen/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

Bases: ABC

Abstract base class for all directed core edges.

Source code in src/serogen/core/graph/models.py
@dataclass(slots=True, frozen=True)
class DirectedEdge(ABC):
    """Abstract 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}"
            )

GraphModel

Bases: ScalarView, Protocol

Protocol and mixin providing core topological algorithms to scalar views representing graphs. Requires concrete @dataclass(slots=True, frozen=True) classes to provide nodes and edges.

Methods:

Name Description
as_graph

Builds a sparse CSR adjacency matrix and node label array.

laplacian_spectrum

Computes the continuous topological spectrum of the core.

spectral_distance

Computes the topological Wasserstein Distance (Earth Mover's Distance)

Attributes:

Name Type Description
nodes Sequence[Any]

Ordered sequence of graph nodes.

edges Sequence[DirectedEdge]

Ordered sequence of directed edges connecting nodes.

Source code in src/serogen/core/graph/models.py
class GraphModel(ScalarView, Protocol):
    """
    Protocol and mixin providing core topological algorithms to scalar views representing graphs.
    Requires concrete `@dataclass(slots=True, frozen=True)` classes to provide `nodes` and `edges`.
    """

    @property
    def nodes(self) -> Sequence[Any]:
        """Ordered sequence of graph nodes."""
        ...

    @property
    def edges(self) -> Sequence[DirectedEdge]:
        """Ordered sequence of directed edges connecting `nodes`."""
        ...

    @cache  # noqa: B019
    def as_graph(
        self,
        as_bipartite: bool = False,
        symmetrize: bool = False,
    ) -> tuple[csr_array, 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_array((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_array((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_array((data, (rows, cols)), shape=(total_nodes, total_nodes), dtype=np.uint32)
        return adj, labels

    @property
    def num_nodes(self) -> int:
        return len(self.nodes)

    @property
    def num_edges(self) -> int:
        return len(self.edges)

    def __len__(self) -> int:
        return self.num_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 adjacency matrix, computes the
        normalized core Laplacian using :func:`scipy.sparse.csgraph.laplacian`,
        and solves for the sorted eigenvalues using :func:`scipy.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.array([], dtype=np.float64)

        adj_unweighted = adj.astype(np.float64)
        adj_unweighted.data.fill(1.0)
        adj_sym = adj_unweighted + adj_unweighted.T
        adj_sym.data.fill(1.0)

        L = csgraph.laplacian(adj_sym, normed=True)
        L_dense = np.asarray(L.toarray() if hasattr(L, "toarray") else L, dtype=np.float64)
        eigenvalues = linalg.eigvalsh(L_dense)
        return np.sort(eigenvalues)

    def spectral_distance(self, other: GraphModel) -> 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 using :func:`scipy.stats.wasserstein_distance`.

        Args:
            other: Another GraphModel instance to compare against.

        Returns:
            Wasserstein distance between the two normalized Laplacian spectra as a float.
        """
        spec1 = self.laplacian_spectrum()
        spec2 = other.laplacian_spectrum()
        if len(spec1) == 0 or len(spec2) == 0:
            return 0.0 if len(spec1) == len(spec2) else 1.0
        return float(wasserstein_distance(spec1, spec2))

    def __repr__(self) -> str:
        return f"{self.__class__.__name__}({len(self.nodes)} nodes, {len(self.edges)} edges)"

nodes property

nodes: Sequence[Any]

Ordered sequence of graph nodes.

edges property

edges: Sequence[DirectedEdge]

Ordered sequence of directed edges connecting nodes.

as_graph cached

as_graph(as_bipartite: bool = False, symmetrize: bool = False) -> tuple[csr_array, 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_array, 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/serogen/core/graph/models.py
@cache  # noqa: B019
def as_graph(
    self,
    as_bipartite: bool = False,
    symmetrize: bool = False,
) -> tuple[csr_array, 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_array((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_array((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_array((data, (rows, cols)), shape=(total_nodes, total_nodes), dtype=np.uint32)
    return adj, labels

laplacian_spectrum cached

laplacian_spectrum() -> ndarray[tuple[int], dtype[float64]]

Computes the continuous topological spectrum of the core.

Constructs the unweighted, symmetrized adjacency matrix, computes the normalized core Laplacian using :func:scipy.sparse.csgraph.laplacian, and solves for the sorted eigenvalues using :func:scipy.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/serogen/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 adjacency matrix, computes the
    normalized core Laplacian using :func:`scipy.sparse.csgraph.laplacian`,
    and solves for the sorted eigenvalues using :func:`scipy.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.array([], dtype=np.float64)

    adj_unweighted = adj.astype(np.float64)
    adj_unweighted.data.fill(1.0)
    adj_sym = adj_unweighted + adj_unweighted.T
    adj_sym.data.fill(1.0)

    L = csgraph.laplacian(adj_sym, normed=True)
    L_dense = np.asarray(L.toarray() if hasattr(L, "toarray") else L, dtype=np.float64)
    eigenvalues = linalg.eigvalsh(L_dense)
    return np.sort(eigenvalues)

spectral_distance

spectral_distance(other: GraphModel) -> 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 using :func:scipy.stats.wasserstein_distance.

Parameters:

Name Type Description Default
other GraphModel

Another GraphModel instance to compare against.

required

Returns:

Type Description
float

Wasserstein distance between the two normalized Laplacian spectra as a float.

Source code in src/serogen/core/graph/models.py
def spectral_distance(self, other: GraphModel) -> 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 using :func:`scipy.stats.wasserstein_distance`.

    Args:
        other: Another GraphModel instance to compare against.

    Returns:
        Wasserstein distance between the two normalized Laplacian spectra as a float.
    """
    spec1 = self.laplacian_spectrum()
    spec2 = other.laplacian_spectrum()
    if len(spec1) == 0 or len(spec2) == 0:
        return 0.0 if len(spec1) == len(spec2) else 1.0
    return float(wasserstein_distance(spec1, spec2))

GraphBatch 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.

get_block_diagonal_graph

Returns a single disconnected CSR core representing the entire 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 ndarray[tuple[int], dtype[uint32]]

Number of nodes in each graph in the batch.

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

Number of edges in each graph in the batch.

Source code in src/serogen/core/graph/models.py
@dataclass(slots=True, frozen=True)
class GraphBatch[T: GraphModel, S: GraphBatch[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: np.ndarray[tuple[int], np.dtype[np.uint32]]
    node_offsets: np.ndarray[tuple[int], np.dtype[np.uint32]]
    edge_sources: np.ndarray[tuple[int], np.dtype[np.uint32]]
    edge_targets: np.ndarray[tuple[int], np.dtype[np.uint32]]
    edge_types: np.ndarray[tuple[int], np.dtype[np.uint32]]
    edge_offsets: np.ndarray[tuple[int], np.dtype[np.uint32]]

    def __len__(self) -> int:
        return len(self.node_offsets) - 1

    @property
    def num_graphs(self) -> int:
        return len(self)

    @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) -> np.ndarray[tuple[int], np.dtype[np.uint32]]:
        """Number of nodes in each graph in the batch."""
        return np.diff(self.node_offsets)

    @property
    def edge_lengths(self) -> np.ndarray[tuple[int], np.dtype[np.uint32]]:
        """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,
        )

    def get_block_diagonal_graph(self, unweighted: bool = False) -> csr_array:
        """
        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_array((N, N), dtype=np.uint32)

        weights = np.ones(len(self.edge_types), dtype=np.uint32) if unweighted else self.edge_types
        return csr_array((weights, (self.edge_sources, self.edge_targets)), shape=(N, N), dtype=np.uint32)

    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 Laplacian spectra from the block-diagonal CSR core via
        :func:`scipy.sparse.csgraph.laplacian` and :func:`scipy.linalg.eigvalsh`, then
        computes pairwise Wasserstein distances using :func:`scipy.stats.wasserstein_distance`.

        Returns:
            A dense symmetric matrix (N x N) of Wasserstein distances (`np.ndarray[tuple[int, int], np.dtype[np.float64]]`).
        """
        N = len(self)
        dist_matrix = np.zeros((N, N), dtype=np.float64)
        if N == 0:
            return dist_matrix

        adj_full = self.get_block_diagonal_graph(unweighted=True)
        spectra = []
        for i in range(N):
            m_start, m_end = self.node_offsets[i], self.node_offsets[i + 1]
            if m_start == m_end:
                spectra.append(np.array([], dtype=np.float64))
                continue

            adj_sub = adj_full[m_start:m_end, m_start:m_end]
            adj_sub = adj_sub + adj_sub.T
            adj_sub.data = np.ones_like(adj_sub.data)

            L = csgraph.laplacian(adj_sub.astype(np.float64), normed=True)
            L_dense = np.asarray(L.toarray() if hasattr(L, "toarray") else L, dtype=np.float64)
            eigenvalues = linalg.eigvalsh(L_dense)
            spectra.append(np.sort(eigenvalues))

        for i in range(N):
            for j in range(i + 1, N):
                if len(spectra[i]) == 0 or len(spectra[j]) == 0:
                    d = 0.0 if len(spectra[i]) == len(spectra[j]) else 1.0
                else:
                    d = float(wasserstein_distance(spectra[i], spectra[j]))
                dist_matrix[i, j] = d
                dist_matrix[j, i] = d

        return dist_matrix

    def as_pan_graph(
        self,
        node_mask: int | None = None,
    ) -> tuple[
        csr_array,
        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_array((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_array((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]

        edge_pairs = np.column_stack([p_src, p_tgt])
        unique_edges, edge_counts = np.unique(edge_pairs, axis=0, return_counts=True)

        rows = unique_edges[:, 0]
        cols = unique_edges[:, 1]
        data = edge_counts.astype(np.uint32)

        adj = csr_array((data, (rows, cols)), shape=(num_pan_nodes, num_pan_nodes), dtype=np.uint32)
        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: ndarray[tuple[int], dtype[uint32]]

Number of nodes in each graph in the batch.

edge_lengths property

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

Number of edges in each graph in the batch.

empty classmethod

empty() -> Self

Create an empty 0-length graph batch.

Source code in src/serogen/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/serogen/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,
    )

get_block_diagonal_graph

get_block_diagonal_graph(unweighted: bool = False) -> csr_array

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_array

Block-diagonal sparse CSR adjacency matrix of shape (total_nodes, total_nodes).

Source code in src/serogen/core/graph/models.py
def get_block_diagonal_graph(self, unweighted: bool = False) -> csr_array:
    """
    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_array((N, N), dtype=np.uint32)

    weights = np.ones(len(self.edge_types), dtype=np.uint32) if unweighted else self.edge_types
    return csr_array((weights, (self.edge_sources, self.edge_targets)), shape=(N, N), dtype=np.uint32)

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 Laplacian spectra from the block-diagonal CSR core via :func:scipy.sparse.csgraph.laplacian and :func:scipy.linalg.eigvalsh, then computes pairwise Wasserstein distances using :func:scipy.stats.wasserstein_distance.

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/serogen/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 Laplacian spectra from the block-diagonal CSR core via
    :func:`scipy.sparse.csgraph.laplacian` and :func:`scipy.linalg.eigvalsh`, then
    computes pairwise Wasserstein distances using :func:`scipy.stats.wasserstein_distance`.

    Returns:
        A dense symmetric matrix (N x N) of Wasserstein distances (`np.ndarray[tuple[int, int], np.dtype[np.float64]]`).
    """
    N = len(self)
    dist_matrix = np.zeros((N, N), dtype=np.float64)
    if N == 0:
        return dist_matrix

    adj_full = self.get_block_diagonal_graph(unweighted=True)
    spectra = []
    for i in range(N):
        m_start, m_end = self.node_offsets[i], self.node_offsets[i + 1]
        if m_start == m_end:
            spectra.append(np.array([], dtype=np.float64))
            continue

        adj_sub = adj_full[m_start:m_end, m_start:m_end]
        adj_sub = adj_sub + adj_sub.T
        adj_sub.data = np.ones_like(adj_sub.data)

        L = csgraph.laplacian(adj_sub.astype(np.float64), normed=True)
        L_dense = np.asarray(L.toarray() if hasattr(L, "toarray") else L, dtype=np.float64)
        eigenvalues = linalg.eigvalsh(L_dense)
        spectra.append(np.sort(eigenvalues))

    for i in range(N):
        for j in range(i + 1, N):
            if len(spectra[i]) == 0 or len(spectra[j]) == 0:
                d = 0.0 if len(spectra[i]) == len(spectra[j]) else 1.0
            else:
                d = float(wasserstein_distance(spectra[i], spectra[j]))
            dist_matrix[i, j] = d
            dist_matrix[j, i] = d

    return dist_matrix

as_pan_graph

as_pan_graph(node_mask: int | None = None) -> tuple[csr_array, 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_array, 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/serogen/core/graph/models.py
def as_pan_graph(
    self,
    node_mask: int | None = None,
) -> tuple[
    csr_array,
    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_array((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_array((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]

    edge_pairs = np.column_stack([p_src, p_tgt])
    unique_edges, edge_counts = np.unique(edge_pairs, axis=0, return_counts=True)

    rows = unique_edges[:, 0]
    cols = unique_edges[:, 1]
    data = edge_counts.astype(np.uint32)

    adj = csr_array((data, (rows, cols)), shape=(num_pan_nodes, num_pan_nodes), dtype=np.uint32)
    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/serogen/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/serogen/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/serogen/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/serogen/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/serogen/core/graph/align.py
class EmbeddingStrategy(ABC):
    """Abstract base strategy for computing continuous topological node embeddings."""

    @abstractmethod
    def compute(
        self,
        adj: csr_array,
        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_array, 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/serogen/core/graph/align.py
@abstractmethod
def compute(
    self,
    adj: csr_array,
    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/serogen/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_array,
        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
        deg_inv = diags(1.0 / deg)
        adj_norm = deg_inv @ adj

        embeddings = [features]
        current_h = features
        alpha = self.smoothing_factor
        for _ in range(self.iterations):
            current_h = (1.0 - alpha) * current_h + alpha * (adj_norm @ current_h)
            embeddings.append(current_h)

        return np.concatenate(embeddings, axis=1)

compute

compute(adj: csr_array, features: ndarray[tuple[int, int], dtype[float64]]) -> ndarray[tuple[int, int], dtype[float64]]

Smooth continuous features across sparse graph adjacency.

Source code in src/serogen/core/graph/align.py
def compute(
    self,
    adj: csr_array,
    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
    deg_inv = diags(1.0 / deg)
    adj_norm = deg_inv @ adj

    embeddings = [features]
    current_h = features
    alpha = self.smoothing_factor
    for _ in range(self.iterations):
        current_h = (1.0 - alpha) * current_h + alpha * (adj_norm @ current_h)
        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/serogen/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_array,
        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_array, 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/serogen/core/graph/align.py
def compute(
    self,
    adj: csr_array,
    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/serogen/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: GraphModel | None = None,
        graph_b: GraphModel | 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: GraphModel | None = None, graph_b: GraphModel | None = None) -> ndarray[tuple[int, int], dtype[float64]]

Compute (N, M) pairwise node compatibility cost matrix.

Source code in src/serogen/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: GraphModel | None = None,
    graph_b: GraphModel | 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/serogen/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: GraphModel | None = None,
        graph_b: GraphModel | 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: GraphModel | None = None, graph_b: GraphModel | None = None) -> ndarray[tuple[int, int], dtype[float64]]

Compute exact equality cost matrix using vectorized broadcasting.

Source code in src/serogen/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: GraphModel | None = None,
    graph_b: GraphModel | 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/serogen/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: GraphModel | None = None,
        graph_b: GraphModel | 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: GraphModel | None = None, graph_b: GraphModel | None = None) -> ndarray[tuple[int, int], dtype[float64]]

Delegate cost computation to wrapped callable.

Source code in src/serogen/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: GraphModel | None = None,
    graph_b: GraphModel | 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/serogen/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: GraphModel
    graph_b: GraphModel
    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 GraphModel instances.

Source code in src/serogen/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: GraphModel, graph_b: GraphModel) -> Alignment:
        """Execute bipartite graph alignment between two `GraphModel` 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(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: GraphModel, graph_b: GraphModel) -> Alignment

Execute bipartite graph alignment between two GraphModel instances.

Source code in src/serogen/core/graph/align.py
def align(self, graph_a: GraphModel, graph_b: GraphModel) -> Alignment:
    """Execute bipartite graph alignment between two `GraphModel` 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(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/serogen/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 GraphModel instances, GraphBatch 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.

Functions:

Name Description
rescale_layout

Rescale node coordinates so the largest centered coordinate magnitude equals scale.

LayoutEngine dataclass

Configurable graph layout engine with Numba-accelerated Fruchterman-Reingold physics.

Supports laying out single GraphModel instances, GraphBatch 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 GraphModel.

node_mask int | None

Optional bitmask passed to GraphBatch.as_pan_graph(node_mask=...).

seed int

Deterministic random seed for coordinate initialization. Defaults to 42.

Methods:

Name Description
layout

Compute (N, dim) layout coordinates for a graph, batch pan-graph, or adjacency matrix.

Source code in src/serogen/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 [`GraphModel`][serogen.core.graph.models.GraphModel] instances,
    [`GraphBatch`][serogen.core.graph.models.GraphBatch] collections (via their unified
    [`.as_pan_graph()`][serogen.core.graph.models.GraphBatch.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 `GraphModel`.
        node_mask: Optional bitmask passed to `GraphBatch.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: GraphModel | GraphBatch[Any, Any] | csr_array | 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`][serogen.core.graph.layout.LayoutEngine.layout])."""
        return self.layout(graph, pos=pos, fixed=fixed)

    def layout(
        self,
        graph: GraphModel | GraphBatch[Any, Any] | csr_array | 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 [`GraphModel`][serogen.core.graph.models.GraphModel],
                [`GraphBatch`][serogen.core.graph.models.GraphBatch] (laid out via `.as_pan_graph()`),
                sparse `csr_array`, 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 = self._extract_undirected_edges(adj)

        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 rescale_layout(coords, scale=float(self.scale), center=center_arr)
        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: GraphModel | GraphBatch[Any, Any] | csr_array | np.ndarray,
    ) -> csr_array:
        r"""Normalize input `GraphModel`, `GraphBatch`, or matrix into a square `csr_array`."""
        if isinstance(graph, GraphBatch):
            adj, _, _ = graph.as_pan_graph(node_mask=self.node_mask)
            return adj
        if hasattr(graph, "as_graph"):
            adj, _ = cast(GraphModel, graph).as_graph(as_bipartite=self.as_bipartite, symmetrize=False)
            return adj
        if issparse(graph):
            csr = csr_array(graph)
            if csr.ndim != 2 or csr.shape[0] != csr.shape[1]:
                raise ValueError(f"Adjacency matrix must be square 2D, got shape {csr.shape}.")
            return csr
        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_array(graph)
        raise TypeError(
            f"Unsupported graph type {type(graph).__name__!r}. "
            "Expected GraphModel, GraphBatch, scipy.sparse.csr_array, or 2D numpy.ndarray."
        )

    def _extract_undirected_edges(
        self,
        adj: csr_array,
    ) -> 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"""Convert a directed/undirected CSR matrix into unique upper-triangular edges `(u < v, w)`."""
        if adj.nnz == 0:
            return (
                np.empty(0, dtype=np.int32),
                np.empty(0, dtype=np.int32),
                np.empty(0, dtype=np.float64),
            )

        adj_f = adj.astype(np.float64)
        if not self.use_weights:
            adj_f.data.fill(1.0)

        sym = csr_array(adj_f + adj_f.T)
        if not self.use_weights and sym.nnz > 0:
            sym.data.fill(1.0)

        coo = sym.tocoo()
        row = np.asarray(coo.row, dtype=np.int32)
        col = np.asarray(coo.col, dtype=np.int32)
        data = np.asarray(coo.data, dtype=np.float64)

        upper = (row < col) & (data > 0.0)
        return (
            np.ascontiguousarray(row[upper], dtype=np.int32),
            np.ascontiguousarray(col[upper], dtype=np.int32),
            np.ascontiguousarray(data[upper], dtype=np.float64),
        )

    def _init_positions(
        self,
        adj: csr_array,
        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:
            adj_sym = adj.astype(np.float64)
            if not self.use_weights:
                adj_sym.data.fill(1.0)
            adj_sym = csr_array(adj_sym + adj_sym.T)
            if not self.use_weights and adj_sym.nnz > 0:
                adj_sym.data.fill(1.0)
            lap = csgraph.laplacian(adj_sym, normed=True)
            lap_dense = np.asarray(lap.toarray() if hasattr(lap, "toarray") else lap, dtype=np.float64)
            _, vecs = 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 rescale_layout(spec, scale=1.0)

        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

layout

layout(graph: GraphModel | GraphBatch[Any, Any] | csr_array | 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 GraphModel | GraphBatch[Any, Any] | csr_array | ndarray

Input GraphModel, GraphBatch (laid out via .as_pan_graph()), sparse csr_array, 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/serogen/core/graph/layout.py
def layout(
    self,
    graph: GraphModel | GraphBatch[Any, Any] | csr_array | 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 [`GraphModel`][serogen.core.graph.models.GraphModel],
            [`GraphBatch`][serogen.core.graph.models.GraphBatch] (laid out via `.as_pan_graph()`),
            sparse `csr_array`, 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 = self._extract_undirected_edges(adj)

    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 rescale_layout(coords, scale=float(self.scale), center=center_arr)
    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

rescale_layout

rescale_layout(pos: ndarray[tuple[int, int], dtype[float64]], scale: float = 1.0, center: ndarray[tuple[int], dtype[float64]] | None = None) -> 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
scale float

Target maximum radius around center.

1.0
center ndarray[tuple[int], dtype[float64]] | None

Optional (dim,) center point. Defaults to origin.

None

Returns:

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

Rescaled (N, dim) coordinate array (float64).

Source code in src/serogen/core/graph/layout.py
def rescale_layout(
    pos: np.ndarray[tuple[int, int], np.dtype[np.float64]],
    scale: float = 1.0,
    center: np.ndarray[tuple[int], np.dtype[np.float64]] | None = None,
) -> 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.
        scale: Target maximum radius around `center`.
        center: Optional `(dim,)` center point. Defaults to origin.

    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 center is None else np.asarray(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:
        out *= float(scale) / max_lim
    out += target_center[None, :]
    return out

gnn

Numba-accelerated Relational Message-Passing Neural Network (R-MPNN) primitives.

Provides domain-agnostic CSR relational graph builders, compiled @njit(parallel=True, fastmath=True) message-passing forward/backward kernels, factorized bilinear directed edge/slot scorers, log-space Sinkhorn bipartite matching, and the RelationalMPNN model class.

Classes:

Name Description
RelationalCSR

Compressed Sparse Row (CSR) incoming-edge representation of a multi-relational directed graph.

RelationalMPNN

Numba-powered Relational Message-Passing Neural Network (R-MPNN) for node & directed tree inference.

Functions:

Name Description
build_relational_csr

Construct a RelationalCSR from flat directed edge arrays.

build_multihop_csr

Build a multi-hop relational CSR graph for a single graph or a ragged batch of graphs.

mpnn_layer_forward

Parallel relational message-passing forward kernel with residual connection, ReLU, and RMSNorm.

mpnn_layer_backward

Backward pass through RMSNorm, clamped residual anchor, ReLU, and CSR relational aggregation.

bilinear_edge_scores_forward

Compute factorized bilinear multi-channel edge logits S[u, v, c] = sum_k (h_u W_src)_k (h_v W_dst)_k W_chan[c, k] + b_c.

bilinear_edge_scores_backward

Backward pass for factorized bilinear multi-channel edge scorer.

sinkhorn_bipartite_matching

Differentiable log-space Sinkhorn-Knopp operator producing a doubly stochastic soft assignment matrix.

RelationalCSR dataclass

Compressed Sparse Row (CSR) incoming-edge representation of a multi-relational directed graph.

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 = src_indices[k], relation ID r_k = rel_types[k], and normalized aggregation weight edge_weights[k].

Attributes:

Name Type Description
n_nodes int

Total number of vertices \(N\) (including any appended virtual pool vertices).

n_relations int

Total number of distinct relation types \(R\).

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

1D int32 array of shape (N + 1,) indexing incoming edges per target vertex.

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

1D int32 array of shape (E,) of source vertex indices.

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

1D int32 array of shape (E,) of relation type IDs in [0, R - 1].

edge_weights ndarray[tuple[int], dtype[float32]]

1D float32 array of shape (E,) of per-edge aggregation weights.

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

1D int32 array of virtual global pool vertex indices (empty if none).

Source code in src/serogen/core/graph/gnn.py
@dataclass(slots=True, frozen=True)
class RelationalCSR:
    r"""Compressed Sparse Row (CSR) incoming-edge representation of a multi-relational directed graph.

    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 = src_indices[k]`, relation ID `r_k = rel_types[k]`, and normalized aggregation weight
    `edge_weights[k]`.

    Attributes:
        n_nodes: Total number of vertices $N$ (including any appended virtual pool vertices).
        n_relations: Total number of distinct relation types $R$.
        indptr: 1D `int32` array of shape `(N + 1,)` indexing incoming edges per target vertex.
        src_indices: 1D `int32` array of shape `(E,)` of source vertex indices.
        rel_types: 1D `int32` array of shape `(E,)` of relation type IDs in `[0, R - 1]`.
        edge_weights: 1D `float32` array of shape `(E,)` of per-edge aggregation weights.
        virtual_nodes: 1D `int32` array of virtual global pool vertex indices (empty if none).
    """

    n_nodes: int
    n_relations: int
    indptr: np.ndarray[tuple[int], np.dtype[np.int32]]
    src_indices: np.ndarray[tuple[int], np.dtype[np.int32]]
    rel_types: np.ndarray[tuple[int], np.dtype[np.int32]]
    edge_weights: np.ndarray[tuple[int], np.dtype[np.float32]]
    virtual_nodes: np.ndarray[tuple[int], np.dtype[np.int32]]

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 maximum_spanning_arborescence.

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/serogen/core/graph/gnn.py
 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
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
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
           [`maximum_spanning_arborescence`][serogen.core.graph.arborescence.maximum_spanning_arborescence].
    """

    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)

        # Stack of L relational MPNN layers: (L, R, D, D) and (L, D)
        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)

        # Node classification head
        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)

        # Factorized bilinear edge scoring head
        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)

        # AdamW moment buffers
        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_)

    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,
        *,
        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)`."""
        x_full, c_mask = self._prepare_features(x, csr, clamped_mask)
        h0 = x_full @ self.w_in_ + self.b_in_[None, :]
        h = np.ascontiguousarray(h0, dtype=np.float32)
        for lay in range(self.num_layers):
            h, _, _ = mpnn_layer_forward(
                h,
                csr.indptr,
                csr.src_indices,
                csr.rel_types,
                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,
        *,
        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,
        *,
        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)
        )
        scores, _, _ = bilinear_edge_scores_forward(
            h_sub,
            self.w_edge_src_,
            self.w_edge_dst_,
            self.w_edge_chan_,
            self.b_edge_chan_,
        )
        return scores

    def fit_step(
        self,
        x: np.ndarray,
        csr: RelationalCSR,
        *,
        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`][serogen.core.graph.gnn.RelationalCSR] 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.
        """
        x_full, c_mask = self._prepare_features(x, csr, clamped_mask)
        n_total = 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,
                csr.indptr,
                csr.src_indices,
                csr.rel_types,
                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)
                scores, u_proj, v_proj = bilinear_edge_scores_forward(
                    h_sub,
                    self.w_edge_src_,
                    self.w_edge_dst_,
                    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
                        # Softmax over all candidate (parent u != v_loc, channel c) pairs for child v_loc
                        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_h_sub, d_w_esrc, d_w_edst, d_w_echan, d_b_echan = bilinear_edge_scores_backward(
                        d_scores,
                        h_sub,
                        u_proj,
                        v_proj,
                        self.w_edge_src_,
                        self.w_edge_dst_,
                        self.w_edge_chan_,
                    )
                    d_h_final[e_idx] += d_h_sub

        # 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)

        for lay in range(self.num_layers - 1, -1, -1):
            # Account for clamped anchor gradient flowing directly to h0
            if np.any(c_mask != 0):
                inv_rms = rms_invs[lay]
                pn = pre_norms[lay]
                inv_d = np.float32(1.0 / max(self.hidden_dim, 1))
                for v in range(n_total):
                    if c_mask[v] != 0:
                        ir = inv_rms[v]
                        ir3 = ir * ir * ir * inv_d
                        dot_val = np.float32(np.sum(d_h_curr[v] * pn[v]))
                        d_h0_accum[v] += np.float32(0.5) * (d_h_curr[v] * ir - pn[v] * ir3 * dot_val)

            d_h_curr, dw_l, db_l = mpnn_layer_backward(
                d_h_curr,
                h_inputs[lay],
                pre_norms[lay],
                rms_invs[lay],
                csr.indptr,
                csr.src_indices,
                csr.rel_types,
                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_2d(self.w_in_, d_w_in, self._m_w_in, self._v_w_in, step_size, wd_factor)
        _adamw_step_1d(self.b_in_, d_b_in, self._m_b_in, self._v_b_in, step_size, wd_factor, False)

        for lay in range(self.num_layers):
            _adamw_step_3d(
                self.w_rel_[lay],
                d_w_rel_all[lay],
                self._m_w_rel[lay],
                self._v_w_rel[lay],
                step_size,
                wd_factor,
            )
            _adamw_step_1d(
                self.b_rel_[lay],
                d_b_rel_all[lay],
                self._m_b_rel[lay],
                self._v_b_rel[lay],
                step_size,
                wd_factor,
                False,
            )

        if node_targets is not None:
            _adamw_step_2d(self.w_node_, d_w_node, self._m_w_node, self._v_w_node, step_size, wd_factor)
            _adamw_step_1d(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_2d(self.w_edge_src_, d_w_esrc, self._m_w_esrc, self._v_w_esrc, step_size, wd_factor)
            _adamw_step_2d(self.w_edge_dst_, d_w_edst, self._m_w_edst, self._v_w_edst, step_size, wd_factor)
            _adamw_step_2d(self.w_edge_chan_, d_w_echan, self._m_w_echan, self._v_w_echan, step_size, wd_factor)
            _adamw_step_1d(
                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`][serogen.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, *, 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/serogen/core/graph/gnn.py
def encode(
    self,
    x: np.ndarray,
    csr: RelationalCSR,
    *,
    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)`."""
    x_full, c_mask = self._prepare_features(x, csr, clamped_mask)
    h0 = x_full @ self.w_in_ + self.b_in_[None, :]
    h = np.ascontiguousarray(h0, dtype=np.float32)
    for lay in range(self.num_layers):
        h, _, _ = mpnn_layer_forward(
            h,
            csr.indptr,
            csr.src_indices,
            csr.rel_types,
            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, *, 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/serogen/core/graph/gnn.py
def predict_node_logits(
    self,
    x: np.ndarray,
    csr: RelationalCSR,
    *,
    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, *, clamped_mask: ndarray | None = None) -> ndarray[tuple[int, int], dtype[float32]]

Compute softmax node class probabilities (N, num_node_classes).

Source code in src/serogen/core/graph/gnn.py
def predict_node_proba(
    self,
    x: np.ndarray,
    csr: RelationalCSR,
    *,
    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/serogen/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)
    )
    scores, _, _ = bilinear_edge_scores_forward(
        h_sub,
        self.w_edge_src_,
        self.w_edge_dst_,
        self.w_edge_chan_,
        self.b_edge_chan_,
    )
    return scores

fit_step

fit_step(x: ndarray, csr: RelationalCSR, *, 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

RelationalCSR 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/serogen/core/graph/gnn.py
def fit_step(
    self,
    x: np.ndarray,
    csr: RelationalCSR,
    *,
    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`][serogen.core.graph.gnn.RelationalCSR] 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.
    """
    x_full, c_mask = self._prepare_features(x, csr, clamped_mask)
    n_total = 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,
            csr.indptr,
            csr.src_indices,
            csr.rel_types,
            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)
            scores, u_proj, v_proj = bilinear_edge_scores_forward(
                h_sub,
                self.w_edge_src_,
                self.w_edge_dst_,
                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
                    # Softmax over all candidate (parent u != v_loc, channel c) pairs for child v_loc
                    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_h_sub, d_w_esrc, d_w_edst, d_w_echan, d_b_echan = bilinear_edge_scores_backward(
                    d_scores,
                    h_sub,
                    u_proj,
                    v_proj,
                    self.w_edge_src_,
                    self.w_edge_dst_,
                    self.w_edge_chan_,
                )
                d_h_final[e_idx] += d_h_sub

    # 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)

    for lay in range(self.num_layers - 1, -1, -1):
        # Account for clamped anchor gradient flowing directly to h0
        if np.any(c_mask != 0):
            inv_rms = rms_invs[lay]
            pn = pre_norms[lay]
            inv_d = np.float32(1.0 / max(self.hidden_dim, 1))
            for v in range(n_total):
                if c_mask[v] != 0:
                    ir = inv_rms[v]
                    ir3 = ir * ir * ir * inv_d
                    dot_val = np.float32(np.sum(d_h_curr[v] * pn[v]))
                    d_h0_accum[v] += np.float32(0.5) * (d_h_curr[v] * ir - pn[v] * ir3 * dot_val)

        d_h_curr, dw_l, db_l = mpnn_layer_backward(
            d_h_curr,
            h_inputs[lay],
            pre_norms[lay],
            rms_invs[lay],
            csr.indptr,
            csr.src_indices,
            csr.rel_types,
            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_2d(self.w_in_, d_w_in, self._m_w_in, self._v_w_in, step_size, wd_factor)
    _adamw_step_1d(self.b_in_, d_b_in, self._m_b_in, self._v_b_in, step_size, wd_factor, False)

    for lay in range(self.num_layers):
        _adamw_step_3d(
            self.w_rel_[lay],
            d_w_rel_all[lay],
            self._m_w_rel[lay],
            self._v_w_rel[lay],
            step_size,
            wd_factor,
        )
        _adamw_step_1d(
            self.b_rel_[lay],
            d_b_rel_all[lay],
            self._m_b_rel[lay],
            self._v_b_rel[lay],
            step_size,
            wd_factor,
            False,
        )

    if node_targets is not None:
        _adamw_step_2d(self.w_node_, d_w_node, self._m_w_node, self._v_w_node, step_size, wd_factor)
        _adamw_step_1d(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_2d(self.w_edge_src_, d_w_esrc, self._m_w_esrc, self._v_w_esrc, step_size, wd_factor)
        _adamw_step_2d(self.w_edge_dst_, d_w_edst, self._m_w_edst, self._v_w_edst, step_size, wd_factor)
        _adamw_step_2d(self.w_edge_chan_, d_w_echan, self._m_w_echan, self._v_w_echan, step_size, wd_factor)
        _adamw_step_1d(
            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/serogen/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/serogen/core/graph/gnn.py
@classmethod
def load(cls, path: Path | str) -> Self:
    r"""Load a [`RelationalMPNN`][serogen.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

build_relational_csr

build_relational_csr(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) -> RelationalCSR

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 RelationalCSR

Incoming-edge CSR structure ready for Numba MPNN kernels.

Source code in src/serogen/core/graph/gnn.py
def build_relational_csr(
    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,
) -> RelationalCSR:
    r"""Construct a [`RelationalCSR`][serogen.core.graph.gnn.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 CSR structure ready for Numba MPNN kernels.
    """
    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])

    if n_relations is None:
        n_rel = int(np.max(rel_arr)) + 1 if len(rel_arr) > 0 else 1
    else:
        n_rel = 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 RelationalCSR(
            n_nodes=n_nodes,
            n_relations=n_rel,
            indptr=np.zeros(n_nodes + 1, dtype=np.int32),
            src_indices=np.empty(0, dtype=np.int32),
            rel_types=np.empty(0, dtype=np.int32),
            edge_weights=np.empty(0, dtype=np.float32),
            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)
        uniq_keys, inv, key_counts = np.unique(key, return_inverse=True, return_counts=True)
        _ = uniq_keys
        weights = (1.0 / np.maximum(key_counts[inv], 1)).astype(np.float32)

    return RelationalCSR(
        n_nodes=n_nodes,
        n_relations=n_rel,
        indptr=indptr,
        src_indices=src_sorted,
        rel_types=rel_sorted,
        edge_weights=np.ascontiguousarray(weights, dtype=np.float32),
        virtual_nodes=v_nodes,
    )

build_multihop_csr

build_multihop_csr(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) -> RelationalCSR

Build a multi-hop relational CSR graph for a single graph or a ragged batch of graphs.

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)

Parameters:

Name Type Description Default
node_offsets Sequence[int] | ndarray

1D array of graph node offsets [0, n_0, n_0 + n_1, ..., N_real].

required
edge_sources Sequence[int] | ndarray

1D array of 0-based global source vertex indices in [0, N_real - 1].

required
edge_targets Sequence[int] | ndarray

1D array of 0-based global target vertex indices in [0, N_real - 1].

required
edge_orientations Sequence[int] | ndarray | None

Optional 1D array of orientation codes (0 = codirectional, >0 = conv/div).

None
include_2hop bool

If True (default), adds 2-hop forward (r=4) and reverse (r=5) edges.

True
add_virtual_pool bool

If True (default), appends 1 virtual global pool node per graph at indices [N_real, ..., N_real + G - 1] connected via relations 6 and 7.

True

Returns:

Name Type Description
RelationalCSR RelationalCSR

Multi-hop relational CSR with n_relations = 8.

Source code in src/serogen/core/graph/gnn.py
def build_multihop_csr(
    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,
) -> RelationalCSR:
    r"""Build a multi-hop relational CSR graph for a single graph or a ragged batch of graphs.

    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`)

    Args:
        node_offsets: 1D array of graph node offsets `[0, n_0, n_0 + n_1, ..., N_real]`.
        edge_sources: 1D array of 0-based global source vertex indices in `[0, N_real - 1]`.
        edge_targets: 1D array of 0-based global target vertex indices in `[0, N_real - 1]`.
        edge_orientations: Optional 1D array of orientation codes (`0` = codirectional, `>0` = conv/div).
        include_2hop: If `True` (default), adds 2-hop forward (`r=4`) and reverse (`r=5`) edges.
        add_virtual_pool: If `True` (default), appends 1 virtual global pool node per graph at
            indices `[N_real, ..., N_real + G - 1]` connected via relations `6` and `7`.

    Returns:
        RelationalCSR: Multi-hop relational CSR with `n_relations = 8`.
    """
    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:
            # Find 2-hop paths u -> w -> v
            adj_out: list[list[int]] = [[] for _ in range(n_real)]
            for u_i, v_i in zip(e_src, e_dst, strict=False):
                adj_out[int(u_i)].append(int(v_i))
            hop2_u: list[int] = []
            hop2_v: list[int] = []
            for u_i in range(n_real):
                for w_i in adj_out[u_i]:
                    for v_i in adj_out[w_i]:
                        if v_i != u_i:
                            hop2_u.append(u_i)
                            hop2_v.append(v_i)
            if hop2_u:
                h2u = np.array(hop2_u, dtype=np.int32)
                h2v = np.array(hop2_v, dtype=np.int32)
                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
        vp_src: list[np.ndarray] = []
        vp_dst: list[np.ndarray] = []
        vp_rel: list[np.ndarray] = []
        for g in range(n_graphs):
            s = int(offsets[g])
            e = int(offsets[g + 1])
            if e > s:
                nodes_g = np.arange(s, e, dtype=np.int32)
                v_node = np.full(e - s, n_real + g, dtype=np.int32)
                vp_src.extend([nodes_g, v_node])
                vp_dst.extend([v_node, nodes_g])
                vp_rel.extend([np.full(e - s, 6, dtype=np.int32), np.full(e - s, 7, dtype=np.int32)])
        if vp_src:
            src_list.extend(vp_src)
            dst_list.extend(vp_dst)
            rel_list.extend(vp_rel)
    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 build_relational_csr(
        total_nodes,
        all_src,
        all_dst,
        all_rel,
        n_relations=8,
        add_self_loops=True,
        normalize_by_degree=True,
        virtual_nodes=virtual_nodes,
    )

mpnn_layer_forward

mpnn_layer_forward(h_in: ndarray, indptr: ndarray, src_indices: ndarray, rel_types: ndarray, edge_weights: ndarray, w_rel: ndarray, b_rel: ndarray, clamped_mask: ndarray, clamped_anchor: ndarray, norm_eps: float = 1e-05) -> tuple[ndarray, ndarray, ndarray]

Parallel relational message-passing forward kernel with residual connection, ReLU, and RMSNorm.

Returns:

Type Description
tuple[ndarray, ndarray, ndarray]

tuple[np.ndarray, np.ndarray, np.ndarray]: (h_out, pre_norm, rms_inv) for backward pass.

Source code in src/serogen/core/graph/gnn.py
@njit(cache=True, nogil=True, parallel=True, fastmath=True)
def mpnn_layer_forward(
    h_in: np.ndarray,  # (N, D)
    indptr: np.ndarray,  # (N + 1,)
    src_indices: np.ndarray,  # (E,)
    rel_types: np.ndarray,  # (E,)
    edge_weights: np.ndarray,  # (E,)
    w_rel: np.ndarray,  # (R, D, D)
    b_rel: np.ndarray,  # (D,)
    clamped_mask: np.ndarray,  # (N,) uint8
    clamped_anchor: np.ndarray,  # (N, D)
    norm_eps: float = 1e-5,
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
    r"""Parallel relational message-passing forward kernel with residual connection, ReLU, and RMSNorm.

    Returns:
        tuple[np.ndarray, np.ndarray, np.ndarray]: `(h_out, pre_norm, rms_inv)` for backward pass.
    """
    n_nodes, dim = h_in.shape
    h_out = np.empty((n_nodes, dim), dtype=np.float32)
    pre_norm = np.empty((n_nodes, dim), dtype=np.float32)
    rms_inv = np.empty(n_nodes, dtype=np.float32)
    eps = np.float32(norm_eps)
    inv_d = np.float32(1.0 / max(dim, 1))

    for v in prange(n_nodes):
        msg = np.empty(dim, dtype=np.float32)
        for d in range(dim):
            msg[d] = b_rel[d]

        start = int(indptr[v])
        end = int(indptr[v + 1])
        for k in range(start, end):
            u = int(src_indices[k])
            r = int(rel_types[k])
            w_e = edge_weights[k]
            for d_in in range(dim):
                hu_val = w_e * h_in[u, d_in]
                for d_out in range(dim):
                    msg[d_out] += hu_val * w_rel[r, d_in, d_out]

        # ReLU activation on aggregated relational message + residual connection
        sq_sum = np.float32(0.0)
        is_clamped = clamped_mask[v] != 0
        for d in range(dim):
            act = msg[d] if msg[d] > np.float32(0.0) else np.float32(0.0)
            res = h_in[v, d] + act
            if is_clamped:
                res = np.float32(0.5) * res + np.float32(0.5) * clamped_anchor[v, d]
            pre_norm[v, d] = res
            sq_sum += res * res

        inv_rms = np.float32(1.0) / np.sqrt(sq_sum * inv_d + eps)
        rms_inv[v] = inv_rms
        for d in range(dim):
            h_out[v, d] = pre_norm[v, d] * inv_rms

    return h_out, pre_norm, rms_inv

mpnn_layer_backward

mpnn_layer_backward(d_h_out: ndarray, h_in: ndarray, pre_norm: ndarray, rms_inv: ndarray, indptr: ndarray, src_indices: ndarray, rel_types: ndarray, edge_weights: ndarray, w_rel: ndarray, clamped_mask: ndarray) -> tuple[ndarray, ndarray, ndarray]

Backward pass through RMSNorm, clamped residual anchor, ReLU, and CSR relational aggregation.

Source code in src/serogen/core/graph/gnn.py
@njit(cache=True, nogil=True, fastmath=True)
def mpnn_layer_backward(
    d_h_out: np.ndarray,  # (N, D)
    h_in: np.ndarray,  # (N, D)
    pre_norm: np.ndarray,  # (N, D)
    rms_inv: np.ndarray,  # (N,)
    indptr: np.ndarray,  # (N + 1,)
    src_indices: np.ndarray,  # (E,)
    rel_types: np.ndarray,  # (E,)
    edge_weights: np.ndarray,  # (E,)
    w_rel: np.ndarray,  # (R, D, D)
    clamped_mask: np.ndarray,  # (N,) uint8
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
    r"""Backward pass through RMSNorm, clamped residual anchor, ReLU, and CSR relational aggregation."""
    n_nodes, dim = h_in.shape
    n_rel = w_rel.shape[0]
    d_h_in = np.zeros((n_nodes, dim), dtype=np.float32)
    d_w_rel = np.zeros((n_rel, dim, dim), dtype=np.float32)
    d_b_rel = np.zeros(dim, dtype=np.float32)
    inv_d = np.float32(1.0 / max(dim, 1))

    for v in range(n_nodes):
        inv_rms = rms_inv[v]
        inv_rms3 = inv_rms * inv_rms * inv_rms * inv_d

        # RMSNorm backward: y = x * inv_rms => dx = dy * inv_rms - x * inv_rms^3 * (sum_j dy_j * x_j)
        dot_dy_x = np.float32(0.0)
        for d in range(dim):
            dot_dy_x += d_h_out[v, d] * pre_norm[v, d]

        d_res = np.empty(dim, dtype=np.float32)
        is_clamped = clamped_mask[v] != 0
        scale_c = np.float32(0.5) if is_clamped else np.float32(1.0)
        for d in range(dim):
            dr = (d_h_out[v, d] * inv_rms - pre_norm[v, d] * inv_rms3 * dot_dy_x) * scale_c
            d_res[d] = dr
            d_h_in[v, d] += dr

        # Reconstruct pre-ReLU check: act = (pre_norm / scale_c) - h_in > 0
        d_msg = np.empty(dim, dtype=np.float32)
        any_active = False
        for d in range(dim):
            raw_res = (np.float32(2.0) * pre_norm[v, d]) if is_clamped else pre_norm[v, d]
            act = raw_res - h_in[v, d]
            if act > np.float32(1e-7):
                d_msg[d] = d_res[d]
                d_b_rel[d] += d_res[d]
                any_active = True
            else:
                d_msg[d] = np.float32(0.0)

        if not any_active:
            continue

        start = int(indptr[v])
        end = int(indptr[v + 1])
        for k in range(start, end):
            u = int(src_indices[k])
            r = int(rel_types[k])
            w_e = edge_weights[k]
            for d_in in range(dim):
                hu_w = w_e * h_in[u, d_in]
                grad_u = np.float32(0.0)
                for d_out in range(dim):
                    dm = d_msg[d_out]
                    if dm != np.float32(0.0):
                        d_w_rel[r, d_in, d_out] += hu_w * dm
                        grad_u += w_rel[r, d_in, d_out] * dm
                d_h_in[u, d_in] += w_e * grad_u

    return d_h_in, d_w_rel, d_b_rel

bilinear_edge_scores_forward

bilinear_edge_scores_forward(h_nodes: ndarray, w_src: ndarray, w_dst: ndarray, w_chan: ndarray, b_chan: ndarray) -> tuple[ndarray, ndarray, ndarray]

Compute factorized bilinear multi-channel edge logits S[u, v, c] = sum_k (h_u W_src)_k (h_v W_dst)_k W_chan[c, k] + b_c.

Source code in src/serogen/core/graph/gnn.py
@njit(cache=True, nogil=True, parallel=True, fastmath=True)
def bilinear_edge_scores_forward(
    h_nodes: np.ndarray,  # (M, D)
    w_src: np.ndarray,  # (D, K)
    w_dst: np.ndarray,  # (D, K)
    w_chan: np.ndarray,  # (C, K)
    b_chan: np.ndarray,  # (C,)
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
    r"""Compute factorized bilinear multi-channel edge logits `S[u, v, c] = sum_k (h_u W_src)_k (h_v W_dst)_k W_chan[c, k] + b_c`."""
    m_nodes, dim = h_nodes.shape
    n_chan, rank_k = w_chan.shape
    u_proj = np.zeros((m_nodes, rank_k), dtype=np.float32)
    v_proj = np.zeros((m_nodes, rank_k), dtype=np.float32)

    for i in prange(m_nodes):
        for d in range(dim):
            hi = h_nodes[i, d]
            for k in range(rank_k):
                u_proj[i, k] += hi * w_src[d, k]
                v_proj[i, k] += hi * w_dst[d, k]

    scores = np.empty((m_nodes, m_nodes, n_chan), dtype=np.float32)
    inv_sqrt_k = np.float32(1.0 / np.sqrt(float(max(rank_k, 1))))

    for u in prange(m_nodes):
        for v in range(m_nodes):
            if u == v:
                for c in range(n_chan):
                    scores[u, v, c] = np.float32(-1e9)
                continue
            for c in range(n_chan):
                s = b_chan[c]
                for k in range(rank_k):
                    s += u_proj[u, k] * v_proj[v, k] * w_chan[c, k] * inv_sqrt_k
                scores[u, v, c] = s

    return scores, u_proj, v_proj

bilinear_edge_scores_backward

bilinear_edge_scores_backward(d_scores: ndarray, h_nodes: ndarray, u_proj: ndarray, v_proj: ndarray, w_src: ndarray, w_dst: ndarray, w_chan: ndarray) -> tuple[ndarray, ndarray, ndarray, ndarray, ndarray]

Backward pass for factorized bilinear multi-channel edge scorer.

Source code in src/serogen/core/graph/gnn.py
@njit(cache=True, nogil=True, fastmath=True)
def bilinear_edge_scores_backward(
    d_scores: np.ndarray,  # (M, M, C)
    h_nodes: np.ndarray,  # (M, D)
    u_proj: np.ndarray,  # (M, K)
    v_proj: np.ndarray,  # (M, K)
    w_src: np.ndarray,  # (D, K)
    w_dst: np.ndarray,  # (D, K)
    w_chan: np.ndarray,  # (C, K)
) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
    r"""Backward pass for factorized bilinear multi-channel edge scorer."""
    m_nodes, dim = h_nodes.shape
    n_chan, rank_k = w_chan.shape
    inv_sqrt_k = np.float32(1.0 / np.sqrt(float(max(rank_k, 1))))

    d_u_proj = np.zeros((m_nodes, rank_k), dtype=np.float32)
    d_v_proj = np.zeros((m_nodes, rank_k), dtype=np.float32)
    d_w_chan = np.zeros((n_chan, rank_k), dtype=np.float32)
    d_b_chan = np.zeros(n_chan, dtype=np.float32)

    for u in range(m_nodes):
        for v in range(m_nodes):
            if u == v:
                continue
            for c in range(n_chan):
                ds = d_scores[u, v, c]
                if ds == np.float32(0.0):
                    continue
                d_b_chan[c] += ds
                ds_scaled = ds * inv_sqrt_k
                for k in range(rank_k):
                    uv_k = u_proj[u, k] * v_proj[v, k]
                    d_w_chan[c, k] += ds_scaled * uv_k
                    wc = w_chan[c, k] * ds_scaled
                    d_u_proj[u, k] += wc * v_proj[v, k]
                    d_v_proj[v, k] += wc * u_proj[u, k]

    d_h_nodes = np.zeros((m_nodes, dim), dtype=np.float32)
    d_w_src = np.zeros((dim, rank_k), dtype=np.float32)
    d_w_dst = np.zeros((dim, rank_k), dtype=np.float32)

    for i in range(m_nodes):
        for d in range(dim):
            hi = h_nodes[i, d]
            grad_hi = np.float32(0.0)
            for k in range(rank_k):
                du = d_u_proj[i, k]
                dv = d_v_proj[i, k]
                d_w_src[d, k] += hi * du
                d_w_dst[d, k] += hi * dv
                grad_hi += w_src[d, k] * du + w_dst[d, k] * dv
            d_h_nodes[i, d] = grad_hi

    return d_h_nodes, d_w_src, d_w_dst, d_w_chan, d_b_chan

sinkhorn_bipartite_matching

sinkhorn_bipartite_matching(log_alpha: ndarray, n_iters: int = 20, tau: float = 1.0) -> ndarray

Differentiable log-space Sinkhorn-Knopp operator producing a doubly stochastic soft assignment matrix.

Parameters:

Name Type Description Default
log_alpha ndarray

2D array (M, K) of unnormalized affinity logits between \(M\) sources and \(K\) targets.

required
n_iters int

Number of alternating log-row and log-column normalization iterations. Defaults to 20.

20
tau float

Entropic temperature \(\tau > 0\). Smaller \(\tau\) approaches a hard permutation matrix.

1.0

Returns:

Type Description
ndarray

np.ndarray: 2D float32 soft assignment matrix P of shape (M, K).

Source code in src/serogen/core/graph/gnn.py
@njit(cache=True, nogil=True, fastmath=True)
def sinkhorn_bipartite_matching(
    log_alpha: np.ndarray,
    n_iters: int = 20,
    tau: float = 1.0,
) -> np.ndarray:
    r"""Differentiable log-space Sinkhorn-Knopp operator producing a doubly stochastic soft assignment matrix.

    Args:
        log_alpha: 2D array `(M, K)` of unnormalized affinity logits between $M$ sources and $K$ targets.
        n_iters: Number of alternating log-row and log-column normalization iterations. Defaults to `20`.
        tau: Entropic temperature $\tau > 0$. Smaller $\tau$ approaches a hard permutation matrix.

    Returns:
        np.ndarray: 2D `float32` soft assignment matrix `P` of shape `(M, K)`.
    """
    m, k = log_alpha.shape
    if m == 0 or k == 0:
        return np.empty((m, k), dtype=np.float32)

    inv_tau = np.float32(1.0 / max(float(tau), 1e-4))
    log_p = np.empty((m, k), dtype=np.float32)
    for i in range(m):
        for j in range(k):
            log_p[i, j] = np.float32(log_alpha[i, j]) * inv_tau

    for _ in range(n_iters):
        # Row log-sum-exp normalization
        for i in range(m):
            max_r = np.float32(-1e30)
            for j in range(k):
                if log_p[i, j] > max_r:
                    max_r = log_p[i, j]
            s = np.float32(0.0)
            for j in range(k):
                s += np.exp(log_p[i, j] - max_r)
            lse = max_r + np.log(s + np.float32(1e-12))
            for j in range(k):
                log_p[i, j] -= lse

        # Column log-sum-exp normalization
        for j in range(k):
            max_c = np.float32(-1e30)
            for i in range(m):
                if log_p[i, j] > max_c:
                    max_c = log_p[i, j]
            s = np.float32(0.0)
            for i in range(m):
                s += np.exp(log_p[i, j] - max_c)
            lse = max_c + np.log(s + np.float32(1e-12))
            for i in range(m):
                log_p[i, j] -= lse

    out = np.empty((m, k), dtype=np.float32)
    for i in range(m):
        for j in range(k):
            out[i, j] = np.exp(log_p[i, j])
    return out

arborescence

Directed spanning arborescence solvers (Chu-Liu/Edmonds & Constrained \(K\)-Best Branch-and-Bound).

Provides compiled Numba algorithms for finding maximum/minimum weight directed spanning trees (arborescences) rooted at a target node, with optional slot/valence exclusivity constraints and \(k\)-best arborescence enumeration:

Classes:

Name Description
ArborescenceResult

Result of a directed spanning arborescence optimization.

Functions:

Name Description
maximum_spanning_arborescence

Find the maximum-weight directed spanning arborescence rooted at root.

minimum_spanning_arborescence

Find the minimum-cost directed spanning arborescence rooted at root.

k_best_arborescences

Enumerate the top-k highest-scoring valid directed spanning arborescences in descending order.

ArborescenceResult dataclass

Result of a directed spanning arborescence optimization.

Supports both standard 2-tuple unpacking (parents, score = maximum_spanning_arborescence(...)) and rich attribute access (.parents, .score, .slots, .edges).

Attributes:

Name Type Description
parents ndarray[tuple[int], dtype[int32]]

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 ndarray[tuple[int], dtype[int32]]

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).

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

2D int32 array of shape (M, 3) containing (parent_u, child_v, slot_s) for each directed edge in the arborescence.

Source code in src/serogen/core/graph/arborescence.py
@dataclass(slots=True, frozen=True)
class ArborescenceResult:
    r"""Result of a directed spanning arborescence optimization.

    Supports both standard 2-tuple unpacking (`parents, score = maximum_spanning_arborescence(...)`)
    and rich attribute access (`.parents`, `.score`, `.slots`, `.edges`).

    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`).
        edges: 2D `int32` array of shape `(M, 3)` containing `(parent_u, child_v, slot_s)` for each
            directed edge in the arborescence.
    """

    parents: np.ndarray[tuple[int], np.dtype[np.int32]]
    score: float
    slots: np.ndarray[tuple[int], np.dtype[np.int32]]
    edges: np.ndarray[tuple[int, int], np.dtype[np.int32]]

    def __iter__(self) -> Iterator[Any]:
        r"""Unpack as `(parents, score)` for tuple assignment compatibility."""
        yield self.parents
        yield self.score

    def __len__(self) -> int:
        return 2

    def __getitem__(self, item: int) -> Any:
        if item == 0:
            return self.parents
        if item == 1:
            return self.score
        if item == 2:
            return self.slots
        raise IndexError(f"ArborescenceResult index {item} out of range [0, 2].")

maximum_spanning_arborescence

maximum_spanning_arborescence(weights: ndarray, root: int = 0, *, edge_groups: ndarray | None = None, required_nodes: ndarray | None = None, slot_exclusivity: bool = True) -> ArborescenceResult

Find the maximum-weight directed spanning arborescence rooted at root.

Uses Chu-Liu / Edmonds' optimum branching algorithm in \(O(N^2)\) time when unconstrained, and automatically switches to compiled Numba branch-and-bound when slot/valence exclusivity constraints (edge_groups or 3D (N, N, S) tensors with slot_exclusivity=True) are active.

Parameters:

Name Type Description Default
weights ndarray

2D array (N, N) where weights[u, v] is the score of directed edge u -> v, or 3D array (N, N, S) where weights[u, v, s] is the score of u -> v via slot s.

required
root int

Index of the root vertex (0 <= root < N). Defaults to 0.

0
edge_groups ndarray | None

Optional integer array of shape matching weights assigning exclusivity group IDs (>= 0). At most one edge from each non-negative group ID may be selected. (-1 = unconstrained).

None
required_nodes ndarray | None

Optional boolean mask (N,) or 1D index array specifying which vertices must be spanned by the arborescence. Defaults to all N vertices.

None
slot_exclusivity bool

If True (default) and weights is 3D (N, N, S) with edge_groups=None, enforces that at most one outgoing edge from parent u may use slot s.

True

Returns:

Name Type Description
ArborescenceResult ArborescenceResult

Result object supporting both parents, score = maximum_spanning_arborescence(...)

ArborescenceResult

unpacking and .parents, .slots, .edges, .score attribute access.

Source code in src/serogen/core/graph/arborescence.py
def maximum_spanning_arborescence(
    weights: np.ndarray,
    root: int = 0,
    *,
    edge_groups: np.ndarray | None = None,
    required_nodes: np.ndarray | None = None,
    slot_exclusivity: bool = True,
) -> ArborescenceResult:
    r"""Find the maximum-weight directed spanning arborescence rooted at `root`.

    Uses Chu-Liu / Edmonds' optimum branching algorithm in $O(N^2)$ time when unconstrained,
    and automatically switches to compiled Numba branch-and-bound when slot/valence
    exclusivity constraints (`edge_groups` or 3D `(N, N, S)` tensors with `slot_exclusivity=True`)
    are active.

    Args:
        weights: 2D array `(N, N)` where `weights[u, v]` is the score of directed edge `u -> v`,
            or 3D array `(N, N, S)` where `weights[u, v, s]` is the score of `u -> v` via slot `s`.
        root: Index of the root vertex (`0 <= root < N`). Defaults to `0`.
        edge_groups: Optional integer array of shape matching `weights` assigning exclusivity group IDs
            (`>= 0`). At most one edge from each non-negative group ID may be selected. (`-1` = unconstrained).
        required_nodes: Optional boolean mask `(N,)` or 1D index array specifying which vertices
            must be spanned by the arborescence. Defaults to all `N` vertices.
        slot_exclusivity: If `True` (default) and `weights` is 3D `(N, N, S)` with `edge_groups=None`,
            enforces that at most one outgoing edge from parent `u` may use slot `s`.

    Returns:
        ArborescenceResult: Result object supporting both `parents, score = maximum_spanning_arborescence(...)`
        unpacking and `.parents`, `.slots`, `.edges`, `.score` attribute access.
    """
    w_3d, g_3d, active_mask, has_constraints = _normalize_arborescence_inputs(
        weights, root, edge_groups, required_nodes, slot_exclusivity
    )
    n, _, _ = w_3d.shape
    if n == 0:
        return _build_result(np.empty(0, dtype=np.int32), np.empty(0, dtype=np.int32), 0.0, 0)
    if n == 1:
        return _build_result(np.array([-1], dtype=np.int32), np.array([-1], dtype=np.int32), 0.0, 0)

    if not has_constraints and bool(np.all(active_mask != 0)):
        best_slots = np.argmax(w_3d, axis=2).astype(np.int32)
        w_2d = np.max(w_3d, axis=2)
        parents, score = _edmonds_msa_2d(w_2d, int(root))
        slots = np.full(n, -1, dtype=np.int32)
        for v in range(n):
            if v != root and parents[v] >= 0:
                slots[v] = best_slots[parents[v], v]
        return _build_result(parents, slots, score, root)

    out_p, out_s, out_sc, found = _branch_and_bound_k_best(w_3d, g_3d, active_mask, int(root), 1)
    if found == 0:
        return _build_result(np.full(n, -1, dtype=np.int32), np.full(n, -1, dtype=np.int32), float(_NEG_INF), root)
    return _build_result(out_p[0], out_s[0], float(out_sc[0]), root)

minimum_spanning_arborescence

minimum_spanning_arborescence(costs: ndarray, root: int = 0, *, edge_groups: ndarray | None = None, required_nodes: ndarray | None = None, slot_exclusivity: bool = True) -> ArborescenceResult

Find the minimum-cost directed spanning arborescence rooted at root.

Parameters:

Name Type Description Default
costs ndarray

2D (N, N) or 3D (N, N, S) array of non-negative/real edge costs (use np.inf for forbidden edges).

required
root int

Index of the root vertex (0 <= root < N). Defaults to 0.

0
edge_groups ndarray | None

Optional exclusivity group IDs (-1 = unconstrained, >= 0 = at most one edge per group).

None
required_nodes ndarray | None

Optional boolean mask or index array of vertices to span.

None
slot_exclusivity bool

Enforce (parent, slot) exclusivity on 3D inputs when True.

True

Returns:

Name Type Description
ArborescenceResult ArborescenceResult

Minimum-cost arborescence (parents, min_cost = minimum_spanning_arborescence(...)).

Source code in src/serogen/core/graph/arborescence.py
def minimum_spanning_arborescence(
    costs: np.ndarray,
    root: int = 0,
    *,
    edge_groups: np.ndarray | None = None,
    required_nodes: np.ndarray | None = None,
    slot_exclusivity: bool = True,
) -> ArborescenceResult:
    r"""Find the minimum-cost directed spanning arborescence rooted at `root`.

    Args:
        costs: 2D `(N, N)` or 3D `(N, N, S)` array of non-negative/real edge costs (use `np.inf` for
            forbidden edges).
        root: Index of the root vertex (`0 <= root < N`). Defaults to `0`.
        edge_groups: Optional exclusivity group IDs (`-1` = unconstrained, `>= 0` = at most one edge per group).
        required_nodes: Optional boolean mask or index array of vertices to span.
        slot_exclusivity: Enforce `(parent, slot)` exclusivity on 3D inputs when `True`.

    Returns:
        ArborescenceResult: Minimum-cost arborescence (`parents, min_cost = minimum_spanning_arborescence(...)`).
    """
    c_arr = np.asarray(costs, dtype=np.float64)
    neg_weights = np.where(np.isinf(c_arr) & (c_arr > 0), _NEG_INF, -c_arr)
    res = maximum_spanning_arborescence(
        neg_weights,
        root=root,
        edge_groups=edge_groups,
        required_nodes=required_nodes,
        slot_exclusivity=slot_exclusivity,
    )
    min_cost = -res.score if res.score > _NEG_INF * 0.1 else float("inf")
    return ArborescenceResult(
        parents=res.parents,
        score=min_cost,
        slots=res.slots,
        edges=res.edges,
    )

k_best_arborescences

k_best_arborescences(weights: ndarray, root: int = 0, k: int = 5, *, edge_groups: ndarray | None = None, required_nodes: ndarray | None = None, slot_exclusivity: bool = True) -> list[ArborescenceResult]

Enumerate the top-k highest-scoring valid directed spanning arborescences in descending order.

Parameters:

Name Type Description Default
weights ndarray

2D (N, N) or 3D (N, N, S) array of directed edge scores u -> v.

required
root int

Index of the root vertex (0 <= root < N). Defaults to 0.

0
k int

Number of top arborescences to return (k >= 1). Defaults to 5.

5
edge_groups ndarray | None

Optional integer array of exclusivity group IDs (>= 0).

None
required_nodes ndarray | None

Optional boolean mask or 1D index array of vertices that must be spanned.

None
slot_exclusivity bool

If True (default) and weights is 3D, enforces (parent, slot) exclusivity.

True

Returns:

Type Description
list[ArborescenceResult]

list[ArborescenceResult]: Up to k valid arborescences ordered from highest to lowest score.

Source code in src/serogen/core/graph/arborescence.py
def k_best_arborescences(
    weights: np.ndarray,
    root: int = 0,
    k: int = 5,
    *,
    edge_groups: np.ndarray | None = None,
    required_nodes: np.ndarray | None = None,
    slot_exclusivity: bool = True,
) -> list[ArborescenceResult]:
    r"""Enumerate the top-`k` highest-scoring valid directed spanning arborescences in descending order.

    Args:
        weights: 2D `(N, N)` or 3D `(N, N, S)` array of directed edge scores `u -> v`.
        root: Index of the root vertex (`0 <= root < N`). Defaults to `0`.
        k: Number of top arborescences to return (`k >= 1`). Defaults to `5`.
        edge_groups: Optional integer array of exclusivity group IDs (`>= 0`).
        required_nodes: Optional boolean mask or 1D index array of vertices that must be spanned.
        slot_exclusivity: If `True` (default) and `weights` is 3D, enforces `(parent, slot)` exclusivity.

    Returns:
        list[ArborescenceResult]: Up to `k` valid arborescences ordered from highest to lowest score.
    """
    if k < 1:
        raise ValueError(f"k must be >= 1; got {k}.")
    w_3d, g_3d, active_mask, _ = _normalize_arborescence_inputs(
        weights, root, edge_groups, required_nodes, slot_exclusivity
    )
    n = w_3d.shape[0]
    if n == 0:
        return [_build_result(np.empty(0, dtype=np.int32), np.empty(0, dtype=np.int32), 0.0, 0)]
    if n == 1:
        return [_build_result(np.array([-1], dtype=np.int32), np.array([-1], dtype=np.int32), 0.0, 0)]

    out_p, out_s, out_sc, found = _branch_and_bound_k_best(w_3d, g_3d, active_mask, int(root), int(k))
    return [_build_result(out_p[i], out_s[i], float(out_sc[i]), root) for i in range(found)]

Comments