Skip to content

serogen.loci

Biosynthetic locus archetypes, exact All-Contiguous-Partitions Dynamic Programming (ACP-DP) locus segmentation, cross-contig boundary stitching, and bipartite reference serotype matching.


High-Level Locus Prediction (serogen.loci.predictor)

predictor

End-to-end biosynthetic locus prediction pipeline (LocusPredictor / LocusFinder).

Classes:

Name Description
LocusPredictor

High-level biosynthetic gene cluster (BGC) discovery and segmentation pipeline.

LocusPredictor

High-level biosynthetic gene cluster (BGC) discovery and segmentation pipeline.

Orchestrates: 1. Open Reading Frame (ORF) prediction via OrfPredictor, 2. Protein sequence functional role classification via GeneRoleClassifier, and 3. Archetype-constrained chromosomal segmentation via LocusSegmenter.

Methods:

Name Description
predict_role_probabilities

Predict full (N, 30) GeneRole probability distributions (including UNKNOWN) for genes.

predict

Discover, classify, and segment biosynthetic Loci from genomic sequences or Genes.

Attributes:

Name Type Description
archetype_classifier ArchetypeClassifier

Return the underlying ArchetypeClassifier used by self.segmenter.

Source code in src/serogen/loci/predictor.py
class LocusPredictor:
    """High-level biosynthetic gene cluster (BGC) discovery and segmentation pipeline.

    Orchestrates:
    1. Open Reading Frame (ORF) prediction via [`OrfPredictor`][serogen.genes.orf.predictor.OrfPredictor],
    2. Protein sequence functional role classification via [`GeneRoleClassifier`][serogen.genes.cnn.GeneRoleClassifier], and
    3. Archetype-constrained chromosomal segmentation via [`LocusSegmenter`][serogen.loci.segmenter.LocusSegmenter].
    """

    def __init__(
        self,
        orf_predictor: OrfPredictor | None = None,
        role_classifier: GeneRoleClassifier | None = None,
        segmenter: LocusSegmenter | None = None,
        *,
        min_completeness: float = 0.80,
        max_incompatibility: float = 0.25,
        unknown_temperature: float = 0.35,
        stitch_contig_splits: bool = True,
    ) -> None:
        self.orf_predictor = orf_predictor if orf_predictor is not None else OrfPredictor()
        self.role_classifier = role_classifier if role_classifier is not None else GeneRoleClassifier.pretrained()
        self.segmenter = (
            segmenter
            if segmenter is not None
            else LocusSegmenter(
                min_completeness=min_completeness,
                max_incompatibility=max_incompatibility,
                stitch_contig_splits=stitch_contig_splits,
            )
        )
        self.unknown_temperature = unknown_temperature

    @property
    def archetype_classifier(self) -> ArchetypeClassifier:
        """Return the underlying `ArchetypeClassifier` used by `self.segmenter`."""
        return self.segmenter.classifier

    def predict_role_probabilities(self, genes: Genes) -> np.ndarray[tuple[int, int], np.dtype[np.float64]]:
        """Predict full `(N, 30)` `GeneRole` probability distributions (including `UNKNOWN`) for `genes`."""
        n_genes = len(genes)
        n_roles = len(GeneRole)
        if n_genes == 0:
            return np.empty((0, n_roles), dtype=np.float64)

        raw_probs = self.role_classifier.predict_proba(genes).astype(np.float64)
        classes = self.role_classifier.classes_

        bp_lengths = genes.intervals.lengths
        aa_lengths = (
            genes.translations.lengths if len(genes.translations) == n_genes else np.maximum(0, bp_lengths // 3 - 1)
        )
        partials = genes.is_partial if len(genes.is_partial) == n_genes else np.zeros(n_genes, dtype=np.bool_)

        # Suppress unpaired ABC_PERMEASE calls when no ABC_ATPASE partner is present within +-15 genes on the same contig
        unpaired_perm = np.zeros(n_genes, dtype=np.bool_)
        perm_col = -1
        if int(GeneRole.ABC_PERMEASE) in classes and int(GeneRole.ABC_ATPASE) in classes:
            perm_col = int(np.flatnonzero(classes == int(GeneRole.ABC_PERMEASE))[0])
            atp_col = int(np.flatnonzero(classes == int(GeneRole.ABC_ATPASE))[0])
            cids = genes.contig_indices if len(genes.contig_indices) == n_genes else np.zeros(n_genes, dtype=np.uint32)
            atp_hits = raw_probs[:, atp_col] >= 0.15
            for idx in np.flatnonzero(raw_probs[:, perm_col] >= 0.15):
                i = int(idx)
                cid_i = cids[i]
                lo = max(0, i - 15)
                hi = min(n_genes, i + 16)
                while lo < i and cids[lo] != cid_i:
                    lo += 1
                while hi > i + 1 and cids[hi - 1] != cid_i:
                    hi -= 1
                near_truncated_edge = (
                    i == lo
                    or i == hi - 1
                    or (i == lo + 1 and bool(partials[lo]) and int(aa_lengths[lo]) < 200)
                    or (i == hi - 2 and bool(partials[hi - 1]) and int(aa_lengths[hi - 1]) < 200)
                )
                if not np.any(atp_hits[lo:hi]) and not near_truncated_edge:
                    unpaired_perm[i] = True

        full_probs = np.full((n_genes, n_roles), 1e-12, dtype=np.float64)
        if int(GeneRole.UNKNOWN) in classes:
            full_probs[:, classes] = raw_probs
            if perm_col >= 0 and np.any(unpaired_perm):
                shifted_perm = full_probs[unpaired_perm, int(GeneRole.ABC_PERMEASE)].copy()
                full_probs[unpaired_perm, int(GeneRole.ABC_PERMEASE)] = 1e-12
                full_probs[unpaired_perm, int(GeneRole.UNKNOWN)] += shifted_perm
        else:
            logits = self.role_classifier.decision_function(genes).astype(np.float64)
            if perm_col >= 0 and np.any(unpaired_perm):
                raw_probs = raw_probs.copy()
                raw_probs[unpaired_perm, perm_col] = 1e-12
                logits[unpaired_perm, perm_col] = -10.0

            mach_set = set(int(r) for r in self.archetype_classifier.tensors.machinery_roles)
            mach_col_mask = np.array([int(c) in mach_set for c in classes], dtype=np.bool_)
            neg_mach_mask = (logits < 1.0) & mach_col_mask[None, :]
            cleaned_probs = np.where(neg_mach_mask, 1e-12, raw_probs)

            max_l = np.max(logits, axis=1, keepdims=True)
            max_p = np.max(cleaned_probs, axis=1, keepdims=True)

            base_conf = 1.0 - np.clip(
                (1.0 - max_p) ** (1.0 / max(self.unknown_temperature, 1e-3)),
                1e-6,
                0.99,
            )
            logit_factor = 0.75 + 0.25 / (1.0 + np.exp(-np.clip((max_l - 4.5) / 1.0, -30.0, 30.0)))
            active_mass = np.clip(base_conf * logit_factor, 1e-4, 0.999999)

            row_sum = np.maximum(cleaned_probs.sum(axis=1, keepdims=True), 1e-12)
            norm_cleaned = np.where(max_p > 1e-6, cleaned_probs / row_sum, cleaned_probs)
            full_probs[:, classes] = norm_cleaned * active_mass
            full_probs[:, int(GeneRole.UNKNOWN)] = np.clip(1.0 - full_probs[:, classes].sum(axis=1), 1e-12, 1.0)

        # Enforce biological minimum amino-acid length constraints on intact (non-partial) ORFs
        if len(bp_lengths) == n_genes and np.any(bp_lengths > 0):
            for role_enum, min_aa in _ROLE_MIN_AA_LEN.items():
                short_r_mask = (~partials) & (aa_lengths > 0) & (aa_lengths < min_aa)
                if np.any(short_r_mask):
                    r_idx = int(role_enum)
                    shifted = full_probs[short_r_mask, r_idx].copy()
                    full_probs[short_r_mask, r_idx] = 1e-12
                    full_probs[short_r_mask, int(GeneRole.UNKNOWN)] += shifted

            tiny_orf_mask = (~partials) & (bp_lengths > 0) & (bp_lengths < 210)
            if np.any(tiny_orf_mask):
                full_probs[tiny_orf_mask, :] = 1e-12
                full_probs[tiny_orf_mask, int(GeneRole.UNKNOWN)] = 0.99

        full_probs /= full_probs.sum(axis=1, keepdims=True)
        return full_probs

    def predict(
        self,
        target: bytes | str | Sequence | Sequences | GenomeAssembly | Genes,
        *,
        categories: ArchetypeCategory | str | Iterable[ArchetypeCategory | str] | None = None,
        archetypes: LocusArchetype | str | int | Iterable[LocusArchetype | str | int] | None = None,
        min_completeness: float = 0.0,
        min_score: float = 0.0,
        max_frameshift_fraction: float = 1.0,
        only_intact: bool = False,
        heal_mobile_elements: bool = False,
    ) -> Loci:
        """Discover, classify, and segment biosynthetic [`Loci`][serogen.loci.models.Loci] from genomic sequences or `Genes`."""
        if isinstance(target, Genes):
            if (
                len(target) > 0
                and len(target.intervals.lengths) == len(target)
                and np.any(target.intervals.lengths > 0)
            ):
                partials = (
                    target.is_partial
                    if len(target.is_partial) == len(target)
                    else np.zeros(len(target), dtype=np.bool_)
                )
                keep_mask = partials | (target.intervals.lengths >= 210)
                genes = target[keep_mask] if not bool(np.all(keep_mask)) else target
            else:
                genes = target
        else:
            genes = self.orf_predictor.predict(target)
        if len(genes) == 0:
            return Loci.empty()

        role_probs = self.predict_role_probabilities(genes)
        loci = self.segmenter.segment(genes, role_probs)
        if heal_mobile_elements and len(loci) > 0:
            loci = loci.heal_mobile_elements(remove_nodes=True)
        if (
            categories is not None
            or archetypes is not None
            or min_completeness > 0.0
            or min_score > 0.0
            or max_frameshift_fraction < 1.0
            or only_intact
        ):
            return loci.filter(
                categories=categories,
                archetypes=archetypes,
                min_completeness=min_completeness,
                min_score=min_score,
                max_frameshift_fraction=max_frameshift_fraction,
                only_intact=only_intact,
            )
        return loci

archetype_classifier property

archetype_classifier: ArchetypeClassifier

Return the underlying ArchetypeClassifier used by self.segmenter.

predict_role_probabilities

predict_role_probabilities(genes: Genes) -> ndarray[tuple[int, int], dtype[float64]]

Predict full (N, 30) GeneRole probability distributions (including UNKNOWN) for genes.

Source code in src/serogen/loci/predictor.py
def predict_role_probabilities(self, genes: Genes) -> np.ndarray[tuple[int, int], np.dtype[np.float64]]:
    """Predict full `(N, 30)` `GeneRole` probability distributions (including `UNKNOWN`) for `genes`."""
    n_genes = len(genes)
    n_roles = len(GeneRole)
    if n_genes == 0:
        return np.empty((0, n_roles), dtype=np.float64)

    raw_probs = self.role_classifier.predict_proba(genes).astype(np.float64)
    classes = self.role_classifier.classes_

    bp_lengths = genes.intervals.lengths
    aa_lengths = (
        genes.translations.lengths if len(genes.translations) == n_genes else np.maximum(0, bp_lengths // 3 - 1)
    )
    partials = genes.is_partial if len(genes.is_partial) == n_genes else np.zeros(n_genes, dtype=np.bool_)

    # Suppress unpaired ABC_PERMEASE calls when no ABC_ATPASE partner is present within +-15 genes on the same contig
    unpaired_perm = np.zeros(n_genes, dtype=np.bool_)
    perm_col = -1
    if int(GeneRole.ABC_PERMEASE) in classes and int(GeneRole.ABC_ATPASE) in classes:
        perm_col = int(np.flatnonzero(classes == int(GeneRole.ABC_PERMEASE))[0])
        atp_col = int(np.flatnonzero(classes == int(GeneRole.ABC_ATPASE))[0])
        cids = genes.contig_indices if len(genes.contig_indices) == n_genes else np.zeros(n_genes, dtype=np.uint32)
        atp_hits = raw_probs[:, atp_col] >= 0.15
        for idx in np.flatnonzero(raw_probs[:, perm_col] >= 0.15):
            i = int(idx)
            cid_i = cids[i]
            lo = max(0, i - 15)
            hi = min(n_genes, i + 16)
            while lo < i and cids[lo] != cid_i:
                lo += 1
            while hi > i + 1 and cids[hi - 1] != cid_i:
                hi -= 1
            near_truncated_edge = (
                i == lo
                or i == hi - 1
                or (i == lo + 1 and bool(partials[lo]) and int(aa_lengths[lo]) < 200)
                or (i == hi - 2 and bool(partials[hi - 1]) and int(aa_lengths[hi - 1]) < 200)
            )
            if not np.any(atp_hits[lo:hi]) and not near_truncated_edge:
                unpaired_perm[i] = True

    full_probs = np.full((n_genes, n_roles), 1e-12, dtype=np.float64)
    if int(GeneRole.UNKNOWN) in classes:
        full_probs[:, classes] = raw_probs
        if perm_col >= 0 and np.any(unpaired_perm):
            shifted_perm = full_probs[unpaired_perm, int(GeneRole.ABC_PERMEASE)].copy()
            full_probs[unpaired_perm, int(GeneRole.ABC_PERMEASE)] = 1e-12
            full_probs[unpaired_perm, int(GeneRole.UNKNOWN)] += shifted_perm
    else:
        logits = self.role_classifier.decision_function(genes).astype(np.float64)
        if perm_col >= 0 and np.any(unpaired_perm):
            raw_probs = raw_probs.copy()
            raw_probs[unpaired_perm, perm_col] = 1e-12
            logits[unpaired_perm, perm_col] = -10.0

        mach_set = set(int(r) for r in self.archetype_classifier.tensors.machinery_roles)
        mach_col_mask = np.array([int(c) in mach_set for c in classes], dtype=np.bool_)
        neg_mach_mask = (logits < 1.0) & mach_col_mask[None, :]
        cleaned_probs = np.where(neg_mach_mask, 1e-12, raw_probs)

        max_l = np.max(logits, axis=1, keepdims=True)
        max_p = np.max(cleaned_probs, axis=1, keepdims=True)

        base_conf = 1.0 - np.clip(
            (1.0 - max_p) ** (1.0 / max(self.unknown_temperature, 1e-3)),
            1e-6,
            0.99,
        )
        logit_factor = 0.75 + 0.25 / (1.0 + np.exp(-np.clip((max_l - 4.5) / 1.0, -30.0, 30.0)))
        active_mass = np.clip(base_conf * logit_factor, 1e-4, 0.999999)

        row_sum = np.maximum(cleaned_probs.sum(axis=1, keepdims=True), 1e-12)
        norm_cleaned = np.where(max_p > 1e-6, cleaned_probs / row_sum, cleaned_probs)
        full_probs[:, classes] = norm_cleaned * active_mass
        full_probs[:, int(GeneRole.UNKNOWN)] = np.clip(1.0 - full_probs[:, classes].sum(axis=1), 1e-12, 1.0)

    # Enforce biological minimum amino-acid length constraints on intact (non-partial) ORFs
    if len(bp_lengths) == n_genes and np.any(bp_lengths > 0):
        for role_enum, min_aa in _ROLE_MIN_AA_LEN.items():
            short_r_mask = (~partials) & (aa_lengths > 0) & (aa_lengths < min_aa)
            if np.any(short_r_mask):
                r_idx = int(role_enum)
                shifted = full_probs[short_r_mask, r_idx].copy()
                full_probs[short_r_mask, r_idx] = 1e-12
                full_probs[short_r_mask, int(GeneRole.UNKNOWN)] += shifted

        tiny_orf_mask = (~partials) & (bp_lengths > 0) & (bp_lengths < 210)
        if np.any(tiny_orf_mask):
            full_probs[tiny_orf_mask, :] = 1e-12
            full_probs[tiny_orf_mask, int(GeneRole.UNKNOWN)] = 0.99

    full_probs /= full_probs.sum(axis=1, keepdims=True)
    return full_probs

predict

predict(target: bytes | str | Sequence | Sequences | GenomeAssembly | Genes, *, categories: ArchetypeCategory | str | Iterable[ArchetypeCategory | str] | None = None, archetypes: LocusArchetype | str | int | Iterable[LocusArchetype | str | int] | None = None, min_completeness: float = 0.0, min_score: float = 0.0, max_frameshift_fraction: float = 1.0, only_intact: bool = False, heal_mobile_elements: bool = False) -> Loci

Discover, classify, and segment biosynthetic Loci from genomic sequences or Genes.

Source code in src/serogen/loci/predictor.py
def predict(
    self,
    target: bytes | str | Sequence | Sequences | GenomeAssembly | Genes,
    *,
    categories: ArchetypeCategory | str | Iterable[ArchetypeCategory | str] | None = None,
    archetypes: LocusArchetype | str | int | Iterable[LocusArchetype | str | int] | None = None,
    min_completeness: float = 0.0,
    min_score: float = 0.0,
    max_frameshift_fraction: float = 1.0,
    only_intact: bool = False,
    heal_mobile_elements: bool = False,
) -> Loci:
    """Discover, classify, and segment biosynthetic [`Loci`][serogen.loci.models.Loci] from genomic sequences or `Genes`."""
    if isinstance(target, Genes):
        if (
            len(target) > 0
            and len(target.intervals.lengths) == len(target)
            and np.any(target.intervals.lengths > 0)
        ):
            partials = (
                target.is_partial
                if len(target.is_partial) == len(target)
                else np.zeros(len(target), dtype=np.bool_)
            )
            keep_mask = partials | (target.intervals.lengths >= 210)
            genes = target[keep_mask] if not bool(np.all(keep_mask)) else target
        else:
            genes = target
    else:
        genes = self.orf_predictor.predict(target)
    if len(genes) == 0:
        return Loci.empty()

    role_probs = self.predict_role_probabilities(genes)
    loci = self.segmenter.segment(genes, role_probs)
    if heal_mobile_elements and len(loci) > 0:
        loci = loci.heal_mobile_elements(remove_nodes=True)
    if (
        categories is not None
        or archetypes is not None
        or min_completeness > 0.0
        or min_score > 0.0
        or max_frameshift_fraction < 1.0
        or only_intact
    ):
        return loci.filter(
            categories=categories,
            archetypes=archetypes,
            min_completeness=min_completeness,
            min_score=min_score,
            max_frameshift_fraction=max_frameshift_fraction,
            only_intact=only_intact,
        )
    return loci

ACP-DP Locus Segmentation (serogen.loci.segmenter)

segmenter

Archetype-Constrained Exact Partitioning Dynamic Programming (ACP-DP) locus segmenter.

Classes:

Name Description
LocusSegmenter

Archetype-Constrained Exact Partitioning Dynamic Programming (ACP-DP) locus segmenter.

LocusSegmenter

Archetype-Constrained Exact Partitioning Dynamic Programming (ACP-DP) locus segmenter.

Segments chromosomal Genes and their GeneRole probability distributions (or discrete role labels) into non-overlapping Loci, disambiguating tandem loci (e.g., adjacent K-locus and O-locus in Klebsiella / E. coli), bridging internal unannotated (UNKNOWN) and mobile element (TRANSPOSASE/INTEGRASE) gaps, and stitching loci split across contig boundaries in fragmented assemblies.

Methods:

Name Description
segment

Segment a Genes container and role predictions into Loci.

Source code in src/serogen/loci/segmenter.py
  33
  34
  35
  36
  37
  38
  39
  40
  41
  42
  43
  44
  45
  46
  47
  48
  49
  50
  51
  52
  53
  54
  55
  56
  57
  58
  59
  60
  61
  62
  63
  64
  65
  66
  67
  68
  69
  70
  71
  72
  73
  74
  75
  76
  77
  78
  79
  80
  81
  82
  83
  84
  85
  86
  87
  88
  89
  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
 497
 498
 499
 500
 501
 502
 503
 504
 505
 506
 507
 508
 509
 510
 511
 512
 513
 514
 515
 516
 517
 518
 519
 520
 521
 522
 523
 524
 525
 526
 527
 528
 529
 530
 531
 532
 533
 534
 535
 536
 537
 538
 539
 540
 541
 542
 543
 544
 545
 546
 547
 548
 549
 550
 551
 552
 553
 554
 555
 556
 557
 558
 559
 560
 561
 562
 563
 564
 565
 566
 567
 568
 569
 570
 571
 572
 573
 574
 575
 576
 577
 578
 579
 580
 581
 582
 583
 584
 585
 586
 587
 588
 589
 590
 591
 592
 593
 594
 595
 596
 597
 598
 599
 600
 601
 602
 603
 604
 605
 606
 607
 608
 609
 610
 611
 612
 613
 614
 615
 616
 617
 618
 619
 620
 621
 622
 623
 624
 625
 626
 627
 628
 629
 630
 631
 632
 633
 634
 635
 636
 637
 638
 639
 640
 641
 642
 643
 644
 645
 646
 647
 648
 649
 650
 651
 652
 653
 654
 655
 656
 657
 658
 659
 660
 661
 662
 663
 664
 665
 666
 667
 668
 669
 670
 671
 672
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 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
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
class LocusSegmenter:
    """Archetype-Constrained Exact Partitioning Dynamic Programming (ACP-DP) locus segmenter.

    Segments chromosomal [`Genes`][serogen.genes.models.Genes] and their `GeneRole` probability
    distributions (or discrete role labels) into non-overlapping [`Loci`][serogen.loci.models.Loci],
    disambiguating tandem loci (e.g., adjacent K-locus and O-locus in *Klebsiella* / *E. coli*),
    bridging internal unannotated (`UNKNOWN`) and mobile element (`TRANSPOSASE`/`INTEGRASE`) gaps,
    and stitching loci split across contig boundaries in fragmented assemblies.
    """

    def __init__(
        self,
        classifier: ArchetypeClassifier | None = None,
        *,
        min_completeness: float = 0.80,
        max_incompatibility: float = 0.25,
        max_gap_genes: int = 3,
        max_gap_bp: int = 5_000,
        min_locus_genes: int = 3,
        max_locus_genes: int = 45,
        locus_open_penalty: float = 6.0,
        strand_switch_penalty: float = 5.5,
        min_mean_llr: float = 1.0,
        min_biosynthetic_prob: float = 0.35,
        min_machinery_prob: float = 0.40,
        max_operon_snap_bp: int = 150,
        allow_single_gene_synthase: bool = True,
        min_synthase_prob: float = 0.70,
        stitch_contig_splits: bool = True,
    ) -> None:
        self.classifier = (
            classifier
            if classifier is not None
            else ArchetypeClassifier(
                min_completeness=min_completeness,
                max_incompatibility=max_incompatibility,
            )
        )
        self.min_completeness = self.classifier.min_completeness
        self.max_incompatibility = self.classifier.max_incompatibility
        self.max_gap_genes = max_gap_genes
        self.max_gap_bp = max_gap_bp
        self.min_locus_genes = min_locus_genes
        self.max_locus_genes = max_locus_genes
        self.locus_open_penalty = locus_open_penalty
        self.strand_switch_penalty = strand_switch_penalty
        self.min_mean_llr = min_mean_llr
        self.min_biosynthetic_prob = min_biosynthetic_prob
        self.min_machinery_prob = min_machinery_prob
        self.max_operon_snap_bp = max_operon_snap_bp
        self.allow_single_gene_synthase = allow_single_gene_synthase
        self.min_synthase_prob = min_synthase_prob
        self.stitch_contig_splits = stitch_contig_splits

    def _find_candidate_islands(
        self,
        starts: np.ndarray[tuple[int], np.dtype[np.int32]],
        ends: np.ndarray[tuple[int], np.dtype[np.int32]],
        probs: np.ndarray[tuple[int, int], np.dtype[np.float64]],
        *,
        allow_boundary_partials: bool = False,
    ) -> list[tuple[int, int]]:
        """Group active biosynthetic genes into coarse candidate islands separated by large gaps."""
        n = len(starts)
        if n == 0:
            return []

        t = self.classifier.tensors
        biosyn_prob = probs[:, t.biosynthetic_mask].sum(axis=1)
        mge_prob = probs[:, int(GeneRole.TRANSPOSASE)] + probs[:, int(GeneRole.INTEGRASE)]
        is_mge = mge_prob >= self.min_biosynthetic_prob
        mach_prob = probs[:, t.machinery_roles].sum(axis=1)
        synth_prob = probs[:, int(GeneRole.TRANSMEMBRANE_SYNTHASE)]

        active_indices = np.nonzero(biosyn_prob >= self.min_biosynthetic_prob)[0]
        if len(active_indices) == 0:
            return []

        contig_min_start = int(starts[0])
        contig_max_end = int(ends[-1])

        def _keep_run(c_start: int, c_end: int, has_mach: bool, has_synth: bool) -> bool:
            if has_mach and (c_end - c_start + 1) >= self.min_locus_genes:
                return True
            if self.allow_single_gene_synthase and has_synth:
                return True
            if allow_boundary_partials:
                touches_start = (c_start <= 1 and int(starts[c_start]) - contig_min_start <= 2500) or (
                    int(starts[c_start]) - contig_min_start <= 1200
                )
                touches_end = (c_end >= n - 2 and contig_max_end - int(ends[c_end]) <= 2500) or (
                    contig_max_end - int(ends[c_end]) <= 1200
                )
                if touches_start or touches_end:
                    return True
            return False

        def _snap_operon_run(c_start: int, c_end: int) -> tuple[int, int]:
            while (
                c_start > 0
                and int(starts[c_start]) - int(ends[c_start - 1]) <= self.max_operon_snap_bp
                and biosyn_prob[c_start - 1] >= self.min_biosynthetic_prob
            ):
                c_start -= 1
            while (
                c_end + 1 < n
                and int(starts[c_end + 1]) - int(ends[c_end]) <= self.max_operon_snap_bp
                and biosyn_prob[c_end + 1] >= self.min_biosynthetic_prob
            ):
                c_end += 1
            return c_start, c_end

        islands: list[tuple[int, int]] = []
        cur_start = int(active_indices[0])
        cur_end = int(active_indices[0])
        has_machinery = bool(mach_prob[cur_start] >= self.min_machinery_prob)
        has_synthase = bool(synth_prob[cur_start] >= self.min_synthase_prob)

        for idx in active_indices[1:]:
            i = int(idx)
            raw_gene_gap = i - cur_end - 1
            mge_in_gap = int(np.sum(is_mge[cur_end + 1 : i])) if raw_gene_gap > 0 else 0
            if mge_in_gap > 0:
                gene_gap = max(0, raw_gene_gap - mge_in_gap - 1)
                mge_idx = np.nonzero(is_mge[cur_end + 1 : i])[0] + (cur_end + 1)
                mge_span = max(0, int(ends[mge_idx[-1]]) - int(starts[mge_idx[0]]))
                bp_gap = max(0, int(starts[i]) - int(ends[cur_end]) - mge_span)
            else:
                gene_gap = raw_gene_gap
                bp_gap = int(starts[i]) - int(ends[cur_end])
            if gene_gap <= self.max_gap_genes and bp_gap <= self.max_gap_bp:
                cur_end = i
                if mach_prob[i] >= self.min_machinery_prob:
                    has_machinery = True
                if synth_prob[i] >= self.min_synthase_prob:
                    has_synthase = True
            else:
                if _keep_run(cur_start, cur_end, has_machinery, has_synthase):
                    s_run, e_run = _snap_operon_run(cur_start, cur_end)
                    islands.append((s_run, e_run + 1))
                cur_start = i
                cur_end = i
                has_machinery = bool(mach_prob[i] >= self.min_machinery_prob)
                has_synthase = bool(synth_prob[i] >= self.min_synthase_prob)

        if _keep_run(cur_start, cur_end, has_machinery, has_synthase):
            s_run, e_run = _snap_operon_run(cur_start, cur_end)
            islands.append((s_run, e_run + 1))

        return islands

    def _trim_boundaries(
        self,
        i_start: int,
        i_end: int,
        archetype: LocusArchetype,
        top_roles: np.ndarray[tuple[int], np.dtype[np.int32]],
        biosyn_prob: np.ndarray[tuple[int], np.dtype[np.float64]],
        strands: np.ndarray[tuple[int], np.dtype[np.int8]],
        prefixes: tuple[np.ndarray, np.ndarray, np.ndarray],
        starts: np.ndarray[tuple[int], np.dtype[np.int32]] | None = None,
        ends: np.ndarray[tuple[int], np.dtype[np.int32]] | None = None,
        min_bound: int = 0,
        max_bound: int | None = None,
    ) -> tuple[int, int]:
        """Snap segment boundaries inward and outward to archetype-relevant genes on the dominant operon strand."""
        left = i_start
        right = i_end - 1
        upper_bound = len(top_roles) if max_bound is None else min(len(top_roles), max_bound)
        min_genes = (
            1
            if (self.allow_single_gene_synthase and archetype == LocusArchetype.CPS_SYNTHASE)
            else self.min_locus_genes
        )

        def _is_operon_coupled_boundary(idx: int, neighbor_idx: int) -> bool:
            if starts is None or ends is None:
                return False
            if int(strands[idx]) != int(strands[neighbor_idx]):
                return False
            gap = (
                int(starts[neighbor_idx]) - int(ends[idx])
                if neighbor_idx > idx
                else int(starts[idx]) - int(ends[neighbor_idx])
            )
            return gap <= self.max_operon_snap_bp and float(biosyn_prob[idx]) >= self.min_biosynthetic_prob

        def _snap_relevant(l_idx: int, r_idx: int) -> tuple[int, int]:
            while l_idx <= r_idx:
                r = GeneRole(int(top_roles[l_idx]))
                is_rel = self.classifier.is_boundary_relevant(archetype, r)
                if is_rel and (
                    biosyn_prob[l_idx] >= self.min_biosynthetic_prob
                    or (l_idx < r_idx and _is_operon_coupled_boundary(l_idx, l_idx + 1))
                ):
                    break
                l_idx += 1
            while r_idx >= l_idx:
                r = GeneRole(int(top_roles[r_idx]))
                is_rel = self.classifier.is_boundary_relevant(archetype, r)
                if is_rel and (
                    biosyn_prob[r_idx] >= self.min_biosynthetic_prob
                    or (r_idx > l_idx and _is_operon_coupled_boundary(r_idx, r_idx - 1))
                ):
                    break
                r_idx -= 1
            return l_idx, r_idx

        left, right = _snap_relevant(left, right)
        if right - left + 1 < min_genes:
            return i_start, i_end

        # Determine dominant strand of core module genes inside [left, right]
        t = self.classifier.tensors
        a_id = int(archetype)
        n_m = int(t.num_modules[a_id])
        mod_mask = 0
        for m_i in range(n_m):
            mod_mask |= int(t.module_masks[a_id, m_i])

        mach_set = {int(r) for r in t.machinery_roles}
        arch_mach_mask = mod_mask & sum(1 << r for r in mach_set)

        # Trim across large inter-operon gaps (>= 2,000 bp) if the outer piece has no archetype machinery
        # and trimming preserves 100% of the segment's module completeness
        mge_roles = (int(GeneRole.TRANSPOSASE), int(GeneRole.INTEGRASE))
        if starts is not None and ends is not None and right - left + 1 > min_genes:
            c_cur, _, _ = self.classifier.score_segments(
                np.array([left], dtype=np.intp),
                np.array([right + 1], dtype=np.intp),
                prefixes=prefixes,
            )
            cur_comp = float(c_cur[0, a_id])

            for k in range(left + 1, right - min_genes + 2):
                if (
                    int(starts[k]) - int(ends[k - 1]) >= 2000
                    and int(top_roles[k - 1]) not in mge_roles
                    and int(top_roles[k]) not in mge_roles
                ):
                    has_outer_mach = any((arch_mach_mask & (1 << int(top_roles[p]))) != 0 for p in range(left, k))
                    if not has_outer_mach:
                        c_re, inc_re, _ = self.classifier.score_segments(
                            np.array([k], dtype=np.intp),
                            np.array([right + 1], dtype=np.intp),
                            prefixes=prefixes,
                        )
                        if (
                            float(c_re[0, a_id]) >= cur_comp - 1e-6
                            and float(c_re[0, a_id]) >= self.min_completeness
                            and float(inc_re[0, a_id]) <= self.max_incompatibility
                        ):
                            left, right = _snap_relevant(k, right)

            for k in range(right, left + min_genes - 1, -1):
                if (
                    int(starts[k]) - int(ends[k - 1]) >= 2000
                    and int(top_roles[k - 1]) not in mge_roles
                    and int(top_roles[k]) not in mge_roles
                ):
                    has_outer_mach = any((arch_mach_mask & (1 << int(top_roles[p]))) != 0 for p in range(k, right + 1))
                    if not has_outer_mach:
                        c_re, inc_re, _ = self.classifier.score_segments(
                            np.array([left], dtype=np.intp),
                            np.array([k], dtype=np.intp),
                            prefixes=prefixes,
                        )
                        if (
                            float(c_re[0, a_id]) >= cur_comp - 1e-6
                            and float(c_re[0, a_id]) >= self.min_completeness
                            and float(inc_re[0, a_id]) <= self.max_incompatibility
                        ):
                            left, right = _snap_relevant(left, k - 1)

        mod_strands = [
            int(strands[k])
            for k in range(left, right + 1)
            if biosyn_prob[k] >= self.min_biosynthetic_prob and (mod_mask & (1 << int(top_roles[k]))) != 0
        ]
        dom_strand = (
            (1 if sum(mod_strands) >= 0 else -1)
            if mod_strands
            else (1 if int(np.sum(strands[left : right + 1])) >= 0 else -1)
        )

        c_cur_st, _, _ = self.classifier.score_segments(
            np.array([left], dtype=np.intp),
            np.array([right + 1], dtype=np.intp),
            prefixes=prefixes,
        )
        cur_comp_st = float(c_cur_st[0, a_id])

        # Trim leading opposite-strand genes if completeness is preserved and remains >= min_completeness
        while right - left + 1 > min_genes and int(strands[left]) != dom_strand:
            c_re, inc_re, _ = self.classifier.score_segments(
                np.array([left + 1], dtype=np.intp),
                np.array([right + 1], dtype=np.intp),
                prefixes=prefixes,
            )
            if (
                float(c_re[0, a_id]) >= cur_comp_st - 1e-6
                and float(c_re[0, a_id]) >= self.min_completeness
                and float(inc_re[0, a_id]) <= self.max_incompatibility
            ):
                left, right = _snap_relevant(left + 1, right)
            else:
                break

        # Trim trailing opposite-strand genes if completeness is preserved and remains >= min_completeness
        while right - left + 1 > min_genes and int(strands[right]) != dom_strand:
            c_re, inc_re, _ = self.classifier.score_segments(
                np.array([left], dtype=np.intp),
                np.array([right], dtype=np.intp),
                prefixes=prefixes,
            )
            if (
                float(c_re[0, a_id]) >= cur_comp_st - 1e-6
                and float(c_re[0, a_id]) >= self.min_completeness
                and float(inc_re[0, a_id]) <= self.max_incompatibility
            ):
                left, right = _snap_relevant(left, right - 1)
            else:
                break

        # Outward operon boundary-snapping pass: include immediately adjacent same-strand genes
        # (< max_operon_snap_bp gap, P(active) >= min_biosynthetic_prob, archetype-relevant & non-MGE/non-forbidden)
        if starts is not None and ends is not None:
            forb_mask = int(archetype.forbidden_mask)
            while left > min_bound:
                cand = left - 1
                if int(strands[cand]) != dom_strand:
                    break
                if int(starts[left]) - int(ends[cand]) > self.max_operon_snap_bp:
                    break
                if float(biosyn_prob[cand]) < self.min_biosynthetic_prob:
                    break
                cand_role = GeneRole(int(top_roles[cand]))
                if not self.classifier.is_boundary_relevant(archetype, cand_role):
                    break
                if (forb_mask & (1 << int(cand_role))) != 0:
                    break
                c_re, inc_re, _ = self.classifier.score_segments(
                    np.array([cand], dtype=np.intp),
                    np.array([right + 1], dtype=np.intp),
                    prefixes=prefixes,
                )
                if float(c_re[0, a_id]) >= self.min_completeness and float(inc_re[0, a_id]) <= self.max_incompatibility:
                    left = cand
                else:
                    break

            while right + 1 < upper_bound:
                cand = right + 1
                if int(strands[cand]) != dom_strand:
                    break
                if int(starts[cand]) - int(ends[right]) > self.max_operon_snap_bp:
                    break
                if float(biosyn_prob[cand]) < self.min_biosynthetic_prob:
                    break
                cand_role = GeneRole(int(top_roles[cand]))
                if not self.classifier.is_boundary_relevant(archetype, cand_role):
                    break
                if (forb_mask & (1 << int(cand_role))) != 0:
                    break
                c_re, inc_re, _ = self.classifier.score_segments(
                    np.array([left], dtype=np.intp),
                    np.array([cand + 1], dtype=np.intp),
                    prefixes=prefixes,
                )
                if float(c_re[0, a_id]) >= self.min_completeness and float(inc_re[0, a_id]) <= self.max_incompatibility:
                    right = cand
                else:
                    break

        if right - left + 1 < min_genes:
            return i_start, i_end
        return left, right + 1

    def _solve_island_dp(
        self,
        island_probs: np.ndarray[tuple[int, int], np.dtype[np.float64]],
        island_starts: np.ndarray[tuple[int], np.dtype[np.int32]],
        island_ends: np.ndarray[tuple[int], np.dtype[np.int32]],
        island_strands: np.ndarray[tuple[int], np.dtype[np.int8]],
    ) -> list[tuple[int, int, LocusArchetype, float, float]]:
        """Run exact ACP-DP interval partitioning on a single candidate island."""
        m = len(island_probs)
        if m == 0:
            return []

        synth_probs = island_probs[:, int(GeneRole.TRANSMEMBRANE_SYNTHASE)]
        has_island_synth = self.allow_single_gene_synthase and bool(np.any(synth_probs >= self.min_synthase_prob))
        if m < self.min_locus_genes and not has_island_synth:
            return []

        t = self.classifier.tensors
        prefixes = self.classifier.precompute_prefixes(island_probs)
        non_unk = island_probs.copy()
        non_unk[:, int(GeneRole.UNKNOWN)] = -1.0
        best_non_unk = np.argmax(non_unk, axis=1).astype(np.int32)
        raw_top = np.argmax(island_probs, axis=1).astype(np.int32)
        biosyn_prob = island_probs[:, t.biosynthetic_mask].sum(axis=1)
        mge_prob = island_probs[:, int(GeneRole.TRANSPOSASE)] + island_probs[:, int(GeneRole.INTEGRASE)]
        is_mge = mge_prob >= self.min_biosynthetic_prob
        top_roles = np.where(
            (raw_top == int(GeneRole.UNKNOWN)) & ((biosyn_prob + mge_prob) >= self.min_biosynthetic_prob),
            best_non_unk,
            raw_top,
        ).astype(np.int32)

        # Precompute internal intergenic transition penalties (strand switches & large gaps),
        # propagating host operon strand across mobile element (TRANSPOSASE/INTEGRASE) insertions
        pref_trans = np.zeros(m, dtype=np.float64)
        if m > 1:
            eff_strands = island_strands.copy()
            for k_s in range(1, m):
                if is_mge[k_s]:
                    eff_strands[k_s] = eff_strands[k_s - 1]
            for k_s in range(m - 2, -1, -1):
                if is_mge[k_s]:
                    eff_strands[k_s] = eff_strands[k_s + 1]
            strand_diff = (eff_strands[:-1] != eff_strands[1:]).astype(np.float64)
            mge_edge = is_mge[:-1] | is_mge[1:]
            gaps_bp = np.where(mge_edge, 0.0, np.maximum(0, island_starts[1:] - island_ends[:-1]).astype(np.float64))
            gap_pen = np.maximum(0.0, (gaps_bp - 500.0) / 1000.0) * 2.0
            edge_pen = strand_diff * self.strand_switch_penalty + gap_pen
            np.cumsum(edge_pen, out=pref_trans[1:])

        min_seg_len = 1 if has_island_synth else self.min_locus_genes
        pair_starts: list[int] = []
        pair_ends: list[int] = []
        for i in range(m):
            max_j = min(m, i + self.max_locus_genes)
            for j in range(i + min_seg_len, max_j + 1):
                if j - i < self.min_locus_genes and not bool(np.any(synth_probs[i:j] >= self.min_synthase_prob)):
                    continue
                pair_starts.append(i)
                pair_ends.append(j)

        if not pair_starts:
            return []

        s_arr = np.array(pair_starts, dtype=np.intp)
        e_arr = np.array(pair_ends, dtype=np.intp)
        comp_mat, incomp_mat, llr_mat = self.classifier.score_segments(s_arr, e_arr, prefixes=prefixes)

        trans_pen_vec = pref_trans[e_arr - 1] - pref_trans[s_arr]
        adj_llr_mat = llr_mat - trans_pen_vec[:, None]
        seg_len = (e_arr - s_arr)[:, None].astype(np.float64)
        pref_mge = np.zeros(m + 1, dtype=np.float64)
        np.cumsum(is_mge.astype(np.float64), out=pref_mge[1:])
        mge_cnt = (pref_mge[e_arr] - pref_mge[s_arr])[:, None]
        eff_seg_len = np.maximum(1.0, seg_len - mge_cnt)

        valid_mask = (
            (comp_mat >= self.min_completeness)
            & (incomp_mat <= self.max_incompatibility)
            & (adj_llr_mat >= self.min_mean_llr * eff_seg_len)
        )
        valid_mask[:, int(LocusArchetype.UNKNOWN)] = False

        # Restrict 1-2 gene segments exclusively to CPS_SYNTHASE
        short_rows = (e_arr - s_arr) < self.min_locus_genes
        if np.any(short_rows):
            cps_synth_id = int(LocusArchetype.CPS_SYNTHASE)
            short_arch_mask = np.zeros(len(LocusArchetype), dtype=np.bool_)
            short_arch_mask[cps_synth_id] = True
            valid_mask[short_rows] &= short_arch_mask[None, :]

        open_pen = np.full(seg_len.shape, self.locus_open_penalty, dtype=np.float64)
        if np.any(short_rows):
            scale = np.minimum(1.0, seg_len[short_rows, 0] / max(self.min_locus_genes, 1))
            open_pen[short_rows, 0] = self.locus_open_penalty * scale

        mod_bonus = (1.5 * t.num_modules[None, :] + 0.02 * t.num_modules[None, :] * seg_len) * (comp_mat**2)
        eff_mat = np.where(
            valid_mask,
            adj_llr_mat * comp_mat - 4.0 * incomp_mat + mod_bonus - open_pen,
            -np.inf,
        )

        best_arch_per_pair = np.argmax(eff_mat, axis=1)
        best_util_per_pair = eff_mat[np.arange(len(s_arr)), best_arch_per_pair]
        best_comp_per_pair = comp_mat[np.arange(len(s_arr)), best_arch_per_pair]

        seg_lookup: dict[tuple[int, int], tuple[float, int, float]] = {}
        for k in range(len(s_arr)):
            u = float(best_util_per_pair[k])
            if u > 0.0:
                seg_lookup[(int(s_arr[k]), int(e_arr[k]))] = (
                    u,
                    int(best_arch_per_pair[k]),
                    float(best_comp_per_pair[k]),
                )

        if not seg_lookup:
            return []

        dp = np.zeros(m + 1, dtype=np.float64)
        back_prev = np.full(m + 1, -1, dtype=np.int32)
        back_arch = np.full(m + 1, int(LocusArchetype.UNKNOWN), dtype=np.int32)
        back_comp = np.zeros(m + 1, dtype=np.float64)
        back_score = np.zeros(m + 1, dtype=np.float64)

        for j in range(1, m + 1):
            dp[j] = dp[j - 1]
            back_prev[j] = j - 1
            back_arch[j] = int(LocusArchetype.UNKNOWN)

            min_i = max(0, j - self.max_locus_genes)
            for i in range(min_i, j - min_seg_len + 1):
                entry = seg_lookup.get((i, j))
                if entry is not None:
                    u, a_id, c_val = entry
                    cand = dp[i] + u
                    if cand > dp[j]:
                        dp[j] = cand
                        back_prev[j] = i
                        back_arch[j] = a_id
                        back_comp[j] = c_val
                        back_score[j] = u

        raw_segments: list[tuple[int, int, LocusArchetype, float, float]] = []
        curr = m
        max_snap_bound = m
        while curr > 0:
            prev = int(back_prev[curr])
            a_id = int(back_arch[curr])
            if a_id != int(LocusArchetype.UNKNOWN):
                arch = LocusArchetype(a_id)
                min_genes_arch = (
                    1
                    if (self.allow_single_gene_synthase and arch == LocusArchetype.CPS_SYNTHASE)
                    else self.min_locus_genes
                )
                min_snap_bound = prev
                while min_snap_bound > 0 and int(back_arch[min_snap_bound]) == int(LocusArchetype.UNKNOWN):
                    min_snap_bound = int(back_prev[min_snap_bound])
                t_start, t_end = self._trim_boundaries(
                    prev,
                    curr,
                    arch,
                    top_roles,
                    biosyn_prob,
                    island_strands,
                    prefixes,
                    starts=island_starts,
                    ends=island_ends,
                    min_bound=min_snap_bound,
                    max_bound=max_snap_bound,
                )
                if t_end - t_start >= min_genes_arch:
                    c_re, inc_re, llr_re = self.classifier.score_segments(
                        np.array([t_start], dtype=np.intp),
                        np.array([t_end], dtype=np.intp),
                        prefixes=prefixes,
                    )
                    comp_val = float(c_re[0, a_id])
                    incomp_val = float(inc_re[0, a_id])
                    t_pen = float(pref_trans[t_end - 1] - pref_trans[t_start])
                    score_val = float(max(0.0, llr_re[0, a_id] - t_pen) * comp_val * (1.0 - incomp_val))
                    if comp_val >= self.min_completeness and incomp_val <= self.max_incompatibility:
                        raw_segments.append((t_start, t_end, arch, comp_val, score_val))
                        max_snap_bound = t_start
                        curr = min(prev, t_start)
                        continue
                    raw_segments.append((prev, curr, arch, float(back_comp[curr]), float(back_score[curr])))
                    max_snap_bound = prev
                else:
                    raw_segments.append((prev, curr, arch, float(back_comp[curr]), float(back_score[curr])))
                    max_snap_bound = prev
            curr = prev

        raw_segments.reverse()
        return raw_segments

    def _compile_virtual_island(
        self,
        contig_ids: set[int],
        pieces: tuple[tuple[int, int, np.ndarray[tuple[int], np.dtype[np.intp]]], ...],
        open_5prime: bool,
        open_3prime: bool,
        probs: np.ndarray[tuple[int, int], np.dtype[np.float64]],
        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]],
    ) -> _BoundaryIsland:
        """Build virtual coordinates and run `_solve_island_dp` on a sequence of oriented contig pieces."""
        canon_g = np.concatenate([p[2] for p in pieces])
        m = len(canon_g)
        v_probs = probs[canon_g]
        v_starts = np.empty(m, dtype=np.int32)
        v_ends = np.empty(m, dtype=np.int32)
        v_strands = np.empty(m, dtype=np.int8)

        cur_coord = 0
        offset = 0
        for _, step_sign, p_idx in pieces:
            p_len = len(p_idx)
            for k in range(p_len):
                g = int(p_idx[k])
                g_len = max(1, int(ends[g]) - int(starts[g]))
                if k == 0:
                    gap = 50 if offset > 0 else 0
                else:
                    g_prev = int(p_idx[k - 1])
                    gap = int(starts[g]) - int(ends[g_prev]) if step_sign > 0 else int(starts[g_prev]) - int(ends[g])
                cur_coord = max(0, cur_coord + gap)
                v_starts[offset + k] = cur_coord
                cur_coord += g_len
                v_ends[offset + k] = cur_coord
                v_strands[offset + k] = int(strands[g]) * step_sign
            offset += p_len

        dp_segs = self._solve_island_dp(v_probs, v_starts, v_ends, v_strands)
        tot_score = sum(float(s[4]) for s in dp_segs)

        return _BoundaryIsland(
            contig_ids=contig_ids,
            pieces=pieces,
            canon_g_indices=canon_g,
            open_5prime=open_5prime,
            open_3prime=open_3prime,
            v_probs=v_probs,
            v_starts=v_starts,
            v_ends=v_ends,
            v_strands=v_strands,
            dp_segments=dp_segs,
            total_score=tot_score,
        )

    def _machinery_mask(
        self,
        g_indices: np.ndarray[tuple[int], np.dtype[np.intp]],
        probs: np.ndarray[tuple[int, int], np.dtype[np.float64]],
        top_roles: np.ndarray[tuple[int], np.dtype[np.int32]],
    ) -> int:
        """Compute bitmask of strict single-copy transport/polymerization roles present in `g_indices`."""
        single_copy_roles = {
            int(GeneRole.TRANSLOCON),
            int(GeneRole.FLIPPASE),
            int(GeneRole.POLYMERASE),
            int(GeneRole.ABC_PERMEASE),
            int(GeneRole.ABC_ATPASE),
            int(GeneRole.TRANSMEMBRANE_SYNTHASE),
        }
        mask = 0
        for g in g_indices:
            r = int(top_roles[g])
            if r in single_copy_roles and float(probs[g, r]) >= self.min_machinery_prob:
                mask |= 1 << r
        return mask

    def _stitch_boundary_islands(
        self,
        islands: list[_BoundaryIsland],
        probs: np.ndarray[tuple[int, int], np.dtype[np.float64]],
        top_roles: 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]],
        partials: np.ndarray[tuple[int], np.dtype[np.bool_]] | None = None,
    ) -> list[_BoundaryIsland]:
        """Greedily stitch complementary boundary islands (`open_3prime` + `open_5prime`) via virtual ACP-DP."""
        if len(islands) <= 1:
            return islands

        t = self.classifier.tensors
        biosyn_prob_all = probs[:, t.biosynthetic_mask].sum(axis=1)

        active = list(islands)
        while len(active) > 1:
            best_gain = 0.0
            best_pair: tuple[int, int, _BoundaryIsland] | None = None

            for u_i, isl_u in enumerate(active):
                if not isl_u.open_3prime:
                    continue
                m_u = len(isl_u.canon_g_indices)
                for d_i, isl_d in enumerate(active):
                    if u_i == d_i or not isl_d.open_5prime:
                        continue
                    if not isl_u.contig_ids.isdisjoint(isl_d.contig_ids):
                        continue

                    combined = self._compile_virtual_island(
                        contig_ids=isl_u.contig_ids | isl_d.contig_ids,
                        pieces=isl_u.pieces + isl_d.pieces,
                        open_5prime=isl_u.open_5prime,
                        open_3prime=isl_d.open_3prime,
                        probs=probs,
                        starts=starts,
                        ends=ends,
                        strands=strands,
                    )

                    # Find whether DP produced a segment crossing the contig break `m_u`
                    cross_segs = [s for s in combined.dp_segments if s[0] < m_u < s[1]]
                    if not cross_segs:
                        continue

                    seg_s, seg_e, arch, comp_cross, _ = cross_segs[0]
                    a_id = int(arch)
                    left_g = combined.canon_g_indices[seg_s:m_u]
                    right_g = combined.canon_g_indices[m_u:seg_e]

                    # Single-copy core machinery disjointness (excluding a gene sliced in half right at the break)
                    u_last_role = int(top_roles[left_g[-1]])
                    d_first_role = int(top_roles[right_g[0]])
                    is_split_break_gene = (u_last_role == d_first_role) or (
                        partials is not None and (bool(partials[left_g[-1]]) or bool(partials[right_g[0]]))
                    )
                    left_inner = left_g[:-1] if (u_last_role == d_first_role and len(left_g) > 1) else left_g
                    right_inner = right_g[1:] if (u_last_role == d_first_role and len(right_g) > 1) else right_g
                    if (
                        self._machinery_mask(left_inner, probs, top_roles)
                        & self._machinery_mask(right_inner, probs, top_roles)
                    ) != 0:
                        continue

                    # Require active biosynthetic genes on the canonical +1 strand immediately flanking the break `m_u`
                    left_edge_g = left_g[-min(len(left_g), 2) :]
                    right_edge_g = right_g[: min(len(right_g), 2)]
                    left_edge_v = combined.v_strands[m_u - len(left_edge_g) : m_u]
                    right_edge_v = combined.v_strands[m_u : m_u + len(right_edge_g)]
                    if not bool(np.any((biosyn_prob_all[left_edge_g] >= 0.25) & (left_edge_v > 0))):
                        continue
                    if not bool(np.any((biosyn_prob_all[right_edge_g] >= 0.25) & (right_edge_v > 0))):
                        continue

                    # Verify canonical strand coherence and biosynthetic density on both sides of the break
                    strand_frac_l = float(np.mean(combined.v_strands[seg_s:m_u] > 0))
                    strand_frac_r = float(np.mean(combined.v_strands[m_u:seg_e] > 0))
                    if strand_frac_l < 0.70 or strand_frac_r < 0.70:
                        continue

                    active_frac_l = float(np.mean(biosyn_prob_all[left_g] >= self.min_biosynthetic_prob))
                    active_frac_r = float(np.mean(biosyn_prob_all[right_g] >= self.min_biosynthetic_prob))
                    if active_frac_l < 0.50 or active_frac_r < 0.50:
                        continue

                    # Verify both sides of the break are individually compatible with `arch`
                    len_l = len(left_g)
                    len_r = len(right_g)
                    tot_lr = len_l + len_r
                    pref_lr = self.classifier.precompute_prefixes(probs[combined.canon_g_indices[seg_s:seg_e]])
                    comp_mat, inc_mat, llr_mat = self.classifier.score_segments(
                        np.array([0, 0, len_l], dtype=np.intp),
                        np.array([tot_lr, len_l, tot_lr], dtype=np.intp),
                        prefixes=pref_lr,
                    )
                    comp_l = float(comp_mat[1, a_id])
                    comp_r = float(comp_mat[2, a_id])
                    inc_l = float(inc_mat[1, a_id])
                    inc_r = float(inc_mat[2, a_id])
                    llr_l = float(llr_mat[1, a_id])
                    llr_r = float(llr_mat[2, a_id])

                    if inc_l > self.max_incompatibility or inc_r > self.max_incompatibility:
                        continue
                    if llr_l < 0.50 * self.min_mean_llr * len_l or llr_r < 0.50 * self.min_mean_llr * len_r:
                        continue

                    # Complementary module gating: check whether combining both halves strictly increases `arch` completeness
                    has_complementary_modules = comp_cross > max(comp_l, comp_r) + 1e-4

                    # Check standalone segments overlapping left_g in isl_u and right_g in isl_d
                    u_overlaps = [s for s in isl_u.dp_segments if s[1] > seg_s]
                    d_overlaps = [s for s in isl_d.dp_segments if s[0] < (seg_e - m_u)]
                    if u_overlaps and d_overlaps and not has_complementary_modules:
                        continue

                    # When one side already meets min_completeness for `arch`, require either complementary modules
                    # OR a pure sub-threshold cargo continuation (without its own standalone locus) / split boundary gene
                    if comp_l >= self.min_completeness:
                        if (
                            inc_r > 0.15
                            or llr_r < 0.90 * self.min_mean_llr * len_r
                            or active_frac_r < 0.65
                            or strand_frac_r < 0.80
                        ):
                            continue
                        if not has_complementary_modules:
                            if d_overlaps:
                                continue
                            if not is_split_break_gene and (inc_r > 0.05 or llr_r < 1.25 * self.min_mean_llr * len_r):
                                continue
                    if comp_r >= self.min_completeness:
                        if (
                            inc_l > 0.15
                            or llr_l < 0.90 * self.min_mean_llr * len_l
                            or active_frac_l < 0.65
                            or strand_frac_l < 0.80
                        ):
                            continue
                        if not has_complementary_modules:
                            if u_overlaps:
                                continue
                            if not is_split_break_gene and (inc_l > 0.05 or llr_l < 1.25 * self.min_mean_llr * len_l):
                                continue

                    # Compute archetype-consistent standalone baseline under `a_id` so sub-archetype halves
                    # (e.g. a 3' CPS_WZY half that resembles LPS_WZY in isolation) are not double-penalized
                    u_non_cross = sum(float(s[4]) for s in isl_u.dp_segments if s[1] <= seg_s)
                    d_non_cross = sum(float(s[4]) for s in isl_d.dp_segments if s[0] >= (seg_e - m_u))
                    base_l = max(0.0, llr_l) * comp_l * (1.0 - inc_l) if comp_l >= self.min_completeness else 0.0
                    base_r = max(0.0, llr_r) * comp_r * (1.0 - inc_r) if comp_r >= self.min_completeness else 0.0
                    raw_gain = combined.total_score - (u_non_cross + d_non_cross + base_l + base_r)
                    if raw_gain <= 0.0:
                        continue

                    mean_density = 0.5 * ((llr_l / len_l) + (llr_r / len_r))
                    gain = (
                        raw_gain * (0.5 + 0.5 * active_frac_l * active_frac_r) * (strand_frac_l * strand_frac_r)
                        + 2.0 * mean_density * comp_cross
                    )
                    if gain > best_gain:
                        best_gain = gain
                        best_pair = (u_i, d_i, combined)

            if best_pair is None:
                break

            u_idx, d_idx, merged_isl = best_pair
            next_active = [isl for idx, isl in enumerate(active) if idx != u_idx and idx != d_idx]
            next_active.append(merged_isl)
            active = next_active

        return active

    def _build_single_contig_locus(
        self,
        c_id: int,
        g_indices: np.ndarray[tuple[int], np.dtype[np.intp]],
        arch: LocusArchetype,
        comp: float,
        score: float,
        is_tandem: bool,
        genes: Genes,
        top_roles: np.ndarray[tuple[int], np.dtype[np.int32]],
        frameshifts: np.ndarray[tuple[int], np.dtype[np.bool_]],
        cluster_arr: 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]],
    ) -> Locus:
        """Construct a scalar `Locus` from gene indices on a single contig."""
        n_seg = len(g_indices)
        seg_genes = genes[g_indices]
        seg_roles = top_roles[g_indices]
        seg_fs = frameshifts[g_indices]
        seg_cids = cluster_arr[g_indices]
        seg_starts = starts[g_indices]
        seg_ends = ends[g_indices]
        seg_strands = strands[g_indices]

        nodes = tuple(
            GeneNode(int(seg_roles[k]), cluster_id=int(seg_cids[k]), is_frameshift=bool(seg_fs[k]))
            for k in range(n_seg)
        )
        edges = tuple(
            IntergenicEdge(
                source=k,
                target=k + 1,
                edge_type=IntergenicType.from_adjacent(
                    int(seg_strands[k]),
                    int(seg_strands[k + 1]),
                    gap_bp=int(seg_starts[k + 1]) - int(seg_ends[k]),
                ),
            )
            for k in range(n_seg - 1)
        )

        dom_strand = Strand.FORWARD if int(np.sum(seg_strands)) >= 0 else Strand.REVERSE
        locus_interval = Interval(int(seg_starts.min()), int(seg_ends.max()), dom_strand)

        return Locus(
            nodes=nodes,
            edges=edges,
            genes=seg_genes,
            interval=locus_interval,
            contig_index=int(c_id),
            archetype=arch,
            completeness=comp,
            score=score,
            is_tandem_split=is_tandem,
        )

    def _emit_boundary_island_loci(
        self,
        isl: _BoundaryIsland,
        genes: Genes,
        top_roles: np.ndarray[tuple[int], np.dtype[np.int32]],
        frameshifts: np.ndarray[tuple[int], np.dtype[np.bool_]],
        cluster_arr: 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]],
    ) -> list[Locus]:
        """Convert each DP segment of a `_BoundaryIsland` into a single-contig or multi-contig `Locus`."""
        if not isl.dp_segments:
            return []

        m = len(isl.canon_g_indices)
        piece_ids = np.empty(m, dtype=np.int32)
        contig_ids = np.empty(m, dtype=np.int32)
        step_signs = np.empty(m, dtype=np.int8)
        offset = 0
        for p_idx, (c_id, s_sign, p_arr) in enumerate(isl.pieces):
            p_len = len(p_arr)
            piece_ids[offset : offset + p_len] = p_idx
            contig_ids[offset : offset + p_len] = c_id
            step_signs[offset : offset + p_len] = s_sign
            offset += p_len

        is_tandem = len(isl.dp_segments) > 1
        out_loci: list[Locus] = []

        for seg_s, seg_e, arch, comp, score in isl.dp_segments:
            g_indices = isl.canon_g_indices[seg_s:seg_e]
            n_seg = len(g_indices)
            if n_seg == 0:
                continue

            seg_pieces = piece_ids[seg_s:seg_e]
            seg_steps = step_signs[seg_s:seg_e]
            seg_contigs = contig_ids[seg_s:seg_e]

            # If this segment lies wholly within a single contig piece, emit a normal single-contig Locus
            if bool(np.all(seg_pieces == seg_pieces[0])):
                asc_idx = g_indices if int(seg_steps[0]) > 0 else g_indices[::-1]
                out_loci.append(
                    self._build_single_contig_locus(
                        c_id=int(seg_contigs[0]),
                        g_indices=asc_idx,
                        arch=arch,
                        comp=comp,
                        score=score,
                        is_tandem=is_tandem,
                        genes=genes,
                        top_roles=top_roles,
                        frameshifts=frameshifts,
                        cluster_arr=cluster_arr,
                        starts=starts,
                        ends=ends,
                        strands=strands,
                    )
                )
                continue

            # Otherwise, emit a stitched multi-contig Locus in canonical 5' -> 3' operon order
            seg_genes = genes[g_indices]
            seg_roles = top_roles[g_indices]
            seg_fs = frameshifts[g_indices]
            seg_cids = cluster_arr[g_indices]
            seg_starts = starts[g_indices]
            seg_ends = ends[g_indices]

            nodes = tuple(
                GeneNode(int(seg_roles[k]), cluster_id=int(seg_cids[k]), is_frameshift=bool(seg_fs[k]))
                for k in range(n_seg)
            )
            edges_list: list[IntergenicEdge] = []
            for k in range(n_seg - 1):
                g_u = int(g_indices[k])
                g_v = int(g_indices[k + 1])
                s_u = int(strands[g_u]) * int(seg_steps[k])
                s_v = int(strands[g_v]) * int(seg_steps[k + 1])
                if seg_pieces[k] == seg_pieces[k + 1]:
                    gap_bp = (
                        int(starts[g_v]) - int(ends[g_u]) if seg_steps[k] > 0 else int(starts[g_u]) - int(ends[g_v])
                    )
                    etype = IntergenicType.from_adjacent(s_u, s_v, gap_bp=gap_bp)
                else:
                    orient = IntergenicType.from_adjacent(s_u, s_v, gap_bp=0).orientation
                    etype = IntergenicType(orient, gap_bin=14)
                edges_list.append(IntergenicEdge(source=k, target=k + 1, edge_type=etype))

            locus_interval = Interval(int(seg_starts.min()), int(seg_ends.max()), Strand.FORWARD)
            out_loci.append(
                Locus(
                    nodes=nodes,
                    edges=tuple(edges_list),
                    genes=seg_genes,
                    interval=locus_interval,
                    contig_index=int(seg_contigs[0]),
                    archetype=arch,
                    completeness=comp,
                    score=score,
                    is_tandem_split=is_tandem,
                )
            )

        return out_loci

    def segment(
        self,
        genes: Genes,
        role_probs: np.ndarray | Sequence[int | GeneRole],
        *,
        clusters: Sequence[int] | np.ndarray | None = None,
    ) -> Loci:
        """Segment a [`Genes`][serogen.genes.models.Genes] container and role predictions into [`Loci`][serogen.loci.models.Loci]."""
        n_genes = len(genes)
        if n_genes == 0:
            return Loci.empty()

        probs = self.classifier.to_probability_matrix(role_probs)
        if len(probs) != n_genes:
            raise ValueError(f"Length of role_probs ({len(probs)}) does not match len(genes) ({n_genes}).")

        t = self.classifier.tensors
        non_unk_probs = probs.copy()
        non_unk_probs[:, int(GeneRole.UNKNOWN)] = -1.0
        best_non_unk_roles = np.argmax(non_unk_probs, axis=1).astype(np.int32)

        top_roles = np.argmax(probs, axis=1).astype(np.int32)
        biosyn_prob_all = probs[:, t.biosynthetic_mask].sum(axis=1)
        mge_prob_all = probs[:, int(GeneRole.TRANSPOSASE)] + probs[:, int(GeneRole.INTEGRASE)]
        use_non_unk = (top_roles == int(GeneRole.UNKNOWN)) & (
            (biosyn_prob_all + mge_prob_all) >= self.min_biosynthetic_prob
        )
        top_roles = np.where(use_non_unk, best_non_unk_roles, top_roles).astype(np.int32)

        starts = genes.intervals.starts
        ends = genes.intervals.ends
        strands = genes.intervals.strands
        contig_indices = (
            genes.contig_indices if len(genes.contig_indices) == n_genes else np.zeros(n_genes, dtype=np.uint32)
        )
        frameshifts = genes.has_frameshift
        partials = genes.is_partial if len(genes.is_partial) == n_genes else np.zeros(n_genes, dtype=np.bool_)
        cluster_arr = (
            np.asarray(clusters, dtype=np.int32) if clusters is not None else np.zeros(n_genes, dtype=np.int32)
        )

        unique_contigs = np.unique(contig_indices)
        multi_contig_mode = self.stitch_contig_splits and len(unique_contigs) > 1

        discovered_loci: list[Locus] = []
        boundary_islands: list[_BoundaryIsland] = []

        for c_id in unique_contigs:
            c_mask = contig_indices == c_id
            c_global_idx = np.nonzero(c_mask)[0]
            n_c = len(c_global_idx)
            if n_c == 0 or (
                not multi_contig_mode and not self.allow_single_gene_synthase and n_c < self.min_locus_genes
            ):
                continue

            c_starts = starts[c_global_idx]
            c_ends = ends[c_global_idx]
            c_strands = strands[c_global_idx]
            c_probs = probs[c_global_idx]
            c_partials = partials[c_global_idx]

            order = np.argsort(c_starts, kind="stable")
            c_global_idx = c_global_idx[order]
            c_starts = c_starts[order]
            c_ends = c_ends[order]
            c_strands = c_strands[order]
            c_probs = c_probs[order]
            c_partials = c_partials[order]

            contig_min_start = int(c_starts[0])
            contig_max_end = int(c_ends[-1])

            islands = self._find_candidate_islands(
                c_starts,
                c_ends,
                c_probs,
                allow_boundary_partials=multi_contig_mode,
            )
            for raw_isl_start, raw_isl_end in islands:
                raw_touches_start = (
                    (raw_isl_start <= 2 and int(c_starts[raw_isl_start]) - contig_min_start <= 2500)
                    or (int(c_starts[raw_isl_start]) - contig_min_start <= 1200)
                    or bool(c_partials[raw_isl_start])
                )
                raw_touches_end = (
                    (raw_isl_end - 1 >= n_c - 3 and contig_max_end - int(c_ends[raw_isl_end - 1]) <= 2500)
                    or (contig_max_end - int(c_ends[raw_isl_end - 1]) <= 1200)
                    or bool(c_partials[raw_isl_end - 1])
                )

                # If a contig island touches BOTH contig ends on opposite strands, split at the internal strand/gap switch
                sub_islands: list[tuple[int, int, bool, bool]] = [
                    (raw_isl_start, raw_isl_end, raw_touches_start, raw_touches_end)
                ]
                if multi_contig_mode and raw_touches_start and raw_touches_end and (raw_isl_end - raw_isl_start) >= 6:
                    raw_g = c_global_idx[raw_isl_start:raw_isl_end]
                    raw_act = raw_g[biosyn_prob_all[raw_g] >= self.min_biosynthetic_prob]
                    if len(raw_act) >= 4:
                        k_e = min(len(raw_act), 5)
                        s_g = raw_act[:k_e]
                        e_g = raw_act[-k_e:]
                        s_w = np.arange(k_e, 0, -1, dtype=np.float64) * biosyn_prob_all[s_g]
                        e_w = np.arange(1, k_e + 1, dtype=np.float64) * biosyn_prob_all[e_g]
                        s_dir = 1 if float(np.dot(strands[s_g], s_w)) >= 0.0 else -1
                        e_dir = 1 if float(np.dot(strands[e_g], e_w)) >= 0.0 else -1
                        if s_dir != e_dir:
                            best_k = raw_isl_start + (raw_isl_end - raw_isl_start) // 2
                            best_sep = -1e9
                            for k_split in range(raw_isl_start + 2, raw_isl_end - 1):
                                coh_l = float(np.mean(c_strands[raw_isl_start:k_split] == s_dir))
                                coh_r = float(np.mean(c_strands[k_split:raw_isl_end] == e_dir))
                                gap_k = max(0, int(c_starts[k_split]) - int(c_ends[k_split - 1]))
                                sep = coh_l + coh_r + 0.0005 * gap_k
                                if sep > best_sep:
                                    best_sep = sep
                                    best_k = k_split
                            sub_islands = [
                                (raw_isl_start, best_k, True, False),
                                (best_k, raw_isl_end, False, True),
                            ]

                for isl_start, isl_end, touches_start, touches_end in sub_islands:
                    if multi_contig_mode and (touches_start or touches_end):
                        isl_g_idx = c_global_idx[isl_start:isl_end]
                        active_g = isl_g_idx[biosyn_prob_all[isl_g_idx] >= self.min_biosynthetic_prob]
                        if len(active_g) == 0:
                            active_g = isl_g_idx
                        if touches_end and not touches_start:
                            prox_g = active_g[-min(len(active_g), 8) :]
                            weights = np.arange(1, len(prox_g) + 1, dtype=np.float64) * biosyn_prob_all[prox_g]
                        elif touches_start and not touches_end:
                            prox_g = active_g[: min(len(active_g), 8)]
                            weights = np.arange(len(prox_g), 0, -1, dtype=np.float64) * biosyn_prob_all[prox_g]
                        else:
                            prox_g = active_g
                            weights = biosyn_prob_all[prox_g]

                        dom_strand = 1 if float(np.dot(strands[prox_g], weights)) >= 0.0 else -1
                        if dom_strand >= 0:
                            canon_idx = isl_g_idx.copy()
                            open_5p = touches_start
                            open_3p = touches_end
                            step_sign = 1
                        else:
                            canon_idx = isl_g_idx[::-1].copy()
                            open_5p = touches_end
                            open_3p = touches_start
                            step_sign = -1

                        boundary_islands.append(
                            self._compile_virtual_island(
                                contig_ids={int(c_id)},
                                pieces=((int(c_id), step_sign, canon_idx),),
                                open_5prime=open_5p,
                                open_3prime=open_3p,
                                probs=probs,
                                starts=starts,
                                ends=ends,
                                strands=strands,
                            )
                        )
                        continue

                    isl_probs = c_probs[isl_start:isl_end]
                    isl_starts = c_starts[isl_start:isl_end]
                    isl_ends = c_ends[isl_start:isl_end]
                    isl_strands = c_strands[isl_start:isl_end]

                    segments = self._solve_island_dp(isl_probs, isl_starts, isl_ends, isl_strands)
                    is_tandem = len(segments) > 1

                    for seg_s, seg_e, arch, comp, score in segments:
                        g_indices = c_global_idx[isl_start + seg_s : isl_start + seg_e]
                        if len(g_indices) == 0:
                            continue
                        discovered_loci.append(
                            self._build_single_contig_locus(
                                c_id=int(c_id),
                                g_indices=g_indices,
                                arch=arch,
                                comp=comp,
                                score=score,
                                is_tandem=is_tandem,
                                genes=genes,
                                top_roles=top_roles,
                                frameshifts=frameshifts,
                                cluster_arr=cluster_arr,
                                starts=starts,
                                ends=ends,
                                strands=strands,
                            )
                        )

        if boundary_islands:
            stitched_islands = self._stitch_boundary_islands(
                boundary_islands,
                probs=probs,
                top_roles=top_roles,
                starts=starts,
                ends=ends,
                strands=strands,
                partials=partials,
            )
            for b_isl in stitched_islands:
                discovered_loci.extend(
                    self._emit_boundary_island_loci(
                        b_isl,
                        genes=genes,
                        top_roles=top_roles,
                        frameshifts=frameshifts,
                        cluster_arr=cluster_arr,
                        starts=starts,
                        ends=ends,
                        strands=strands,
                    )
                )

        if not discovered_loci:
            return Loci.empty()
        return Loci.from_list(discovered_loci)

segment

segment(genes: Genes, role_probs: ndarray | Sequence[int | GeneRole], *, clusters: Sequence[int] | ndarray | None = None) -> Loci

Segment a Genes container and role predictions into Loci.

Source code in src/serogen/loci/segmenter.py
def segment(
    self,
    genes: Genes,
    role_probs: np.ndarray | Sequence[int | GeneRole],
    *,
    clusters: Sequence[int] | np.ndarray | None = None,
) -> Loci:
    """Segment a [`Genes`][serogen.genes.models.Genes] container and role predictions into [`Loci`][serogen.loci.models.Loci]."""
    n_genes = len(genes)
    if n_genes == 0:
        return Loci.empty()

    probs = self.classifier.to_probability_matrix(role_probs)
    if len(probs) != n_genes:
        raise ValueError(f"Length of role_probs ({len(probs)}) does not match len(genes) ({n_genes}).")

    t = self.classifier.tensors
    non_unk_probs = probs.copy()
    non_unk_probs[:, int(GeneRole.UNKNOWN)] = -1.0
    best_non_unk_roles = np.argmax(non_unk_probs, axis=1).astype(np.int32)

    top_roles = np.argmax(probs, axis=1).astype(np.int32)
    biosyn_prob_all = probs[:, t.biosynthetic_mask].sum(axis=1)
    mge_prob_all = probs[:, int(GeneRole.TRANSPOSASE)] + probs[:, int(GeneRole.INTEGRASE)]
    use_non_unk = (top_roles == int(GeneRole.UNKNOWN)) & (
        (biosyn_prob_all + mge_prob_all) >= self.min_biosynthetic_prob
    )
    top_roles = np.where(use_non_unk, best_non_unk_roles, top_roles).astype(np.int32)

    starts = genes.intervals.starts
    ends = genes.intervals.ends
    strands = genes.intervals.strands
    contig_indices = (
        genes.contig_indices if len(genes.contig_indices) == n_genes else np.zeros(n_genes, dtype=np.uint32)
    )
    frameshifts = genes.has_frameshift
    partials = genes.is_partial if len(genes.is_partial) == n_genes else np.zeros(n_genes, dtype=np.bool_)
    cluster_arr = (
        np.asarray(clusters, dtype=np.int32) if clusters is not None else np.zeros(n_genes, dtype=np.int32)
    )

    unique_contigs = np.unique(contig_indices)
    multi_contig_mode = self.stitch_contig_splits and len(unique_contigs) > 1

    discovered_loci: list[Locus] = []
    boundary_islands: list[_BoundaryIsland] = []

    for c_id in unique_contigs:
        c_mask = contig_indices == c_id
        c_global_idx = np.nonzero(c_mask)[0]
        n_c = len(c_global_idx)
        if n_c == 0 or (
            not multi_contig_mode and not self.allow_single_gene_synthase and n_c < self.min_locus_genes
        ):
            continue

        c_starts = starts[c_global_idx]
        c_ends = ends[c_global_idx]
        c_strands = strands[c_global_idx]
        c_probs = probs[c_global_idx]
        c_partials = partials[c_global_idx]

        order = np.argsort(c_starts, kind="stable")
        c_global_idx = c_global_idx[order]
        c_starts = c_starts[order]
        c_ends = c_ends[order]
        c_strands = c_strands[order]
        c_probs = c_probs[order]
        c_partials = c_partials[order]

        contig_min_start = int(c_starts[0])
        contig_max_end = int(c_ends[-1])

        islands = self._find_candidate_islands(
            c_starts,
            c_ends,
            c_probs,
            allow_boundary_partials=multi_contig_mode,
        )
        for raw_isl_start, raw_isl_end in islands:
            raw_touches_start = (
                (raw_isl_start <= 2 and int(c_starts[raw_isl_start]) - contig_min_start <= 2500)
                or (int(c_starts[raw_isl_start]) - contig_min_start <= 1200)
                or bool(c_partials[raw_isl_start])
            )
            raw_touches_end = (
                (raw_isl_end - 1 >= n_c - 3 and contig_max_end - int(c_ends[raw_isl_end - 1]) <= 2500)
                or (contig_max_end - int(c_ends[raw_isl_end - 1]) <= 1200)
                or bool(c_partials[raw_isl_end - 1])
            )

            # If a contig island touches BOTH contig ends on opposite strands, split at the internal strand/gap switch
            sub_islands: list[tuple[int, int, bool, bool]] = [
                (raw_isl_start, raw_isl_end, raw_touches_start, raw_touches_end)
            ]
            if multi_contig_mode and raw_touches_start and raw_touches_end and (raw_isl_end - raw_isl_start) >= 6:
                raw_g = c_global_idx[raw_isl_start:raw_isl_end]
                raw_act = raw_g[biosyn_prob_all[raw_g] >= self.min_biosynthetic_prob]
                if len(raw_act) >= 4:
                    k_e = min(len(raw_act), 5)
                    s_g = raw_act[:k_e]
                    e_g = raw_act[-k_e:]
                    s_w = np.arange(k_e, 0, -1, dtype=np.float64) * biosyn_prob_all[s_g]
                    e_w = np.arange(1, k_e + 1, dtype=np.float64) * biosyn_prob_all[e_g]
                    s_dir = 1 if float(np.dot(strands[s_g], s_w)) >= 0.0 else -1
                    e_dir = 1 if float(np.dot(strands[e_g], e_w)) >= 0.0 else -1
                    if s_dir != e_dir:
                        best_k = raw_isl_start + (raw_isl_end - raw_isl_start) // 2
                        best_sep = -1e9
                        for k_split in range(raw_isl_start + 2, raw_isl_end - 1):
                            coh_l = float(np.mean(c_strands[raw_isl_start:k_split] == s_dir))
                            coh_r = float(np.mean(c_strands[k_split:raw_isl_end] == e_dir))
                            gap_k = max(0, int(c_starts[k_split]) - int(c_ends[k_split - 1]))
                            sep = coh_l + coh_r + 0.0005 * gap_k
                            if sep > best_sep:
                                best_sep = sep
                                best_k = k_split
                        sub_islands = [
                            (raw_isl_start, best_k, True, False),
                            (best_k, raw_isl_end, False, True),
                        ]

            for isl_start, isl_end, touches_start, touches_end in sub_islands:
                if multi_contig_mode and (touches_start or touches_end):
                    isl_g_idx = c_global_idx[isl_start:isl_end]
                    active_g = isl_g_idx[biosyn_prob_all[isl_g_idx] >= self.min_biosynthetic_prob]
                    if len(active_g) == 0:
                        active_g = isl_g_idx
                    if touches_end and not touches_start:
                        prox_g = active_g[-min(len(active_g), 8) :]
                        weights = np.arange(1, len(prox_g) + 1, dtype=np.float64) * biosyn_prob_all[prox_g]
                    elif touches_start and not touches_end:
                        prox_g = active_g[: min(len(active_g), 8)]
                        weights = np.arange(len(prox_g), 0, -1, dtype=np.float64) * biosyn_prob_all[prox_g]
                    else:
                        prox_g = active_g
                        weights = biosyn_prob_all[prox_g]

                    dom_strand = 1 if float(np.dot(strands[prox_g], weights)) >= 0.0 else -1
                    if dom_strand >= 0:
                        canon_idx = isl_g_idx.copy()
                        open_5p = touches_start
                        open_3p = touches_end
                        step_sign = 1
                    else:
                        canon_idx = isl_g_idx[::-1].copy()
                        open_5p = touches_end
                        open_3p = touches_start
                        step_sign = -1

                    boundary_islands.append(
                        self._compile_virtual_island(
                            contig_ids={int(c_id)},
                            pieces=((int(c_id), step_sign, canon_idx),),
                            open_5prime=open_5p,
                            open_3prime=open_3p,
                            probs=probs,
                            starts=starts,
                            ends=ends,
                            strands=strands,
                        )
                    )
                    continue

                isl_probs = c_probs[isl_start:isl_end]
                isl_starts = c_starts[isl_start:isl_end]
                isl_ends = c_ends[isl_start:isl_end]
                isl_strands = c_strands[isl_start:isl_end]

                segments = self._solve_island_dp(isl_probs, isl_starts, isl_ends, isl_strands)
                is_tandem = len(segments) > 1

                for seg_s, seg_e, arch, comp, score in segments:
                    g_indices = c_global_idx[isl_start + seg_s : isl_start + seg_e]
                    if len(g_indices) == 0:
                        continue
                    discovered_loci.append(
                        self._build_single_contig_locus(
                            c_id=int(c_id),
                            g_indices=g_indices,
                            arch=arch,
                            comp=comp,
                            score=score,
                            is_tandem=is_tandem,
                            genes=genes,
                            top_roles=top_roles,
                            frameshifts=frameshifts,
                            cluster_arr=cluster_arr,
                            starts=starts,
                            ends=ends,
                            strands=strands,
                        )
                    )

    if boundary_islands:
        stitched_islands = self._stitch_boundary_islands(
            boundary_islands,
            probs=probs,
            top_roles=top_roles,
            starts=starts,
            ends=ends,
            strands=strands,
            partials=partials,
        )
        for b_isl in stitched_islands:
            discovered_loci.extend(
                self._emit_boundary_island_loci(
                    b_isl,
                    genes=genes,
                    top_roles=top_roles,
                    frameshifts=frameshifts,
                    cluster_arr=cluster_arr,
                    starts=starts,
                    ends=ends,
                    strands=strands,
                )
            )

    if not discovered_loci:
        return Loci.empty()
    return Loci.from_list(discovered_loci)

Locus & Graph Models (serogen.loci.models)

models

Data models for biosynthetic gene clusters (Locus, Loci) and syntenic graph primitives.

Classes:

Name Description
IntergenicOrientation

Relative transcriptional orientation between two adjacent genes in a locus.

IntergenicType

32-bit integer encoding for an intergenic synteny transition between adjacent genes.

IntergenicEdge

Directed synteny edge connecting adjacent gene nodes (source -> target) in a Locus.

GeneNode

32-bit integer encoding for a gene node inside a biosynthetic locus graph.

Locus

Scalar view and directed syntenic graph of a single biosynthetic gene cluster.

Loci

Structure-of-Arrays (SoA) batch container for biosynthetic gene clusters (Locus).

IntergenicOrientation

Bases: IntEnum

Relative transcriptional orientation between two adjacent genes in a locus.

Source code in src/serogen/loci/models.py
class IntergenicOrientation(IntEnum):
    """Relative transcriptional orientation between two adjacent genes in a locus."""

    CODIRECTIONAL = 0  # -> -> or <- <-
    CONVERGENT = 1  # -> <-
    DIVERGENT = 2  # <- ->

IntergenicType

Bases: IntNode

32-bit integer encoding for an intergenic synteny transition between adjacent genes.

Bit 0..1 (2 bits): Relative transcriptional orientation (IntergenicOrientation, 0..2). Bit 2..5 (4 bits): Discretized intergenic distance bin (0 = overlap, 1 = <50bp, 2 = 50..200bp, 3 = >200bp, 15 = healed MGE scar). Bit 31 (1 bit): TAG_EDGE (1 = intermediate edge node in bipartite graphs).

Methods:

Name Description
from_adjacent

Construct an IntergenicType from adjacent gene strands and base-pair distance.

Attributes:

Name Type Description
orientation IntergenicOrientation

Relative transcriptional orientation between source and target genes.

gap_bin int

Discretized intergenic distance bin (0..15).

is_contig_break bool

True if this edge bridges a contig boundary in a fragmented assembly.

is_healed_scar bool

True if this edge bridges a healed mobile genetic element (MGE) insertion.

Source code in src/serogen/loci/models.py
class IntergenicType(IntNode):
    """32-bit integer encoding for an intergenic synteny transition between adjacent genes.

    Bit 0..1 (2 bits):  Relative transcriptional orientation (`IntergenicOrientation`, 0..2).
    Bit 2..5 (4 bits):  Discretized intergenic distance bin (0 = overlap, 1 = <50bp, 2 = 50..200bp,
                        3 = >200bp, 15 = healed MGE scar).
    Bit 31 (1 bit):     `TAG_EDGE` (`1` = intermediate edge node in bipartite graphs).
    """

    ORIENTATION = BitField(shift=0, width=2)
    GAP_BIN = BitField(shift=2, width=4)
    TAG_EDGE = BitField(shift=31, width=1)

    def __new__(
        cls,
        val_or_orientation: int | IntergenicOrientation = IntergenicOrientation.CODIRECTIONAL,
        gap_bin: int | None = None,
    ) -> Self:
        if gap_bin is None and int(val_or_orientation) & TAG_EDGE:
            return super().__new__(cls, int(val_or_orientation))
        g_bin = 1 if gap_bin is None else int(gap_bin)
        packed = cls.ORIENTATION.pack(int(val_or_orientation)) | cls.GAP_BIN.pack(g_bin) | cls.TAG_EDGE.pack(1)
        return super().__new__(cls, packed)

    @classmethod
    def from_adjacent(cls, strand_a: int, strand_b: int, gap_bp: int = 0) -> Self:
        """Construct an `IntergenicType` from adjacent gene strands and base-pair distance."""
        if strand_a == strand_b:
            orient = IntergenicOrientation.CODIRECTIONAL
        elif strand_a >= 0 and strand_b < 0:
            orient = IntergenicOrientation.CONVERGENT
        else:
            orient = IntergenicOrientation.DIVERGENT

        if gap_bp < 0:
            g_bin = 0
        elif gap_bp < 50:
            g_bin = 1
        elif gap_bp <= 200:
            g_bin = 2
        else:
            g_bin = 3
        return cls(orient, gap_bin=g_bin)

    @property
    def orientation(self) -> IntergenicOrientation:
        """Relative transcriptional orientation between source and target genes."""
        return IntergenicOrientation(int(self.ORIENTATION))

    @property
    def gap_bin(self) -> int:
        """Discretized intergenic distance bin (`0..15`)."""
        return int(self.GAP_BIN)

    @property
    def is_contig_break(self) -> bool:
        """True if this edge bridges a contig boundary in a fragmented assembly."""
        return int(self.GAP_BIN) == 14

    @property
    def is_healed_scar(self) -> bool:
        """True if this edge bridges a healed mobile genetic element (MGE) insertion."""
        return int(self.GAP_BIN) == 15

    def __repr__(self) -> str:
        arrows = {
            IntergenicOrientation.CODIRECTIONAL: "->->",
            IntergenicOrientation.CONVERGENT: "-><-",
            IntergenicOrientation.DIVERGENT: "<-->",
        }[self.orientation]
        if self.is_healed_scar:
            suffix = ",scar"
        elif self.is_contig_break:
            suffix = ",break"
        else:
            suffix = f",bin={self.gap_bin}"
        return f"IntergenicType({arrows}{suffix})"

orientation property

orientation: IntergenicOrientation

Relative transcriptional orientation between source and target genes.

gap_bin property

gap_bin: int

Discretized intergenic distance bin (0..15).

is_contig_break property

is_contig_break: bool

True if this edge bridges a contig boundary in a fragmented assembly.

is_healed_scar property

is_healed_scar: bool

True if this edge bridges a healed mobile genetic element (MGE) insertion.

from_adjacent classmethod

from_adjacent(strand_a: int, strand_b: int, gap_bp: int = 0) -> Self

Construct an IntergenicType from adjacent gene strands and base-pair distance.

Source code in src/serogen/loci/models.py
@classmethod
def from_adjacent(cls, strand_a: int, strand_b: int, gap_bp: int = 0) -> Self:
    """Construct an `IntergenicType` from adjacent gene strands and base-pair distance."""
    if strand_a == strand_b:
        orient = IntergenicOrientation.CODIRECTIONAL
    elif strand_a >= 0 and strand_b < 0:
        orient = IntergenicOrientation.CONVERGENT
    else:
        orient = IntergenicOrientation.DIVERGENT

    if gap_bp < 0:
        g_bin = 0
    elif gap_bp < 50:
        g_bin = 1
    elif gap_bp <= 200:
        g_bin = 2
    else:
        g_bin = 3
    return cls(orient, gap_bin=g_bin)

IntergenicEdge dataclass

Bases: DirectedEdge

Directed synteny edge connecting adjacent gene nodes (source -> target) in a Locus.

Source code in src/serogen/loci/models.py
@dataclass(slots=True, frozen=True)
class IntergenicEdge(DirectedEdge):
    """Directed synteny edge connecting adjacent gene nodes `(source -> target)` in a `Locus`."""

    source: int
    target: int
    edge_type: IntergenicType

GeneNode

Bases: IntNode

32-bit integer encoding for a gene node inside a biosynthetic locus graph.

Bit 0..4 (5 bits): Canonical GeneRole index (0..29). Bit 5 (1 bit): Frameshift / pseudogene indicator (0 = intact, 1 = frameshifted). Bit 6..30 (25 bits): Sequence homology cluster ID (0 = unclustered, 1..33,554,431 = cluster ID). Bit 31 (1 bit): TAG_EDGE (0 = primary gene node).

Methods:

Name Description
with_cluster

Return a copy of this GeneNode with an updated homology cluster_id.

Attributes:

Name Type Description
role_idx int

Integer GeneRole index (0..29).

role GeneRole

Canonical GeneRole enum member.

is_frameshift bool

True if this gene contains a frameshift indel or premature stop.

cluster_id int

Sequence homology cluster ID (0 if unclustered).

is_clustered bool

True if assigned to a homology cluster (cluster_id > 0).

is_mobile_element bool

True if this node is a mobile genetic element (TRANSPOSASE or INTEGRASE).

is_biosynthetic bool

True if this node is an active biosynthetic, modifier, or transport gene.

Source code in src/serogen/loci/models.py
class GeneNode(IntNode):
    """32-bit integer encoding for a gene node inside a biosynthetic locus graph.

    Bit 0..4 (5 bits):   Canonical `GeneRole` index (`0..29`).
    Bit 5 (1 bit):       Frameshift / pseudogene indicator (`0` = intact, `1` = frameshifted).
    Bit 6..30 (25 bits): Sequence homology cluster ID (`0` = unclustered, `1..33,554,431` = cluster ID).
    Bit 31 (1 bit):      `TAG_EDGE` (`0` = primary gene node).
    """

    ROLE_ID = BitField(shift=0, width=5)
    IS_FRAMESHIFT = BitField(shift=5, width=1)
    CLUSTER_ID = BitField(shift=6, width=25)
    TAG_EDGE = BitField(shift=31, width=1)

    def __new__(
        cls,
        val_or_role: int | str | bytes | GeneRole,
        cluster_id: int | None = None,
        is_frameshift: bool = False,
    ) -> Self:
        if (
            isinstance(val_or_role, (int, np.integer))
            and not isinstance(val_or_role, GeneRole)
            and cluster_id is None
            and not is_frameshift
            and int(val_or_role) >= len(GeneRole)
        ):
            return super().__new__(cls, int(val_or_role))

        role_enum = GeneRole.resolve(val_or_role) if not isinstance(val_or_role, GeneRole) else val_or_role
        cid = 0 if cluster_id is None else int(cluster_id)
        packed = (
            cls.ROLE_ID.pack(int(role_enum)) | cls.IS_FRAMESHIFT.pack(int(is_frameshift)) | cls.CLUSTER_ID.pack(cid)
        )
        return super().__new__(cls, packed)

    @property
    def role_idx(self) -> int:
        """Integer `GeneRole` index (`0..29`)."""
        return int(self.ROLE_ID)

    @property
    def role(self) -> GeneRole:
        """Canonical `GeneRole` enum member."""
        return GeneRole(int(self.ROLE_ID))

    @property
    def is_frameshift(self) -> bool:
        """True if this gene contains a frameshift indel or premature stop."""
        return bool(self.IS_FRAMESHIFT)

    @property
    def cluster_id(self) -> int:
        """Sequence homology cluster ID (`0` if unclustered)."""
        return int(self.CLUSTER_ID)

    @property
    def is_clustered(self) -> bool:
        """True if assigned to a homology cluster (`cluster_id > 0`)."""
        return int(self.CLUSTER_ID) > 0

    @property
    def is_mobile_element(self) -> bool:
        """True if this node is a mobile genetic element (`TRANSPOSASE` or `INTEGRASE`)."""
        return self.role.is_mobile_element

    @property
    def is_biosynthetic(self) -> bool:
        """True if this node is an active biosynthetic, modifier, or transport gene."""
        return self.role.is_biosynthetic

    def with_cluster(self, cluster_id: int) -> GeneNode:
        """Return a copy of this `GeneNode` with an updated homology `cluster_id`."""
        cleared = int(self) & ~type(self).CLUSTER_ID.mask
        return GeneNode(cleared | type(self).CLUSTER_ID.pack(int(cluster_id)))

    def __repr__(self) -> str:
        r_abbr = self.role.abbreviation
        fs = ",FS" if self.is_frameshift else ""
        cid = f",c={self.cluster_id}" if self.cluster_id > 0 else ""
        return f"GeneNode({r_abbr}{cid}{fs})"

role_idx property

role_idx: int

Integer GeneRole index (0..29).

role property

role: GeneRole

Canonical GeneRole enum member.

is_frameshift property

is_frameshift: bool

True if this gene contains a frameshift indel or premature stop.

cluster_id property

cluster_id: int

Sequence homology cluster ID (0 if unclustered).

is_clustered property

is_clustered: bool

True if assigned to a homology cluster (cluster_id > 0).

is_mobile_element property

is_mobile_element: bool

True if this node is a mobile genetic element (TRANSPOSASE or INTEGRASE).

is_biosynthetic property

is_biosynthetic: bool

True if this node is an active biosynthetic, modifier, or transport gene.

with_cluster

with_cluster(cluster_id: int) -> GeneNode

Return a copy of this GeneNode with an updated homology cluster_id.

Source code in src/serogen/loci/models.py
def with_cluster(self, cluster_id: int) -> GeneNode:
    """Return a copy of this `GeneNode` with an updated homology `cluster_id`."""
    cleared = int(self) & ~type(self).CLUSTER_ID.mask
    return GeneNode(cleared | type(self).CLUSTER_ID.pack(int(cluster_id)))

Locus dataclass

Bases: GraphModel

Scalar view and directed syntenic graph of a single biosynthetic gene cluster.

Embeds its constituent Genes container (self.genes), deriving per-gene intervals, strands, lengths, coding sequences, and frameshifts directly without duplicating metadata across parallel tuples.

Methods:

Name Description
with_name

Return a copy of this Locus with an updated identifier name.

canonicalize_strand

Orient the locus into canonical 5' -> 3' operon order (strand = +1).

heal_mobile_elements

Excise mobile genetic element (TRANSPOSASE, INTEGRASE) insertions and heal synteny edges.

from_genes

Construct a Locus from either a Genes container or a sequence of gene symbols/roles.

match

Compare this Locus against a target Locus (returns Match) or target Loci database (returns Matches).

Attributes:

Name Type Description
num_genes int

Number of genes in this locus.

start int

0-based inclusive start coordinate on the parent contig.

end int

0-based exclusive end coordinate on the parent contig.

strand int

Dominant transcriptional strand (+1 or -1) of the locus.

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

1D int8 array of per-gene transcriptional strands from self.genes.

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

1D int32 array of per-gene base-pair lengths from self.genes.

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

1D array of integer GeneRole indices (0..29) for each gene in the locus.

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

1D array of homology cluster_id values for each gene in the locus.

frameshifts ndarray[tuple[int], dtype[bool_]]

1D boolean array indicating which genes carry frameshift mutations.

role_mask int

32-bit integer presence bitmask of all GeneRoles present in this locus.

has_mobile_elements bool

True if this locus contains any TRANSPOSASE or INTEGRASE insertions.

has_mge bool

Alias for has_mobile_elements.

is_fragmented bool

True if this locus spans multiple contigs across an assembly break.

num_frameshifts int

Number of genes in this locus carrying frameshift mutations or premature stops.

frameshift_fraction float

Fraction of genes in this locus ([0.0, 1.0]) carrying frameshift mutations.

is_intact bool

True if this locus is on a single contig, has no mobile elements, and has no frameshifted genes.

category ArchetypeCategory

High-level ArchetypeCategory (Capsule, O-Antigen, Exopolysaccharide, Other) of this locus.

num_contigs int

Number of distinct contigs spanned by this locus.

Source code in src/serogen/loci/models.py
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
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
@dataclass(slots=True, frozen=True, eq=False)
class Locus(GraphModel):
    """Scalar view and directed syntenic graph of a single biosynthetic gene cluster.

    Embeds its constituent [`Genes`][serogen.genes.models.Genes] container (`self.genes`),
    deriving per-gene intervals, strands, lengths, coding sequences, and frameshifts directly
    without duplicating metadata across parallel tuples.
    """

    nodes: tuple[GeneNode, ...]
    edges: tuple[IntergenicEdge, ...]
    genes: Genes = field(default_factory=Genes.empty)
    interval: Interval = Interval(0, 0, Strand.FORWARD)
    contig_index: int = 0
    archetype: LocusArchetype = LocusArchetype.UNKNOWN
    completeness: float = 0.0
    score: float = 0.0
    is_tandem_split: bool = False
    name: str = ""

    def __eq__(self, other: object) -> bool:
        if not isinstance(other, Locus):
            return NotImplemented
        return bool(
            self.nodes == other.nodes
            and self.edges == other.edges
            and self.interval == other.interval
            and self.contig_index == other.contig_index
            and self.archetype == other.archetype
            and np.isclose(self.completeness, other.completeness)
            and np.isclose(self.score, other.score)
            and self.is_tandem_split == other.is_tandem_split
            and self.name == other.name
        )

    def __hash__(self) -> int:
        return hash(
            (
                self.nodes,
                self.edges,
                self.interval,
                self.contig_index,
                self.archetype,
                round(float(self.completeness), 6),
                round(float(self.score), 6),
                self.is_tandem_split,
                self.name,
            )
        )

    def with_name(self, name: str | bytes) -> Locus:
        """Return a copy of this `Locus` with an updated identifier `name`."""
        name_str = name.decode("utf-8", errors="replace") if isinstance(name, bytes) else str(name)
        return Locus(
            nodes=self.nodes,
            edges=self.edges,
            genes=self.genes,
            interval=self.interval,
            contig_index=self.contig_index,
            archetype=self.archetype,
            completeness=self.completeness,
            score=self.score,
            is_tandem_split=self.is_tandem_split,
            name=name_str,
        )

    @property
    def num_genes(self) -> int:
        """Number of genes in this locus."""
        return len(self.nodes)

    @property
    def start(self) -> int:
        """0-based inclusive start coordinate on the parent contig."""
        return self.interval.start

    @property
    def end(self) -> int:
        """0-based exclusive end coordinate on the parent contig."""
        return self.interval.end

    @property
    def strand(self) -> int:
        """Dominant transcriptional strand (`+1` or `-1`) of the locus."""
        return self.interval.strand

    @property
    def strands(self) -> np.ndarray[tuple[int], np.dtype[np.int8]]:
        """1D `int8` array of per-gene transcriptional strands from `self.genes`."""
        if len(self.genes) > 0:
            return self.genes.intervals.strands
        return np.ones(len(self.nodes), dtype=np.int8)

    @property
    def lengths(self) -> np.ndarray[tuple[int], np.dtype[np.int32]]:
        """1D `int32` array of per-gene base-pair lengths from `self.genes`."""
        if len(self.genes) > 0:
            return self.genes.intervals.lengths
        return np.full(len(self.nodes), 1000, dtype=np.int32)

    @property
    def roles(self) -> np.ndarray[tuple[int], np.dtype[np.int32]]:
        """1D array of integer `GeneRole` indices (`0..29`) for each gene in the locus."""
        if not self.nodes:
            return np.empty(0, dtype=np.int32)
        arr = np.fromiter((int(n) for n in self.nodes), dtype=np.uint32, count=len(self.nodes))
        return GeneNode.ROLE_ID.extract_array(arr).astype(np.int32)

    @property
    def cluster_ids(self) -> np.ndarray[tuple[int], np.dtype[np.int32]]:
        """1D array of homology `cluster_id` values for each gene in the locus."""
        if not self.nodes:
            return np.empty(0, dtype=np.int32)
        arr = np.fromiter((int(n) for n in self.nodes), dtype=np.uint32, count=len(self.nodes))
        return GeneNode.CLUSTER_ID.extract_array(arr).astype(np.int32)

    @property
    def frameshifts(self) -> np.ndarray[tuple[int], np.dtype[np.bool_]]:
        """1D boolean array indicating which genes carry frameshift mutations."""
        if not self.nodes:
            return np.empty(0, dtype=np.bool_)
        arr = np.fromiter((int(n) for n in self.nodes), dtype=np.uint32, count=len(self.nodes))
        return GeneNode.IS_FRAMESHIFT.extract_array(arr).astype(np.bool_)

    @property
    def role_mask(self) -> int:
        """32-bit integer presence bitmask of all `GeneRole`s present in this locus."""
        return roles_to_mask(self.roles)

    @property
    def has_mobile_elements(self) -> bool:
        """True if this locus contains any `TRANSPOSASE` or `INTEGRASE` insertions."""
        if not self.nodes:
            return False
        roles = self.roles
        return bool(np.any((roles == int(GeneRole.TRANSPOSASE)) | (roles == int(GeneRole.INTEGRASE))))

    @property
    def has_mge(self) -> bool:
        """Alias for `has_mobile_elements`."""
        return self.has_mobile_elements

    @property
    def is_fragmented(self) -> bool:
        """True if this locus spans multiple contigs across an assembly break."""
        if any(e.edge_type.is_contig_break for e in self.edges):
            return True
        c_idx = self.genes.contig_indices
        return len(c_idx) > 1 and not bool(np.all(c_idx == c_idx[0]))

    @property
    def num_frameshifts(self) -> int:
        """Number of genes in this locus carrying frameshift mutations or premature stops."""
        if not self.nodes:
            return 0
        return int(np.sum(self.frameshifts))

    @property
    def frameshift_fraction(self) -> float:
        """Fraction of genes in this locus (`[0.0, 1.0]`) carrying frameshift mutations."""
        if not self.nodes:
            return 0.0
        return float(self.num_frameshifts) / float(len(self.nodes))

    @property
    def is_intact(self) -> bool:
        """True if this locus is on a single contig, has no mobile elements, and has no frameshifted genes."""
        return not self.is_fragmented and not self.has_mobile_elements and self.num_frameshifts == 0

    @property
    def category(self) -> ArchetypeCategory:
        """High-level `ArchetypeCategory` (`Capsule`, `O-Antigen`, `Exopolysaccharide`, `Other`) of this locus."""
        return self.archetype.category

    @property
    def num_contigs(self) -> int:
        """Number of distinct contigs spanned by this locus."""
        c_idx = self.genes.contig_indices
        if len(c_idx) > 0:
            return int(len(np.unique(c_idx)))
        return 1

    def canonicalize_strand(self) -> Locus:
        """Orient the locus into canonical 5' -> 3' operon order (`strand = +1`).

        If `self.strand >= 0`, returns `self` unchanged. If `self.strand < 0`, reverses
        `nodes` and `genes` and re-indexes `edges` so that directed synteny transitions
        run 5' -> 3' along the dominant transcriptional strand.
        """
        if self.strand >= 0:
            return self
        n = len(self.nodes)
        new_interval = Interval(self.interval.start, self.interval.end, Strand.FORWARD)
        if n == 0:
            return Locus(
                nodes=(),
                edges=(),
                genes=Genes.empty(),
                interval=new_interval,
                contig_index=self.contig_index,
                archetype=self.archetype,
                completeness=self.completeness,
                score=self.score,
                is_tandem_split=self.is_tandem_split,
                name=self.name,
            )

        new_nodes = self.nodes[::-1]
        new_edges = tuple(
            IntergenicEdge(
                source=n - 1 - e.target,
                target=n - 1 - e.source,
                edge_type=e.edge_type,
            )
            for e in reversed(self.edges)
        )
        new_genes = self.genes[::-1] if len(self.genes) == n else self.genes
        return Locus(
            nodes=new_nodes,
            edges=new_edges,
            genes=new_genes,
            interval=new_interval,
            contig_index=self.contig_index,
            archetype=self.archetype,
            completeness=self.completeness,
            score=self.score,
            is_tandem_split=self.is_tandem_split,
            name=self.name,
        )

    def heal_mobile_elements(self, remove_nodes: bool = True) -> Locus:
        """Excise mobile genetic element (`TRANSPOSASE`, `INTEGRASE`) insertions and heal synteny edges."""
        if not self.has_mobile_elements or not remove_nodes:
            return self

        roles = self.roles
        keep_mask = (roles != int(GeneRole.TRANSPOSASE)) & (roles != int(GeneRole.INTEGRASE))
        kept_idx = np.nonzero(keep_mask)[0]
        if len(kept_idx) == 0:
            return Locus(
                nodes=(),
                edges=(),
                genes=Genes.empty(),
                interval=self.interval,
                contig_index=self.contig_index,
                archetype=self.archetype,
                completeness=self.completeness,
                score=self.score,
                is_tandem_split=self.is_tandem_split,
                name=self.name,
            )

        new_nodes = tuple(self.nodes[int(i)] for i in kept_idx)
        new_genes = self.genes[keep_mask] if len(self.genes) == len(self.nodes) else self.genes

        strands = new_genes.intervals.strands if len(new_genes) > 0 else np.ones(len(new_nodes), dtype=np.int8)
        starts = new_genes.intervals.starts if len(new_genes) > 0 else np.arange(len(new_nodes), dtype=np.int32) * 1000
        ends = new_genes.intervals.ends if len(new_genes) > 0 else starts + 900
        c_indices = new_genes.contig_indices if len(new_genes) > 0 else np.zeros(len(new_nodes), dtype=np.uint32)
        edge_map = {(e.source, e.target): e.edge_type for e in self.edges}

        new_edges: list[IntergenicEdge] = []
        for k in range(len(kept_idx) - 1):
            orig_a = int(kept_idx[k])
            orig_b = int(kept_idx[k + 1])
            step_sign = 1 if int(starts[k + 1]) >= int(starts[k]) else -1
            s_a = int(strands[k]) * step_sign
            s_b = int(strands[k + 1]) * step_sign
            if orig_b - orig_a > 1:
                if s_a == s_b:
                    orient = IntergenicOrientation.CODIRECTIONAL
                elif s_a >= 0 and s_b < 0:
                    orient = IntergenicOrientation.CONVERGENT
                else:
                    orient = IntergenicOrientation.DIVERGENT
                spans_break = int(c_indices[k]) != int(c_indices[k + 1]) or any(
                    edge_map.get((m, m + 1), IntergenicType()).is_contig_break for m in range(orig_a, orig_b)
                )
                etype = IntergenicType(orient, gap_bin=14 if spans_break else 15)
            elif (orig_a, orig_b) in edge_map:
                etype = edge_map[(orig_a, orig_b)]
            else:
                gap_bp = int(starts[k + 1]) - int(ends[k]) if step_sign > 0 else int(starts[k]) - int(ends[k + 1])
                etype = IntergenicType.from_adjacent(s_a, s_b, gap_bp=gap_bp)
            new_edges.append(IntergenicEdge(source=k, target=k + 1, edge_type=etype))

        return Locus(
            nodes=new_nodes,
            edges=tuple(new_edges),
            genes=new_genes,
            interval=self.interval,
            contig_index=self.contig_index,
            archetype=self.archetype,
            completeness=self.completeness,
            score=self.score,
            is_tandem_split=self.is_tandem_split,
            name=self.name,
        )

    @classmethod
    def from_genes(
        cls,
        genes: Genes | Sequence[str | bytes | int | GeneRole] | np.ndarray,
        roles: Sequence[str | bytes | int | GeneRole] | np.ndarray | None = None,
        *,
        strands: Sequence[int] | np.ndarray | None = None,
        lengths: Sequence[int] | np.ndarray | None = None,
        clusters: Sequence[int] | np.ndarray | None = None,
        frameshifts: Sequence[bool] | np.ndarray | None = None,
        archetype: LocusArchetype = LocusArchetype.UNKNOWN,
        completeness: float = 0.0,
        score: float = 0.0,
        is_tandem_split: bool = False,
        contig_index: int = 0,
        name: str = "",
    ) -> Self:
        """Construct a `Locus` from either a `Genes` container or a sequence of gene symbols/roles."""
        if isinstance(genes, Genes):
            genes_obj = genes
            n = len(genes_obj)
            if n == 0:
                return cls(nodes=(), edges=(), genes=Genes.empty(), archetype=archetype, name=name)
            if roles is None:
                role_ids = np.full(n, int(GeneRole.UNKNOWN), dtype=np.int32)
            elif isinstance(roles, np.ndarray) and roles.dtype.kind in ("i", "u"):
                role_ids = roles.astype(np.int32, copy=False)
            else:
                role_ids = np.array(
                    [int(GeneRole.label(r) if isinstance(r, (str, bytes)) else GeneRole.resolve(r)) for r in roles],
                    dtype=np.int32,
                )
            fs_arr = np.asarray(frameshifts, dtype=np.bool_) if frameshifts is not None else genes_obj.has_frameshift
            c_idx = int(genes_obj.contig_indices[0]) if len(genes_obj.contig_indices) > 0 else contig_index
        else:
            raw_list = list(genes)
            n = len(raw_list)
            if n == 0:
                return cls(nodes=(), edges=(), genes=Genes.empty(), archetype=archetype, name=name)

            if roles is not None:
                role_ids = np.array(
                    [int(GeneRole.label(r) if isinstance(r, (str, bytes)) else GeneRole.resolve(r)) for r in roles],
                    dtype=np.int32,
                )
            else:
                role_ids = np.empty(n, dtype=np.int32)
                for i, item in enumerate(raw_list):
                    if isinstance(item, (GeneRole, int, np.integer)):
                        role_ids[i] = int(item)
                    else:
                        s_item = item.decode("utf-8", errors="replace") if isinstance(item, bytes) else str(item)
                        try:
                            role_ids[i] = int(GeneRole.from_name(s_item))
                        except KeyError:
                            role_ids[i] = int(GeneRole.label(s_item))

            strands_arr = np.asarray(strands, dtype=np.int8) if strands is not None else np.ones(n, dtype=np.int8)
            lengths_arr = (
                np.asarray(lengths, dtype=np.int32) if lengths is not None else np.full(n, 1000, dtype=np.int32)
            )
            starts_arr = np.zeros(n, dtype=np.int32)
            if n > 1:
                np.cumsum(lengths_arr[:-1] + 50, out=starts_arr[1:])
            ends_arr = starts_arr + lengths_arr
            genes_obj = Genes(
                intervals=Intervals(starts=starts_arr, ends=ends_arr, strands=strands_arr),
                contig_indices=np.full(n, contig_index, dtype=np.uint32),
            )
            fs_arr = np.asarray(frameshifts, dtype=np.bool_) if frameshifts is not None else np.zeros(n, dtype=np.bool_)
            c_idx = contig_index

        cluster_arr = np.asarray(clusters, dtype=np.int32) if clusters is not None else np.zeros(n, dtype=np.int32)

        nodes = tuple(
            GeneNode(int(role_ids[i]), cluster_id=int(cluster_arr[i]), is_frameshift=bool(fs_arr[i])) for i in range(n)
        )

        g_starts = genes_obj.intervals.starts
        g_ends = genes_obj.intervals.ends
        g_strands = genes_obj.intervals.strands
        g_contigs = genes_obj.contig_indices

        edge_list: list[IntergenicEdge] = []
        for i in range(n - 1):
            c_a = int(g_contigs[i])
            c_b = int(g_contigs[i + 1])
            if c_b != c_a:
                s_a = int(g_strands[i])
                s_b = int(g_strands[i + 1])
                if s_a == s_b:
                    orient = IntergenicOrientation.CODIRECTIONAL
                elif s_a >= 0 and s_b < 0:
                    orient = IntergenicOrientation.CONVERGENT
                else:
                    orient = IntergenicOrientation.DIVERGENT
                etype = IntergenicType(orient, gap_bin=14)
            else:
                etype = IntergenicType.from_adjacent(
                    int(g_strands[i]),
                    int(g_strands[i + 1]),
                    gap_bp=int(g_starts[i + 1]) - int(g_ends[i]),
                )
            edge_list.append(IntergenicEdge(source=i, target=i + 1, edge_type=etype))

        edges = tuple(edge_list)
        dom_strand = Strand.FORWARD if int(np.sum(g_strands)) >= 0 else Strand.REVERSE
        locus_interval = Interval(int(g_starts.min()), int(g_ends.max()), dom_strand)

        return cls(
            nodes=nodes,
            edges=edges,
            genes=genes_obj,
            interval=locus_interval,
            contig_index=c_idx,
            archetype=archetype,
            completeness=float(completeness),
            score=float(score),
            is_tandem_split=bool(is_tandem_split),
            name=name,
        )

    def match(self, targets: Locus | Loci, **kwargs: Any) -> Any:
        """Compare this `Locus` against a target `Locus` (returns `Match`) or target `Loci` database (returns `Matches`)."""
        from serogen.loci.matcher import LocusMatcher

        matcher = LocusMatcher(targets, **kwargs)
        matches = matcher.match(self)
        if isinstance(targets, Locus):
            return matches[0]
        return matches

    def __repr__(self) -> str:
        name_part = f"name={self.name!r}, " if self.name else ""
        return (
            f"Locus({name_part}archetype={self.archetype.name}, genes={self.num_genes}, "
            f"interval={self.start}..{self.end}({self.strand:+d}), "
            f"completeness={self.completeness:.2f}, score={self.score:.2f})"
        )

num_genes property

num_genes: int

Number of genes in this locus.

start property

start: int

0-based inclusive start coordinate on the parent contig.

end property

end: int

0-based exclusive end coordinate on the parent contig.

strand property

strand: int

Dominant transcriptional strand (+1 or -1) of the locus.

strands property

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

1D int8 array of per-gene transcriptional strands from self.genes.

lengths property

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

1D int32 array of per-gene base-pair lengths from self.genes.

roles property

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

1D array of integer GeneRole indices (0..29) for each gene in the locus.

cluster_ids property

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

1D array of homology cluster_id values for each gene in the locus.

frameshifts property

frameshifts: ndarray[tuple[int], dtype[bool_]]

1D boolean array indicating which genes carry frameshift mutations.

role_mask property

role_mask: int

32-bit integer presence bitmask of all GeneRoles present in this locus.

has_mobile_elements property

has_mobile_elements: bool

True if this locus contains any TRANSPOSASE or INTEGRASE insertions.

has_mge property

has_mge: bool

Alias for has_mobile_elements.

is_fragmented property

is_fragmented: bool

True if this locus spans multiple contigs across an assembly break.

num_frameshifts property

num_frameshifts: int

Number of genes in this locus carrying frameshift mutations or premature stops.

frameshift_fraction property

frameshift_fraction: float

Fraction of genes in this locus ([0.0, 1.0]) carrying frameshift mutations.

is_intact property

is_intact: bool

True if this locus is on a single contig, has no mobile elements, and has no frameshifted genes.

category property

High-level ArchetypeCategory (Capsule, O-Antigen, Exopolysaccharide, Other) of this locus.

num_contigs property

num_contigs: int

Number of distinct contigs spanned by this locus.

with_name

with_name(name: str | bytes) -> Locus

Return a copy of this Locus with an updated identifier name.

Source code in src/serogen/loci/models.py
def with_name(self, name: str | bytes) -> Locus:
    """Return a copy of this `Locus` with an updated identifier `name`."""
    name_str = name.decode("utf-8", errors="replace") if isinstance(name, bytes) else str(name)
    return Locus(
        nodes=self.nodes,
        edges=self.edges,
        genes=self.genes,
        interval=self.interval,
        contig_index=self.contig_index,
        archetype=self.archetype,
        completeness=self.completeness,
        score=self.score,
        is_tandem_split=self.is_tandem_split,
        name=name_str,
    )

canonicalize_strand

canonicalize_strand() -> Locus

Orient the locus into canonical 5' -> 3' operon order (strand = +1).

If self.strand >= 0, returns self unchanged. If self.strand < 0, reverses nodes and genes and re-indexes edges so that directed synteny transitions run 5' -> 3' along the dominant transcriptional strand.

Source code in src/serogen/loci/models.py
def canonicalize_strand(self) -> Locus:
    """Orient the locus into canonical 5' -> 3' operon order (`strand = +1`).

    If `self.strand >= 0`, returns `self` unchanged. If `self.strand < 0`, reverses
    `nodes` and `genes` and re-indexes `edges` so that directed synteny transitions
    run 5' -> 3' along the dominant transcriptional strand.
    """
    if self.strand >= 0:
        return self
    n = len(self.nodes)
    new_interval = Interval(self.interval.start, self.interval.end, Strand.FORWARD)
    if n == 0:
        return Locus(
            nodes=(),
            edges=(),
            genes=Genes.empty(),
            interval=new_interval,
            contig_index=self.contig_index,
            archetype=self.archetype,
            completeness=self.completeness,
            score=self.score,
            is_tandem_split=self.is_tandem_split,
            name=self.name,
        )

    new_nodes = self.nodes[::-1]
    new_edges = tuple(
        IntergenicEdge(
            source=n - 1 - e.target,
            target=n - 1 - e.source,
            edge_type=e.edge_type,
        )
        for e in reversed(self.edges)
    )
    new_genes = self.genes[::-1] if len(self.genes) == n else self.genes
    return Locus(
        nodes=new_nodes,
        edges=new_edges,
        genes=new_genes,
        interval=new_interval,
        contig_index=self.contig_index,
        archetype=self.archetype,
        completeness=self.completeness,
        score=self.score,
        is_tandem_split=self.is_tandem_split,
        name=self.name,
    )

heal_mobile_elements

heal_mobile_elements(remove_nodes: bool = True) -> Locus

Excise mobile genetic element (TRANSPOSASE, INTEGRASE) insertions and heal synteny edges.

Source code in src/serogen/loci/models.py
def heal_mobile_elements(self, remove_nodes: bool = True) -> Locus:
    """Excise mobile genetic element (`TRANSPOSASE`, `INTEGRASE`) insertions and heal synteny edges."""
    if not self.has_mobile_elements or not remove_nodes:
        return self

    roles = self.roles
    keep_mask = (roles != int(GeneRole.TRANSPOSASE)) & (roles != int(GeneRole.INTEGRASE))
    kept_idx = np.nonzero(keep_mask)[0]
    if len(kept_idx) == 0:
        return Locus(
            nodes=(),
            edges=(),
            genes=Genes.empty(),
            interval=self.interval,
            contig_index=self.contig_index,
            archetype=self.archetype,
            completeness=self.completeness,
            score=self.score,
            is_tandem_split=self.is_tandem_split,
            name=self.name,
        )

    new_nodes = tuple(self.nodes[int(i)] for i in kept_idx)
    new_genes = self.genes[keep_mask] if len(self.genes) == len(self.nodes) else self.genes

    strands = new_genes.intervals.strands if len(new_genes) > 0 else np.ones(len(new_nodes), dtype=np.int8)
    starts = new_genes.intervals.starts if len(new_genes) > 0 else np.arange(len(new_nodes), dtype=np.int32) * 1000
    ends = new_genes.intervals.ends if len(new_genes) > 0 else starts + 900
    c_indices = new_genes.contig_indices if len(new_genes) > 0 else np.zeros(len(new_nodes), dtype=np.uint32)
    edge_map = {(e.source, e.target): e.edge_type for e in self.edges}

    new_edges: list[IntergenicEdge] = []
    for k in range(len(kept_idx) - 1):
        orig_a = int(kept_idx[k])
        orig_b = int(kept_idx[k + 1])
        step_sign = 1 if int(starts[k + 1]) >= int(starts[k]) else -1
        s_a = int(strands[k]) * step_sign
        s_b = int(strands[k + 1]) * step_sign
        if orig_b - orig_a > 1:
            if s_a == s_b:
                orient = IntergenicOrientation.CODIRECTIONAL
            elif s_a >= 0 and s_b < 0:
                orient = IntergenicOrientation.CONVERGENT
            else:
                orient = IntergenicOrientation.DIVERGENT
            spans_break = int(c_indices[k]) != int(c_indices[k + 1]) or any(
                edge_map.get((m, m + 1), IntergenicType()).is_contig_break for m in range(orig_a, orig_b)
            )
            etype = IntergenicType(orient, gap_bin=14 if spans_break else 15)
        elif (orig_a, orig_b) in edge_map:
            etype = edge_map[(orig_a, orig_b)]
        else:
            gap_bp = int(starts[k + 1]) - int(ends[k]) if step_sign > 0 else int(starts[k]) - int(ends[k + 1])
            etype = IntergenicType.from_adjacent(s_a, s_b, gap_bp=gap_bp)
        new_edges.append(IntergenicEdge(source=k, target=k + 1, edge_type=etype))

    return Locus(
        nodes=new_nodes,
        edges=tuple(new_edges),
        genes=new_genes,
        interval=self.interval,
        contig_index=self.contig_index,
        archetype=self.archetype,
        completeness=self.completeness,
        score=self.score,
        is_tandem_split=self.is_tandem_split,
        name=self.name,
    )

from_genes classmethod

from_genes(genes: Genes | Sequence[str | bytes | int | GeneRole] | ndarray, roles: Sequence[str | bytes | int | GeneRole] | ndarray | None = None, *, strands: Sequence[int] | ndarray | None = None, lengths: Sequence[int] | ndarray | None = None, clusters: Sequence[int] | ndarray | None = None, frameshifts: Sequence[bool] | ndarray | None = None, archetype: LocusArchetype = UNKNOWN, completeness: float = 0.0, score: float = 0.0, is_tandem_split: bool = False, contig_index: int = 0, name: str = '') -> Self

Construct a Locus from either a Genes container or a sequence of gene symbols/roles.

Source code in src/serogen/loci/models.py
@classmethod
def from_genes(
    cls,
    genes: Genes | Sequence[str | bytes | int | GeneRole] | np.ndarray,
    roles: Sequence[str | bytes | int | GeneRole] | np.ndarray | None = None,
    *,
    strands: Sequence[int] | np.ndarray | None = None,
    lengths: Sequence[int] | np.ndarray | None = None,
    clusters: Sequence[int] | np.ndarray | None = None,
    frameshifts: Sequence[bool] | np.ndarray | None = None,
    archetype: LocusArchetype = LocusArchetype.UNKNOWN,
    completeness: float = 0.0,
    score: float = 0.0,
    is_tandem_split: bool = False,
    contig_index: int = 0,
    name: str = "",
) -> Self:
    """Construct a `Locus` from either a `Genes` container or a sequence of gene symbols/roles."""
    if isinstance(genes, Genes):
        genes_obj = genes
        n = len(genes_obj)
        if n == 0:
            return cls(nodes=(), edges=(), genes=Genes.empty(), archetype=archetype, name=name)
        if roles is None:
            role_ids = np.full(n, int(GeneRole.UNKNOWN), dtype=np.int32)
        elif isinstance(roles, np.ndarray) and roles.dtype.kind in ("i", "u"):
            role_ids = roles.astype(np.int32, copy=False)
        else:
            role_ids = np.array(
                [int(GeneRole.label(r) if isinstance(r, (str, bytes)) else GeneRole.resolve(r)) for r in roles],
                dtype=np.int32,
            )
        fs_arr = np.asarray(frameshifts, dtype=np.bool_) if frameshifts is not None else genes_obj.has_frameshift
        c_idx = int(genes_obj.contig_indices[0]) if len(genes_obj.contig_indices) > 0 else contig_index
    else:
        raw_list = list(genes)
        n = len(raw_list)
        if n == 0:
            return cls(nodes=(), edges=(), genes=Genes.empty(), archetype=archetype, name=name)

        if roles is not None:
            role_ids = np.array(
                [int(GeneRole.label(r) if isinstance(r, (str, bytes)) else GeneRole.resolve(r)) for r in roles],
                dtype=np.int32,
            )
        else:
            role_ids = np.empty(n, dtype=np.int32)
            for i, item in enumerate(raw_list):
                if isinstance(item, (GeneRole, int, np.integer)):
                    role_ids[i] = int(item)
                else:
                    s_item = item.decode("utf-8", errors="replace") if isinstance(item, bytes) else str(item)
                    try:
                        role_ids[i] = int(GeneRole.from_name(s_item))
                    except KeyError:
                        role_ids[i] = int(GeneRole.label(s_item))

        strands_arr = np.asarray(strands, dtype=np.int8) if strands is not None else np.ones(n, dtype=np.int8)
        lengths_arr = (
            np.asarray(lengths, dtype=np.int32) if lengths is not None else np.full(n, 1000, dtype=np.int32)
        )
        starts_arr = np.zeros(n, dtype=np.int32)
        if n > 1:
            np.cumsum(lengths_arr[:-1] + 50, out=starts_arr[1:])
        ends_arr = starts_arr + lengths_arr
        genes_obj = Genes(
            intervals=Intervals(starts=starts_arr, ends=ends_arr, strands=strands_arr),
            contig_indices=np.full(n, contig_index, dtype=np.uint32),
        )
        fs_arr = np.asarray(frameshifts, dtype=np.bool_) if frameshifts is not None else np.zeros(n, dtype=np.bool_)
        c_idx = contig_index

    cluster_arr = np.asarray(clusters, dtype=np.int32) if clusters is not None else np.zeros(n, dtype=np.int32)

    nodes = tuple(
        GeneNode(int(role_ids[i]), cluster_id=int(cluster_arr[i]), is_frameshift=bool(fs_arr[i])) for i in range(n)
    )

    g_starts = genes_obj.intervals.starts
    g_ends = genes_obj.intervals.ends
    g_strands = genes_obj.intervals.strands
    g_contigs = genes_obj.contig_indices

    edge_list: list[IntergenicEdge] = []
    for i in range(n - 1):
        c_a = int(g_contigs[i])
        c_b = int(g_contigs[i + 1])
        if c_b != c_a:
            s_a = int(g_strands[i])
            s_b = int(g_strands[i + 1])
            if s_a == s_b:
                orient = IntergenicOrientation.CODIRECTIONAL
            elif s_a >= 0 and s_b < 0:
                orient = IntergenicOrientation.CONVERGENT
            else:
                orient = IntergenicOrientation.DIVERGENT
            etype = IntergenicType(orient, gap_bin=14)
        else:
            etype = IntergenicType.from_adjacent(
                int(g_strands[i]),
                int(g_strands[i + 1]),
                gap_bp=int(g_starts[i + 1]) - int(g_ends[i]),
            )
        edge_list.append(IntergenicEdge(source=i, target=i + 1, edge_type=etype))

    edges = tuple(edge_list)
    dom_strand = Strand.FORWARD if int(np.sum(g_strands)) >= 0 else Strand.REVERSE
    locus_interval = Interval(int(g_starts.min()), int(g_ends.max()), dom_strand)

    return cls(
        nodes=nodes,
        edges=edges,
        genes=genes_obj,
        interval=locus_interval,
        contig_index=c_idx,
        archetype=archetype,
        completeness=float(completeness),
        score=float(score),
        is_tandem_split=bool(is_tandem_split),
        name=name,
    )

match

match(targets: Locus | Loci, **kwargs: Any) -> Any

Compare this Locus against a target Locus (returns Match) or target Loci database (returns Matches).

Source code in src/serogen/loci/models.py
def match(self, targets: Locus | Loci, **kwargs: Any) -> Any:
    """Compare this `Locus` against a target `Locus` (returns `Match`) or target `Loci` database (returns `Matches`)."""
    from serogen.loci.matcher import LocusMatcher

    matcher = LocusMatcher(targets, **kwargs)
    matches = matcher.match(self)
    if isinstance(targets, Locus):
        return matches[0]
    return matches

Loci dataclass

Bases: GraphBatch[Locus, 'Loci']

Structure-of-Arrays (SoA) batch container for biosynthetic gene clusters (Locus).

Combines GraphBatch sparse topology arrays (node_types, node_offsets, edge_sources, edge_targets, edge_types, edge_offsets) with a flat Genes container (self.genes, indexed per locus by node_offsets) and per-locus archetype metadata.

Methods:

Name Description
with_names

Return a new Loci batch with updated per-locus identifier names.

best

Return the highest-scoring Locus in the batch (or None if empty).

filter

Filter loci by names, ontology categories, specific archetypes, min_completeness, min_score, max_frameshift_fraction, and/or only_intact.

empty

Create an empty 0-length Loci container.

concat

Concatenate multiple Loci batches into a single contiguous Loci container.

from_list

Compile a sequence of scalar Locus objects into a contiguous Loci batch.

canonicalize_strands

Orient all loci in the batch into canonical 5' -> 3' operon order (strand = +1).

heal_mobile_elements

Excise mobile genetic element (TRANSPOSASE, INTEGRASE) insertions across all loci in the batch.

with_clusters

Return a new Loci batch with homology cluster_id bitfields updated from Clusters or an ID array.

cluster_genes

Cluster translated locus proteins and return a new Loci batch with packed homology CLUSTER_IDs.

as_pan_graph

Compress the Loci batch into a directed consensus pan-locus graph.

match

Compare one or more query loci against this Loci batch acting as a reference database.

Attributes:

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

Flat 1D array of GeneRole indices (0..29) across all gene nodes in the batch.

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

Flat 1D array of homology cluster_id values across all gene nodes in the batch.

is_fragmented ndarray[tuple[int], dtype[bool_]]

1D boolean array ((N,)) indicating whether each locus spans a contig break (gap_bin == 14).

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

1D int32 array ((N,)) of frameshifted gene counts per locus.

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

1D float32 array ((N,)) of frameshifted gene fractions ([0.0, 1.0]) per locus.

has_mge ndarray[tuple[int], dtype[bool_]]

1D boolean array ((N,)) indicating whether each locus contains TRANSPOSASE or INTEGRASE insertions.

is_intact ndarray[tuple[int], dtype[bool_]]

1D boolean array ((N,)) indicating whether each locus is single-contig, MGE-free, and frameshift-free.

categories ndarray[tuple[int], dtype[str_]]

1D string array ((N,)) of ArchetypeCategory values ('Capsule', 'O-Antigen', etc.) per locus.

Source code in src/serogen/loci/models.py
 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
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
@dataclass(slots=True, frozen=True)
class Loci(GraphBatch[Locus, "Loci"]):
    """Structure-of-Arrays (SoA) batch container for biosynthetic gene clusters (`Locus`).

    Combines `GraphBatch` sparse topology arrays (`node_types`, `node_offsets`, `edge_sources`,
    `edge_targets`, `edge_types`, `edge_offsets`) with a flat [`Genes`][serogen.genes.models.Genes]
    container (`self.genes`, indexed per locus by `node_offsets`) and per-locus archetype metadata.
    """

    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]]
    genes: Genes = field(default_factory=Genes.empty)
    intervals: Intervals = field(default_factory=Intervals.empty)
    contig_indices: np.ndarray[tuple[int], np.dtype[np.uint32]] = field(
        default_factory=lambda: np.empty(0, dtype=np.uint32)
    )
    archetypes: np.ndarray[tuple[int], np.dtype[np.int8]] = field(default_factory=lambda: np.empty(0, dtype=np.int8))
    completeness: np.ndarray[tuple[int], np.dtype[np.float32]] = field(
        default_factory=lambda: np.empty(0, dtype=np.float32)
    )
    scores: np.ndarray[tuple[int], np.dtype[np.float32]] = field(default_factory=lambda: np.empty(0, dtype=np.float32))
    is_tandem_split: np.ndarray[tuple[int], np.dtype[np.bool_]] = field(
        default_factory=lambda: np.empty(0, dtype=np.bool_)
    )
    names: np.ndarray[tuple[int], np.dtype[np.str_]] = field(default_factory=lambda: np.empty(0, dtype="U1"))

    def __post_init__(self) -> None:
        n = max(0, len(self.node_offsets) - 1)
        if len(self.names) == 0 and n > 0:
            object.__setattr__(self, "names", np.full(n, "", dtype="U1"))
        elif self.names.dtype.kind == "S":
            object.__setattr__(self, "names", np.char.decode(cast(Any, self.names), "utf-8", errors="replace"))
        elif self.names.dtype.kind != "U":
            object.__setattr__(self, "names", np.asarray(self.names, dtype=str))

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

    def with_names(self, names: Sequence[str | bytes] | np.ndarray) -> Self:
        """Return a new `Loci` batch with updated per-locus identifier `names`."""
        raw = [x.decode("utf-8", errors="replace") if isinstance(x, bytes) else str(x) for x in names]
        if len(raw) != len(self):
            raise ValueError(f"Expected {len(self)} locus names, got {len(raw)}.")
        names_arr = np.array(raw, dtype=str) if raw else np.empty(0, dtype="U1")
        return type(self)(
            node_types=self.node_types,
            node_offsets=self.node_offsets,
            edge_sources=self.edge_sources,
            edge_targets=self.edge_targets,
            edge_types=self.edge_types,
            edge_offsets=self.edge_offsets,
            genes=self.genes,
            intervals=self.intervals,
            contig_indices=self.contig_indices,
            archetypes=self.archetypes,
            completeness=self.completeness,
            scores=self.scores,
            is_tandem_split=self.is_tandem_split,
            names=names_arr,
        )

    @property
    def roles(self) -> np.ndarray[tuple[int], np.dtype[np.int32]]:
        """Flat 1D array of `GeneRole` indices (`0..29`) across all gene nodes in the batch."""
        return GeneNode.ROLE_ID.extract_array(self.node_types).astype(np.int32)

    @property
    def cluster_ids(self) -> np.ndarray[tuple[int], np.dtype[np.int32]]:
        """Flat 1D array of homology `cluster_id` values across all gene nodes in the batch."""
        return GeneNode.CLUSTER_ID.extract_array(self.node_types).astype(np.int32)

    @property
    def is_fragmented(self) -> np.ndarray[tuple[int], np.dtype[np.bool_]]:
        """1D boolean array (`(N,)`) indicating whether each locus spans a contig break (`gap_bin == 14`)."""
        n_loci = len(self)
        if n_loci == 0:
            return np.empty(0, dtype=np.bool_)
        if len(self.edge_types) == 0:
            return np.zeros(n_loci, dtype=np.bool_)
        edge_breaks = IntergenicType.GAP_BIN.extract_array(self.edge_types) == 14
        out = np.zeros(n_loci, dtype=np.bool_)
        for i in range(n_loci):
            s = int(self.edge_offsets[i])
            e = int(self.edge_offsets[i + 1])
            if e > s and np.any(edge_breaks[s:e]):
                out[i] = True
        return out

    @property
    def num_frameshifts(self) -> np.ndarray[tuple[int], np.dtype[np.int32]]:
        """1D `int32` array (`(N,)`) of frameshifted gene counts per locus."""
        n_loci = len(self)
        if n_loci == 0:
            return np.empty(0, dtype=np.int32)
        if len(self.node_types) == 0:
            return np.zeros(n_loci, dtype=np.int32)
        fs_nodes = GeneNode.IS_FRAMESHIFT.extract_array(self.node_types).astype(np.int32)
        out = np.zeros(n_loci, dtype=np.int32)
        for i in range(n_loci):
            s = int(self.node_offsets[i])
            e = int(self.node_offsets[i + 1])
            if e > s:
                out[i] = int(np.sum(fs_nodes[s:e]))
        return out

    @property
    def frameshift_fractions(self) -> np.ndarray[tuple[int], np.dtype[np.float32]]:
        """1D `float32` array (`(N,)`) of frameshifted gene fractions (`[0.0, 1.0]`) per locus."""
        n_loci = len(self)
        if n_loci == 0:
            return np.empty(0, dtype=np.float32)
        counts = (self.node_offsets[1:] - self.node_offsets[:-1]).astype(np.float32)
        fs = self.num_frameshifts.astype(np.float32)
        return np.where(counts > 0.0, fs / np.maximum(counts, 1.0), np.float32(0.0)).astype(np.float32)

    @property
    def has_mge(self) -> np.ndarray[tuple[int], np.dtype[np.bool_]]:
        """1D boolean array (`(N,)`) indicating whether each locus contains `TRANSPOSASE` or `INTEGRASE` insertions."""
        if len(self) == 0:
            return np.empty(0, dtype=np.bool_)
        return np.array([loc.has_mobile_elements for loc in self], dtype=np.bool_)

    @property
    def is_intact(self) -> np.ndarray[tuple[int], np.dtype[np.bool_]]:
        """1D boolean array (`(N,)`) indicating whether each locus is single-contig, MGE-free, and frameshift-free."""
        n_loci = len(self)
        if n_loci == 0:
            return np.empty(0, dtype=np.bool_)
        return (~self.is_fragmented) & (~self.has_mge) & (self.num_frameshifts == 0)

    @property
    def categories(self) -> np.ndarray[tuple[int], np.dtype[np.str_]]:
        """1D string array (`(N,)`) of `ArchetypeCategory` values (`'Capsule'`, `'O-Antigen'`, etc.) per locus."""
        if len(self) == 0:
            return np.empty(0, dtype="U20")
        return get_archetype_tensors().categories[self.archetypes]

    def best(self, *, prefer_intact: bool = False) -> Locus | None:
        """Return the highest-scoring `Locus` in the batch (or `None` if empty).

        Args:
            prefer_intact: If True, prioritize intact loci without frameshifts or assembly breaks
                (e.g., an intact ectopic `CPS_SYNTHASE` over a frameshifted/cryptic `CPS_WZY` pseudogene cluster).
        """
        if len(self) == 0:
            return None
        if prefer_intact:
            fs_frac = self.frameshift_fractions
            frag = self.is_fragmented.astype(np.int32)
            # Lexsort ascending by (is_fragmented, frameshift_fraction, -score) and take the first index
            order = np.lexsort((-self.scores, fs_frac, frag))
            return self[int(order[0])]
        return self[int(np.argmax(self.scores))]

    def filter(
        self,
        *,
        names: str | Iterable[str] | None = None,
        categories: ArchetypeCategory | str | Iterable[ArchetypeCategory | str] | None = None,
        archetypes: LocusArchetype | str | int | Iterable[LocusArchetype | str | int] | None = None,
        min_completeness: float = 0.0,
        min_score: float = 0.0,
        max_frameshift_fraction: float = 1.0,
        only_intact: bool = False,
    ) -> Self:
        """Filter loci by `names`, ontology `categories`, specific `archetypes`, `min_completeness`, `min_score`, `max_frameshift_fraction`, and/or `only_intact`."""
        n = len(self)
        if n == 0:
            return self
        mask = np.ones(n, dtype=np.bool_)
        if names is not None:
            name_items = [names] if isinstance(names, str) else list(names)
            allowed_names = np.array([str(x) for x in name_items], dtype=str)
            mask &= np.isin(self.names, allowed_names)
        if min_completeness > 0.0:
            mask &= self.completeness >= min_completeness
        if min_score > 0.0:
            mask &= self.scores >= min_score
        if max_frameshift_fraction < 1.0:
            mask &= self.frameshift_fractions <= max_frameshift_fraction + 1e-6
        if only_intact:
            mask &= ~self.is_fragmented
            mask &= np.array([not loc.has_mobile_elements for loc in self], dtype=np.bool_)
        if categories is not None:
            cat_items = [categories] if isinstance(categories, (str, ArchetypeCategory)) else list(categories)
            allowed_cats = {
                (c.value if isinstance(c, ArchetypeCategory) else str(c)).strip().lower().replace("_", "-")
                for c in cat_items
            }
            t_cats = np.array(
                [str(c).strip().lower().replace("_", "-") for c in get_archetype_tensors().categories],
                dtype="U20",
            )
            arch_cat_ok = np.array([c in allowed_cats for c in t_cats], dtype=np.bool_)
            mask &= arch_cat_ok[self.archetypes]
        if archetypes is not None:
            arch_items: list[LocusArchetype | str | int] = (
                [archetypes] if isinstance(archetypes, (str, int, LocusArchetype)) else list(archetypes)
            )
            allowed_arch_ids = np.array([int(LocusArchetype.from_name(a)) for a in arch_items], dtype=np.int8)
            mask &= np.isin(self.archetypes, allowed_arch_ids)
        return self[mask]

    @classmethod
    def empty(cls) -> Self:
        """Create an empty 0-length `Loci` container."""
        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),
            genes=Genes.empty(),
            intervals=Intervals.empty(),
            contig_indices=np.empty(0, dtype=np.uint32),
            archetypes=np.empty(0, dtype=np.int8),
            completeness=np.empty(0, dtype=np.float32),
            scores=np.empty(0, dtype=np.float32),
            is_tandem_split=np.empty(0, dtype=np.bool_),
            names=np.empty(0, dtype="U1"),
        )

    @classmethod
    def concat(cls, batches: Iterable[Self]) -> Self:
        """Concatenate multiple `Loci` batches into a single contiguous `Loci` container."""
        batch_list = [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,
            genes=Genes.concat([b.genes for b in batch_list]),
            intervals=Intervals.concat([b.intervals for b in batch_list]),
            contig_indices=np.concatenate([b.contig_indices for b in batch_list]),
            archetypes=np.concatenate([b.archetypes for b in batch_list]),
            completeness=np.concatenate([b.completeness for b in batch_list]),
            scores=np.concatenate([b.scores for b in batch_list]),
            is_tandem_split=np.concatenate([b.is_tandem_split for b in batch_list]),
            names=np.concatenate([b.names for b in batch_list]),
        )

    @classmethod
    def from_list(cls, loci: Sequence[Locus]) -> Self:
        """Compile a sequence of scalar `Locus` objects into a contiguous `Loci` batch."""
        n_loci = len(loci)
        if n_loci == 0:
            return cls.empty()

        node_offsets = np.empty(n_loci + 1, dtype=np.uint32)
        node_offsets[0] = 0
        edge_offsets = np.empty(n_loci + 1, dtype=np.uint32)
        edge_offsets[0] = 0

        tot_nodes = sum(len(loc.nodes) for loc in loci)
        tot_edges = sum(len(loc.edges) for loc in loci)

        node_types = np.empty(tot_nodes, dtype=np.uint32)
        edge_sources = np.empty(tot_edges, dtype=np.uint32)
        edge_targets = np.empty(tot_edges, dtype=np.uint32)
        edge_types = np.empty(tot_edges, dtype=np.uint32)

        starts = np.empty(n_loci, dtype=np.int32)
        ends = np.empty(n_loci, dtype=np.int32)
        strands = np.empty(n_loci, dtype=np.int8)
        contig_indices = np.empty(n_loci, dtype=np.uint32)
        archetypes = np.empty(n_loci, dtype=np.int8)
        completeness = np.empty(n_loci, dtype=np.float32)
        scores = np.empty(n_loci, dtype=np.float32)
        is_tandem_split = np.empty(n_loci, dtype=np.bool_)
        names_list: list[str] = []
        genes_batches: list[Genes] = []

        curr_node = 0
        curr_edge = 0

        for i, loc in enumerate(loci):
            n_n = len(loc.nodes)
            n_e = len(loc.edges)
            if n_n > 0:
                node_types[curr_node : curr_node + n_n] = [int(n) for n in loc.nodes]
            if n_e > 0:
                for j, e in enumerate(loc.edges):
                    edge_sources[curr_edge + j] = e.source + curr_node
                    edge_targets[curr_edge + j] = e.target + curr_node
                    edge_types[curr_edge + j] = int(e.edge_type)

            curr_node += n_n
            curr_edge += n_e
            node_offsets[i + 1] = curr_node
            edge_offsets[i + 1] = curr_edge

            starts[i] = loc.interval.start
            ends[i] = loc.interval.end
            strands[i] = loc.interval.strand
            contig_indices[i] = loc.contig_index
            archetypes[i] = int(loc.archetype)
            completeness[i] = loc.completeness
            scores[i] = loc.score
            is_tandem_split[i] = loc.is_tandem_split
            names_list.append(loc.name)
            genes_batches.append(loc.genes)

        return cls(
            node_types=node_types,
            node_offsets=node_offsets,
            edge_sources=edge_sources,
            edge_targets=edge_targets,
            edge_types=edge_types,
            edge_offsets=edge_offsets,
            genes=Genes.concat(genes_batches),
            intervals=Intervals(starts=starts, ends=ends, strands=strands),
            contig_indices=contig_indices,
            archetypes=archetypes,
            completeness=completeness,
            scores=scores,
            is_tandem_split=is_tandem_split,
            names=np.array(names_list, dtype=str),
        )

    def canonicalize_strands(self) -> Self:
        """Orient all loci in the batch into canonical 5' -> 3' operon order (`strand = +1`)."""
        if len(self) == 0 or bool(np.all(self.intervals.strands >= 0)):
            return self
        return self.from_list([loc.canonicalize_strand() for loc in self])

    def heal_mobile_elements(self, remove_nodes: bool = True) -> Self:
        """Excise mobile genetic element (`TRANSPOSASE`, `INTEGRASE`) insertions across all loci in the batch."""
        if not remove_nodes or len(self) == 0 or len(self.node_types) == 0:
            return self
        roles = self.roles
        has_mge = bool(np.any((roles == int(GeneRole.TRANSPOSASE)) | (roles == int(GeneRole.INTEGRASE))))
        if not has_mge:
            return self
        return self.from_list([loc.heal_mobile_elements(remove_nodes=True) for loc in self])

    def with_clusters(self, clusters: Clusters | np.ndarray) -> Self:
        """Return a new `Loci` batch with homology `cluster_id` bitfields updated from `Clusters` or an ID array."""
        c_ids = clusters.cluster_ids if isinstance(clusters, Clusters) else np.asarray(clusters, dtype=np.int32)
        if len(c_ids) != len(self.node_types):
            raise ValueError(
                f"Cluster IDs length ({len(c_ids)}) does not match total gene nodes ({len(self.node_types)})."
            )
        # In GeneNode, cluster_id = 0 means unclustered; shift 0-based Clusters IDs to 1-based if needed
        shifted = np.where(c_ids >= 0, c_ids + 1, 0).astype(np.uint32)
        cleared = self.node_types & ~np.uint32(GeneNode.CLUSTER_ID.mask)
        packed_cid = (shifted & np.uint32(GeneNode.CLUSTER_ID.max_val)) << np.uint32(GeneNode.CLUSTER_ID.shift)
        new_node_types = cleared | packed_cid
        return type(self)(
            node_types=new_node_types,
            node_offsets=self.node_offsets,
            edge_sources=self.edge_sources,
            edge_targets=self.edge_targets,
            edge_types=self.edge_types,
            edge_offsets=self.edge_offsets,
            genes=self.genes,
            intervals=self.intervals,
            contig_indices=self.contig_indices,
            archetypes=self.archetypes,
            completeness=self.completeness,
            scores=self.scores,
            is_tandem_split=self.is_tandem_split,
            names=self.names,
        )

    def cluster_genes(
        self,
        clusterer: Clusterer | None = None,
        *,
        by_role: bool = True,
        classifier: Any | None = None,
        embedding_layer: str = "task_weighted_conv_max",
        **kwargs: Any,
    ) -> Self:
        """Cluster translated locus proteins and return a new `Loci` batch with packed homology `CLUSTER_ID`s."""
        if len(self.node_types) == 0 or len(self.genes) != len(self.node_types):
            return self
        proteins = self.genes.translations
        if len(proteins) != len(self.node_types):
            return self
        engine = clusterer if clusterer is not None else Clusterer(**kwargs)
        roles = self.roles if by_role else None
        use_emb = classifier is not None or engine.embedding_weight > 0.0
        clf = classifier
        if use_emb and clf is None:
            from serogen.genes.cnn import GeneRoleClassifier

            clf = GeneRoleClassifier.pretrained()

        if len(proteins) > 500:
            seen_key_to_u: dict[tuple[int, bytes], int] = {}
            u_indices: list[int] = []
            inv_map = np.empty(len(proteins), dtype=np.int32)
            for g_i in range(len(proteins)):
                p_off = int(proteins.offsets[g_i])
                p_len = int(proteins.lengths[g_i])
                r_key = int(roles[g_i]) if roles is not None else 0
                key = (r_key, proteins.seqs[p_off : p_off + p_len].tobytes())
                u_idx = seen_key_to_u.get(key)
                if u_idx is None:
                    u_idx = len(u_indices)
                    seen_key_to_u[key] = u_idx
                    u_indices.append(g_i)
                inv_map[g_i] = u_idx
            u_idx_arr = np.asarray(u_indices, dtype=np.int32)
            u_prots = proteins[u_idx_arr]
            u_embs = clf.transform(u_prots, layer=embedding_layer) if clf is not None else None
            u_clusters = engine.cluster(
                u_prots,
                roles=roles[u_idx_arr] if roles is not None else None,
                embeddings=u_embs,
            )
            return self.with_clusters(u_clusters.cluster_ids[inv_map])
        embs = clf.transform(proteins, layer=embedding_layer) if clf is not None else None
        clusters = engine.cluster(proteins, roles=roles, embeddings=embs)
        return self.with_clusters(clusters)

    def as_pan_graph(
        self,
        node_mask: int | None = None,
        *,
        canonicalize_strands: bool = True,
        heal_mges: bool = False,
        cluster_genes: bool = False,
        clusterer: Clusterer | None = None,
    ) -> tuple[
        csr_array,
        np.ndarray[tuple[int], np.dtype[np.uint32]],
        np.ndarray[tuple[int], np.dtype[np.uint32]],
    ]:
        """Compress the `Loci` batch into a directed consensus pan-locus graph.

        Args:
            node_mask: Optional bitmask to apply to `GeneNode` values before merging
                (e.g., `GeneNode.mask("ROLE_ID", "CLUSTER_ID")`).
            canonicalize_strands: If True (default), orient `-1` strand loci into canonical
                5' -> 3' operon order prior to graph compression.
            heal_mges: If True, excise `TRANSPOSASE` and `INTEGRASE` insertions and heal
                synteny edges prior to graph compression.
            cluster_genes: If True (or if `clusterer` is provided), cluster translated gene
                proteins and pack homology `CLUSTER_ID`s into nodes before merging.
            clusterer: Optional pre-configured `Clusterer` instance to use when clustering proteins.

        Returns:
            A tuple of `(adj, pan_nodes, node_counts)`.
        """
        batch = self
        if heal_mges:
            batch = batch.heal_mobile_elements(remove_nodes=True)
        if canonicalize_strands:
            batch = batch.canonicalize_strands()
        if cluster_genes or clusterer is not None:
            batch = batch.cluster_genes(clusterer=clusterer)
        return super(Loci, batch).as_pan_graph(node_mask=node_mask)

    def match(
        self,
        query: Locus | Loci,
        *,
        top_k: int | None = None,
        min_score: float = 0.0,
        sort: bool = True,
        **matcher_kwargs: Any,
    ) -> Any:
        """Compare one or more `query` loci against this `Loci` batch acting as a reference database."""
        from serogen.loci.matcher import LocusMatcher

        matcher = LocusMatcher(self, **matcher_kwargs)
        return matcher.match(query, top_k=top_k, min_score=min_score, sort=sort)

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

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

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

    def __getitem__(
        self, item: int | np.integer[Any] | str | slice | np.ndarray[tuple[int], Any] | list[Any]
    ) -> Locus | Self:
        if isinstance(item, str):
            matches = np.nonzero(self.names == item)[0]
            if len(matches) == 0:
                raise KeyError(f"Locus name {item!r} not found in Loci batch.")
            item = int(matches[0])

        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"Loci index {item} out of range [0, {len(self) - 1}]")

            m_start = int(self.node_offsets[idx])
            m_end = int(self.node_offsets[idx + 1])
            e_start = int(self.edge_offsets[idx])
            e_end = int(self.edge_offsets[idx + 1])

            nodes = tuple(GeneNode(int(x)) for x in self.node_types[m_start:m_end])
            edges = tuple(
                IntergenicEdge(
                    source=int(self.edge_sources[k]) - m_start,
                    target=int(self.edge_targets[k]) - m_start,
                    edge_type=IntergenicType(int(self.edge_types[k])),
                )
                for k in range(e_start, e_end)
            )
            sub_genes = self.genes[m_start:m_end] if len(self.genes) >= m_end else Genes.empty()
            return Locus(
                nodes=nodes,
                edges=edges,
                genes=sub_genes,
                interval=self.intervals[idx],
                contig_index=int(self.contig_indices[idx]) if len(self.contig_indices) > idx else 0,
                archetype=LocusArchetype(int(self.archetypes[idx]))
                if len(self.archetypes) > idx
                else LocusArchetype.UNKNOWN,
                completeness=float(self.completeness[idx]) if len(self.completeness) > idx else 0.0,
                score=float(self.scores[idx]) if len(self.scores) > idx else 0.0,
                is_tandem_split=bool(self.is_tandem_split[idx]) if len(self.is_tandem_split) > idx else False,
                name=str(self.names[idx]) if len(self.names) > idx else "",
            )

        kept_indices = self._normalize_indices(item)
        if len(kept_indices) == 0:
            return self.empty()

        new_nodes, new_node_offsets, new_sources, new_targets, new_types, new_edge_offsets = self._slice_soa_arrays(
            kept_indices
        )

        # Gather flat node-level `genes` matching the selected loci
        node_idx_slices = [
            np.arange(int(self.node_offsets[i]), int(self.node_offsets[i + 1]), dtype=np.int32) for i in kept_indices
        ]
        flat_node_indices = np.concatenate(node_idx_slices) if node_idx_slices else np.empty(0, dtype=np.int32)

        return type(self)(
            node_types=new_nodes,
            node_offsets=new_node_offsets,
            edge_sources=new_sources,
            edge_targets=new_targets,
            edge_types=new_types,
            edge_offsets=new_edge_offsets,
            genes=self.genes[flat_node_indices] if len(self.genes) > 0 else Genes.empty(),
            intervals=self.intervals[kept_indices],
            contig_indices=self.contig_indices[kept_indices],
            archetypes=self.archetypes[kept_indices],
            completeness=self.completeness[kept_indices],
            scores=self.scores[kept_indices],
            is_tandem_split=self.is_tandem_split[kept_indices],
            names=self.names[kept_indices],
        )

roles property

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

Flat 1D array of GeneRole indices (0..29) across all gene nodes in the batch.

cluster_ids property

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

Flat 1D array of homology cluster_id values across all gene nodes in the batch.

is_fragmented property

is_fragmented: ndarray[tuple[int], dtype[bool_]]

1D boolean array ((N,)) indicating whether each locus spans a contig break (gap_bin == 14).

num_frameshifts property

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

1D int32 array ((N,)) of frameshifted gene counts per locus.

frameshift_fractions property

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

1D float32 array ((N,)) of frameshifted gene fractions ([0.0, 1.0]) per locus.

has_mge property

has_mge: ndarray[tuple[int], dtype[bool_]]

1D boolean array ((N,)) indicating whether each locus contains TRANSPOSASE or INTEGRASE insertions.

is_intact property

is_intact: ndarray[tuple[int], dtype[bool_]]

1D boolean array ((N,)) indicating whether each locus is single-contig, MGE-free, and frameshift-free.

categories property

categories: ndarray[tuple[int], dtype[str_]]

1D string array ((N,)) of ArchetypeCategory values ('Capsule', 'O-Antigen', etc.) per locus.

with_names

with_names(names: Sequence[str | bytes] | ndarray) -> Self

Return a new Loci batch with updated per-locus identifier names.

Source code in src/serogen/loci/models.py
def with_names(self, names: Sequence[str | bytes] | np.ndarray) -> Self:
    """Return a new `Loci` batch with updated per-locus identifier `names`."""
    raw = [x.decode("utf-8", errors="replace") if isinstance(x, bytes) else str(x) for x in names]
    if len(raw) != len(self):
        raise ValueError(f"Expected {len(self)} locus names, got {len(raw)}.")
    names_arr = np.array(raw, dtype=str) if raw else np.empty(0, dtype="U1")
    return type(self)(
        node_types=self.node_types,
        node_offsets=self.node_offsets,
        edge_sources=self.edge_sources,
        edge_targets=self.edge_targets,
        edge_types=self.edge_types,
        edge_offsets=self.edge_offsets,
        genes=self.genes,
        intervals=self.intervals,
        contig_indices=self.contig_indices,
        archetypes=self.archetypes,
        completeness=self.completeness,
        scores=self.scores,
        is_tandem_split=self.is_tandem_split,
        names=names_arr,
    )

best

best(*, prefer_intact: bool = False) -> Locus | None

Return the highest-scoring Locus in the batch (or None if empty).

Parameters:

Name Type Description Default
prefer_intact bool

If True, prioritize intact loci without frameshifts or assembly breaks (e.g., an intact ectopic CPS_SYNTHASE over a frameshifted/cryptic CPS_WZY pseudogene cluster).

False
Source code in src/serogen/loci/models.py
def best(self, *, prefer_intact: bool = False) -> Locus | None:
    """Return the highest-scoring `Locus` in the batch (or `None` if empty).

    Args:
        prefer_intact: If True, prioritize intact loci without frameshifts or assembly breaks
            (e.g., an intact ectopic `CPS_SYNTHASE` over a frameshifted/cryptic `CPS_WZY` pseudogene cluster).
    """
    if len(self) == 0:
        return None
    if prefer_intact:
        fs_frac = self.frameshift_fractions
        frag = self.is_fragmented.astype(np.int32)
        # Lexsort ascending by (is_fragmented, frameshift_fraction, -score) and take the first index
        order = np.lexsort((-self.scores, fs_frac, frag))
        return self[int(order[0])]
    return self[int(np.argmax(self.scores))]

filter

filter(*, names: str | Iterable[str] | None = None, categories: ArchetypeCategory | str | Iterable[ArchetypeCategory | str] | None = None, archetypes: LocusArchetype | str | int | Iterable[LocusArchetype | str | int] | None = None, min_completeness: float = 0.0, min_score: float = 0.0, max_frameshift_fraction: float = 1.0, only_intact: bool = False) -> Self

Filter loci by names, ontology categories, specific archetypes, min_completeness, min_score, max_frameshift_fraction, and/or only_intact.

Source code in src/serogen/loci/models.py
def filter(
    self,
    *,
    names: str | Iterable[str] | None = None,
    categories: ArchetypeCategory | str | Iterable[ArchetypeCategory | str] | None = None,
    archetypes: LocusArchetype | str | int | Iterable[LocusArchetype | str | int] | None = None,
    min_completeness: float = 0.0,
    min_score: float = 0.0,
    max_frameshift_fraction: float = 1.0,
    only_intact: bool = False,
) -> Self:
    """Filter loci by `names`, ontology `categories`, specific `archetypes`, `min_completeness`, `min_score`, `max_frameshift_fraction`, and/or `only_intact`."""
    n = len(self)
    if n == 0:
        return self
    mask = np.ones(n, dtype=np.bool_)
    if names is not None:
        name_items = [names] if isinstance(names, str) else list(names)
        allowed_names = np.array([str(x) for x in name_items], dtype=str)
        mask &= np.isin(self.names, allowed_names)
    if min_completeness > 0.0:
        mask &= self.completeness >= min_completeness
    if min_score > 0.0:
        mask &= self.scores >= min_score
    if max_frameshift_fraction < 1.0:
        mask &= self.frameshift_fractions <= max_frameshift_fraction + 1e-6
    if only_intact:
        mask &= ~self.is_fragmented
        mask &= np.array([not loc.has_mobile_elements for loc in self], dtype=np.bool_)
    if categories is not None:
        cat_items = [categories] if isinstance(categories, (str, ArchetypeCategory)) else list(categories)
        allowed_cats = {
            (c.value if isinstance(c, ArchetypeCategory) else str(c)).strip().lower().replace("_", "-")
            for c in cat_items
        }
        t_cats = np.array(
            [str(c).strip().lower().replace("_", "-") for c in get_archetype_tensors().categories],
            dtype="U20",
        )
        arch_cat_ok = np.array([c in allowed_cats for c in t_cats], dtype=np.bool_)
        mask &= arch_cat_ok[self.archetypes]
    if archetypes is not None:
        arch_items: list[LocusArchetype | str | int] = (
            [archetypes] if isinstance(archetypes, (str, int, LocusArchetype)) else list(archetypes)
        )
        allowed_arch_ids = np.array([int(LocusArchetype.from_name(a)) for a in arch_items], dtype=np.int8)
        mask &= np.isin(self.archetypes, allowed_arch_ids)
    return self[mask]

empty classmethod

empty() -> Self

Create an empty 0-length Loci container.

Source code in src/serogen/loci/models.py
@classmethod
def empty(cls) -> Self:
    """Create an empty 0-length `Loci` container."""
    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),
        genes=Genes.empty(),
        intervals=Intervals.empty(),
        contig_indices=np.empty(0, dtype=np.uint32),
        archetypes=np.empty(0, dtype=np.int8),
        completeness=np.empty(0, dtype=np.float32),
        scores=np.empty(0, dtype=np.float32),
        is_tandem_split=np.empty(0, dtype=np.bool_),
        names=np.empty(0, dtype="U1"),
    )

concat classmethod

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

Concatenate multiple Loci batches into a single contiguous Loci container.

Source code in src/serogen/loci/models.py
@classmethod
def concat(cls, batches: Iterable[Self]) -> Self:
    """Concatenate multiple `Loci` batches into a single contiguous `Loci` container."""
    batch_list = [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,
        genes=Genes.concat([b.genes for b in batch_list]),
        intervals=Intervals.concat([b.intervals for b in batch_list]),
        contig_indices=np.concatenate([b.contig_indices for b in batch_list]),
        archetypes=np.concatenate([b.archetypes for b in batch_list]),
        completeness=np.concatenate([b.completeness for b in batch_list]),
        scores=np.concatenate([b.scores for b in batch_list]),
        is_tandem_split=np.concatenate([b.is_tandem_split for b in batch_list]),
        names=np.concatenate([b.names for b in batch_list]),
    )

from_list classmethod

from_list(loci: Sequence[Locus]) -> Self

Compile a sequence of scalar Locus objects into a contiguous Loci batch.

Source code in src/serogen/loci/models.py
@classmethod
def from_list(cls, loci: Sequence[Locus]) -> Self:
    """Compile a sequence of scalar `Locus` objects into a contiguous `Loci` batch."""
    n_loci = len(loci)
    if n_loci == 0:
        return cls.empty()

    node_offsets = np.empty(n_loci + 1, dtype=np.uint32)
    node_offsets[0] = 0
    edge_offsets = np.empty(n_loci + 1, dtype=np.uint32)
    edge_offsets[0] = 0

    tot_nodes = sum(len(loc.nodes) for loc in loci)
    tot_edges = sum(len(loc.edges) for loc in loci)

    node_types = np.empty(tot_nodes, dtype=np.uint32)
    edge_sources = np.empty(tot_edges, dtype=np.uint32)
    edge_targets = np.empty(tot_edges, dtype=np.uint32)
    edge_types = np.empty(tot_edges, dtype=np.uint32)

    starts = np.empty(n_loci, dtype=np.int32)
    ends = np.empty(n_loci, dtype=np.int32)
    strands = np.empty(n_loci, dtype=np.int8)
    contig_indices = np.empty(n_loci, dtype=np.uint32)
    archetypes = np.empty(n_loci, dtype=np.int8)
    completeness = np.empty(n_loci, dtype=np.float32)
    scores = np.empty(n_loci, dtype=np.float32)
    is_tandem_split = np.empty(n_loci, dtype=np.bool_)
    names_list: list[str] = []
    genes_batches: list[Genes] = []

    curr_node = 0
    curr_edge = 0

    for i, loc in enumerate(loci):
        n_n = len(loc.nodes)
        n_e = len(loc.edges)
        if n_n > 0:
            node_types[curr_node : curr_node + n_n] = [int(n) for n in loc.nodes]
        if n_e > 0:
            for j, e in enumerate(loc.edges):
                edge_sources[curr_edge + j] = e.source + curr_node
                edge_targets[curr_edge + j] = e.target + curr_node
                edge_types[curr_edge + j] = int(e.edge_type)

        curr_node += n_n
        curr_edge += n_e
        node_offsets[i + 1] = curr_node
        edge_offsets[i + 1] = curr_edge

        starts[i] = loc.interval.start
        ends[i] = loc.interval.end
        strands[i] = loc.interval.strand
        contig_indices[i] = loc.contig_index
        archetypes[i] = int(loc.archetype)
        completeness[i] = loc.completeness
        scores[i] = loc.score
        is_tandem_split[i] = loc.is_tandem_split
        names_list.append(loc.name)
        genes_batches.append(loc.genes)

    return cls(
        node_types=node_types,
        node_offsets=node_offsets,
        edge_sources=edge_sources,
        edge_targets=edge_targets,
        edge_types=edge_types,
        edge_offsets=edge_offsets,
        genes=Genes.concat(genes_batches),
        intervals=Intervals(starts=starts, ends=ends, strands=strands),
        contig_indices=contig_indices,
        archetypes=archetypes,
        completeness=completeness,
        scores=scores,
        is_tandem_split=is_tandem_split,
        names=np.array(names_list, dtype=str),
    )

canonicalize_strands

canonicalize_strands() -> Self

Orient all loci in the batch into canonical 5' -> 3' operon order (strand = +1).

Source code in src/serogen/loci/models.py
def canonicalize_strands(self) -> Self:
    """Orient all loci in the batch into canonical 5' -> 3' operon order (`strand = +1`)."""
    if len(self) == 0 or bool(np.all(self.intervals.strands >= 0)):
        return self
    return self.from_list([loc.canonicalize_strand() for loc in self])

heal_mobile_elements

heal_mobile_elements(remove_nodes: bool = True) -> Self

Excise mobile genetic element (TRANSPOSASE, INTEGRASE) insertions across all loci in the batch.

Source code in src/serogen/loci/models.py
def heal_mobile_elements(self, remove_nodes: bool = True) -> Self:
    """Excise mobile genetic element (`TRANSPOSASE`, `INTEGRASE`) insertions across all loci in the batch."""
    if not remove_nodes or len(self) == 0 or len(self.node_types) == 0:
        return self
    roles = self.roles
    has_mge = bool(np.any((roles == int(GeneRole.TRANSPOSASE)) | (roles == int(GeneRole.INTEGRASE))))
    if not has_mge:
        return self
    return self.from_list([loc.heal_mobile_elements(remove_nodes=True) for loc in self])

with_clusters

with_clusters(clusters: Clusters | ndarray) -> Self

Return a new Loci batch with homology cluster_id bitfields updated from Clusters or an ID array.

Source code in src/serogen/loci/models.py
def with_clusters(self, clusters: Clusters | np.ndarray) -> Self:
    """Return a new `Loci` batch with homology `cluster_id` bitfields updated from `Clusters` or an ID array."""
    c_ids = clusters.cluster_ids if isinstance(clusters, Clusters) else np.asarray(clusters, dtype=np.int32)
    if len(c_ids) != len(self.node_types):
        raise ValueError(
            f"Cluster IDs length ({len(c_ids)}) does not match total gene nodes ({len(self.node_types)})."
        )
    # In GeneNode, cluster_id = 0 means unclustered; shift 0-based Clusters IDs to 1-based if needed
    shifted = np.where(c_ids >= 0, c_ids + 1, 0).astype(np.uint32)
    cleared = self.node_types & ~np.uint32(GeneNode.CLUSTER_ID.mask)
    packed_cid = (shifted & np.uint32(GeneNode.CLUSTER_ID.max_val)) << np.uint32(GeneNode.CLUSTER_ID.shift)
    new_node_types = cleared | packed_cid
    return type(self)(
        node_types=new_node_types,
        node_offsets=self.node_offsets,
        edge_sources=self.edge_sources,
        edge_targets=self.edge_targets,
        edge_types=self.edge_types,
        edge_offsets=self.edge_offsets,
        genes=self.genes,
        intervals=self.intervals,
        contig_indices=self.contig_indices,
        archetypes=self.archetypes,
        completeness=self.completeness,
        scores=self.scores,
        is_tandem_split=self.is_tandem_split,
        names=self.names,
    )

cluster_genes

cluster_genes(clusterer: Clusterer | None = None, *, by_role: bool = True, classifier: Any | None = None, embedding_layer: str = 'task_weighted_conv_max', **kwargs: Any) -> Self

Cluster translated locus proteins and return a new Loci batch with packed homology CLUSTER_IDs.

Source code in src/serogen/loci/models.py
def cluster_genes(
    self,
    clusterer: Clusterer | None = None,
    *,
    by_role: bool = True,
    classifier: Any | None = None,
    embedding_layer: str = "task_weighted_conv_max",
    **kwargs: Any,
) -> Self:
    """Cluster translated locus proteins and return a new `Loci` batch with packed homology `CLUSTER_ID`s."""
    if len(self.node_types) == 0 or len(self.genes) != len(self.node_types):
        return self
    proteins = self.genes.translations
    if len(proteins) != len(self.node_types):
        return self
    engine = clusterer if clusterer is not None else Clusterer(**kwargs)
    roles = self.roles if by_role else None
    use_emb = classifier is not None or engine.embedding_weight > 0.0
    clf = classifier
    if use_emb and clf is None:
        from serogen.genes.cnn import GeneRoleClassifier

        clf = GeneRoleClassifier.pretrained()

    if len(proteins) > 500:
        seen_key_to_u: dict[tuple[int, bytes], int] = {}
        u_indices: list[int] = []
        inv_map = np.empty(len(proteins), dtype=np.int32)
        for g_i in range(len(proteins)):
            p_off = int(proteins.offsets[g_i])
            p_len = int(proteins.lengths[g_i])
            r_key = int(roles[g_i]) if roles is not None else 0
            key = (r_key, proteins.seqs[p_off : p_off + p_len].tobytes())
            u_idx = seen_key_to_u.get(key)
            if u_idx is None:
                u_idx = len(u_indices)
                seen_key_to_u[key] = u_idx
                u_indices.append(g_i)
            inv_map[g_i] = u_idx
        u_idx_arr = np.asarray(u_indices, dtype=np.int32)
        u_prots = proteins[u_idx_arr]
        u_embs = clf.transform(u_prots, layer=embedding_layer) if clf is not None else None
        u_clusters = engine.cluster(
            u_prots,
            roles=roles[u_idx_arr] if roles is not None else None,
            embeddings=u_embs,
        )
        return self.with_clusters(u_clusters.cluster_ids[inv_map])
    embs = clf.transform(proteins, layer=embedding_layer) if clf is not None else None
    clusters = engine.cluster(proteins, roles=roles, embeddings=embs)
    return self.with_clusters(clusters)

as_pan_graph

as_pan_graph(node_mask: int | None = None, *, canonicalize_strands: bool = True, heal_mges: bool = False, cluster_genes: bool = False, clusterer: Clusterer | None = None) -> tuple[csr_array, ndarray[tuple[int], dtype[uint32]], ndarray[tuple[int], dtype[uint32]]]

Compress the Loci batch into a directed consensus pan-locus graph.

Parameters:

Name Type Description Default
node_mask int | None

Optional bitmask to apply to GeneNode values before merging (e.g., GeneNode.mask("ROLE_ID", "CLUSTER_ID")).

None
canonicalize_strands bool

If True (default), orient -1 strand loci into canonical 5' -> 3' operon order prior to graph compression.

True
heal_mges bool

If True, excise TRANSPOSASE and INTEGRASE insertions and heal synteny edges prior to graph compression.

False
cluster_genes bool

If True (or if clusterer is provided), cluster translated gene proteins and pack homology CLUSTER_IDs into nodes before merging.

False
clusterer Clusterer | None

Optional pre-configured Clusterer instance to use when clustering proteins.

None

Returns:

Type Description
tuple[csr_array, ndarray[tuple[int], dtype[uint32]], ndarray[tuple[int], dtype[uint32]]]

A tuple of (adj, pan_nodes, node_counts).

Source code in src/serogen/loci/models.py
def as_pan_graph(
    self,
    node_mask: int | None = None,
    *,
    canonicalize_strands: bool = True,
    heal_mges: bool = False,
    cluster_genes: bool = False,
    clusterer: Clusterer | None = None,
) -> tuple[
    csr_array,
    np.ndarray[tuple[int], np.dtype[np.uint32]],
    np.ndarray[tuple[int], np.dtype[np.uint32]],
]:
    """Compress the `Loci` batch into a directed consensus pan-locus graph.

    Args:
        node_mask: Optional bitmask to apply to `GeneNode` values before merging
            (e.g., `GeneNode.mask("ROLE_ID", "CLUSTER_ID")`).
        canonicalize_strands: If True (default), orient `-1` strand loci into canonical
            5' -> 3' operon order prior to graph compression.
        heal_mges: If True, excise `TRANSPOSASE` and `INTEGRASE` insertions and heal
            synteny edges prior to graph compression.
        cluster_genes: If True (or if `clusterer` is provided), cluster translated gene
            proteins and pack homology `CLUSTER_ID`s into nodes before merging.
        clusterer: Optional pre-configured `Clusterer` instance to use when clustering proteins.

    Returns:
        A tuple of `(adj, pan_nodes, node_counts)`.
    """
    batch = self
    if heal_mges:
        batch = batch.heal_mobile_elements(remove_nodes=True)
    if canonicalize_strands:
        batch = batch.canonicalize_strands()
    if cluster_genes or clusterer is not None:
        batch = batch.cluster_genes(clusterer=clusterer)
    return super(Loci, batch).as_pan_graph(node_mask=node_mask)

match

match(query: Locus | Loci, *, top_k: int | None = None, min_score: float = 0.0, sort: bool = True, **matcher_kwargs: Any) -> Any

Compare one or more query loci against this Loci batch acting as a reference database.

Source code in src/serogen/loci/models.py
def match(
    self,
    query: Locus | Loci,
    *,
    top_k: int | None = None,
    min_score: float = 0.0,
    sort: bool = True,
    **matcher_kwargs: Any,
) -> Any:
    """Compare one or more `query` loci against this `Loci` batch acting as a reference database."""
    from serogen.loci.matcher import LocusMatcher

    matcher = LocusMatcher(self, **matcher_kwargs)
    return matcher.match(query, top_k=top_k, min_score=min_score, sort=sort)

Biosynthetic Archetypes (serogen.loci.archetypes)

archetypes

Biosynthetic locus archetypes, ontology categories, and structural specifications.

Classes:

Name Description
ArchetypeCategory

High-level structural category of a polysaccharide biosynthetic locus archetype.

ArchetypeSpec

Immutable biological specification for a canonical LocusArchetype.

LocusArchetype

Canonical biosynthetic locus archetypes (0..10).

ArchetypeTensors

Immutable container holding precomputed bitmask and role-indicator tensors for all LocusArchetypes.

Functions:

Name Description
roles_to_mask

Pack a collection of GeneRole values into a 32-bit integer presence bitmask.

mask_to_roles

Unpack a 32-bit integer presence bitmask into a sorted tuple of GeneRole enums.

get_archetype_tensors

Return the cached singleton ArchetypeTensors instance.

ArchetypeCategory

Bases: StrEnum

High-level structural category of a polysaccharide biosynthetic locus archetype.

Source code in src/serogen/loci/archetypes.py
class ArchetypeCategory(StrEnum):
    """High-level structural category of a polysaccharide biosynthetic locus archetype."""

    CAPSULE = "Capsule"
    O_ANTIGEN = "O-Antigen"
    EXOPOLYSACCHARIDE = "Exopolysaccharide"
    OTHER = "Other"

ArchetypeSpec dataclass

Immutable biological specification for a canonical LocusArchetype.

Attributes:

Name Type Description
required_mask int

Combined 32-bit union mask of all roles participating in any required module.

all_relevant_mask int

Combined 32-bit bitmask of all required and optional cargo roles.

num_modules int

Number of required functional modules (M_A) for this archetype.

Source code in src/serogen/loci/archetypes.py
@dataclass(frozen=True, slots=True)
class ArchetypeSpec:
    """Immutable biological specification for a canonical `LocusArchetype`."""

    name: str
    category: ArchetypeCategory
    forbidden_mask: int
    cargo_mask: int
    description: str
    module_masks: tuple[int, ...] = ()
    aliases: tuple[str, ...] = ()

    @property
    def required_mask(self) -> int:
        """Combined 32-bit union mask of all roles participating in any required module."""
        mask = 0
        for m in self.module_masks:
            mask |= m
        return mask

    @property
    def all_relevant_mask(self) -> int:
        """Combined 32-bit bitmask of all required and optional cargo roles."""
        return self.required_mask | self.cargo_mask

    @property
    def num_modules(self) -> int:
        """Number of required functional modules (`M_A`) for this archetype."""
        return len(self.module_masks)

required_mask property

required_mask: int

Combined 32-bit union mask of all roles participating in any required module.

all_relevant_mask property

all_relevant_mask: int

Combined 32-bit bitmask of all required and optional cargo roles.

num_modules property

num_modules: int

Number of required functional modules (M_A) for this archetype.

LocusArchetype

Bases: IntEnum

Canonical biosynthetic locus archetypes (0..10).

Defines the ontological classification and structural requirements of bacterial surface polysaccharide biosynthetic gene clusters. Scoring, completeness evaluation, and segment classification are encapsulated in ArchetypeClassifier.

Methods:

Name Description
from_name

Resolve an archetype by integer ID, enum name, or biological alias.

resolve

Resolve an archetype identifier, falling back to default (or UNKNOWN).

Attributes:

Name Type Description
spec ArchetypeSpec

Return the ArchetypeSpec metadata record for this archetype.

category ArchetypeCategory

High-level ArchetypeCategory (Capsule, O-Antigen, Exopolysaccharide, Other).

forbidden_mask int

32-bit bitmask of GeneRole bits that are incompatible with this archetype.

cargo_mask int

32-bit bitmask of optional cargo/modifier/precursor GeneRole bits.

required_mask int

Combined 32-bit union mask of all roles participating in any required module.

all_relevant_mask int

Combined 32-bit bitmask of all required and optional cargo roles.

description str

Human-readable biological description of the pathway.

module_masks tuple[int, ...]

Tuple of 32-bit disjunctive module bitmasks (M_1, ..., M_k) required for completeness.

num_modules int

Number of required functional modules (M_A) for this archetype.

Source code in src/serogen/loci/archetypes.py
class LocusArchetype(IntEnum):
    """Canonical biosynthetic locus archetypes (0..10).

    Defines the ontological classification and structural requirements of bacterial
    surface polysaccharide biosynthetic gene clusters. Scoring, completeness evaluation,
    and segment classification are encapsulated in [`ArchetypeClassifier`][serogen.loci.classifier.ArchetypeClassifier].
    """

    CPS_WZY = 0
    CPS_ABC = 1
    CPS_SYNTHASE = 2
    LPS_WZY = 3
    LPS_ABC = 4
    EPS_PEL = 5
    EPS_PGA = 6
    EPS_ALGINATE = 7
    EPS_BCS = 8
    TEICHOIC_ACID = 9
    UNKNOWN = 10

    @property
    def spec(self) -> ArchetypeSpec:
        """Return the `ArchetypeSpec` metadata record for this archetype."""
        return _get_archetype_specs()[int(self)]

    @property
    def category(self) -> ArchetypeCategory:
        """High-level `ArchetypeCategory` (`Capsule`, `O-Antigen`, `Exopolysaccharide`, `Other`)."""
        return self.spec.category

    @property
    def forbidden_mask(self) -> int:
        """32-bit bitmask of `GeneRole` bits that are incompatible with this archetype."""
        return self.spec.forbidden_mask

    @property
    def cargo_mask(self) -> int:
        """32-bit bitmask of optional cargo/modifier/precursor `GeneRole` bits."""
        return self.spec.cargo_mask

    @property
    def required_mask(self) -> int:
        """Combined 32-bit union mask of all roles participating in any required module."""
        return self.spec.required_mask

    @property
    def all_relevant_mask(self) -> int:
        """Combined 32-bit bitmask of all required and optional cargo roles."""
        return self.spec.all_relevant_mask

    @property
    def description(self) -> str:
        """Human-readable biological description of the pathway."""
        return self.spec.description

    @property
    def module_masks(self) -> tuple[int, ...]:
        """Tuple of 32-bit disjunctive module bitmasks (`M_1, ..., M_k`) required for completeness."""
        return self.spec.module_masks

    @property
    def num_modules(self) -> int:
        """Number of required functional modules (`M_A`) for this archetype."""
        return self.spec.num_modules

    @classmethod
    def from_name(cls, key: str | bytes | int | LocusArchetype) -> LocusArchetype:
        """Resolve an archetype by integer ID, enum name, or biological alias."""
        if isinstance(key, cls):
            return key
        if isinstance(key, (int, np.integer)):
            return cls(int(key))
        s = key.decode("utf-8", errors="replace") if isinstance(key, (bytes, bytearray)) else str(key)
        s_clean = s.strip().lower().replace("-", "_").replace(" ", "_")
        lookup = _get_archetype_lookup()
        if s_clean in lookup:
            return lookup[s_clean]
        raise KeyError(f"Unknown LocusArchetype identifier: {key!r}")

    @classmethod
    def resolve(
        cls,
        key: str | bytes | int | LocusArchetype,
        default: LocusArchetype | None = None,
    ) -> LocusArchetype:
        """Resolve an archetype identifier, falling back to `default` (or `UNKNOWN`)."""
        fallback = cls.UNKNOWN if default is None else default
        try:
            return cls.from_name(key)
        except KeyError, ValueError:
            return fallback

spec property

Return the ArchetypeSpec metadata record for this archetype.

category property

High-level ArchetypeCategory (Capsule, O-Antigen, Exopolysaccharide, Other).

forbidden_mask property

forbidden_mask: int

32-bit bitmask of GeneRole bits that are incompatible with this archetype.

cargo_mask property

cargo_mask: int

32-bit bitmask of optional cargo/modifier/precursor GeneRole bits.

required_mask property

required_mask: int

Combined 32-bit union mask of all roles participating in any required module.

all_relevant_mask property

all_relevant_mask: int

Combined 32-bit bitmask of all required and optional cargo roles.

description property

description: str

Human-readable biological description of the pathway.

module_masks property

module_masks: tuple[int, ...]

Tuple of 32-bit disjunctive module bitmasks (M_1, ..., M_k) required for completeness.

num_modules property

num_modules: int

Number of required functional modules (M_A) for this archetype.

from_name classmethod

from_name(key: str | bytes | int | LocusArchetype) -> LocusArchetype

Resolve an archetype by integer ID, enum name, or biological alias.

Source code in src/serogen/loci/archetypes.py
@classmethod
def from_name(cls, key: str | bytes | int | LocusArchetype) -> LocusArchetype:
    """Resolve an archetype by integer ID, enum name, or biological alias."""
    if isinstance(key, cls):
        return key
    if isinstance(key, (int, np.integer)):
        return cls(int(key))
    s = key.decode("utf-8", errors="replace") if isinstance(key, (bytes, bytearray)) else str(key)
    s_clean = s.strip().lower().replace("-", "_").replace(" ", "_")
    lookup = _get_archetype_lookup()
    if s_clean in lookup:
        return lookup[s_clean]
    raise KeyError(f"Unknown LocusArchetype identifier: {key!r}")

resolve classmethod

resolve(key: str | bytes | int | LocusArchetype, default: LocusArchetype | None = None) -> LocusArchetype

Resolve an archetype identifier, falling back to default (or UNKNOWN).

Source code in src/serogen/loci/archetypes.py
@classmethod
def resolve(
    cls,
    key: str | bytes | int | LocusArchetype,
    default: LocusArchetype | None = None,
) -> LocusArchetype:
    """Resolve an archetype identifier, falling back to `default` (or `UNKNOWN`)."""
    fallback = cls.UNKNOWN if default is None else default
    try:
        return cls.from_name(key)
    except KeyError, ValueError:
        return fallback

ArchetypeTensors dataclass

Immutable container holding precomputed bitmask and role-indicator tensors for all LocusArchetypes.

Encapsulates the vectorized lookup tables required by ArchetypeClassifier and LocusSegmenter.

Methods:

Name Description
build

Construct and freeze all archetype bitmask and indicator tensors from LocusArchetype specs.

Source code in src/serogen/loci/archetypes.py
@dataclass(frozen=True, slots=True)
class ArchetypeTensors:
    """Immutable container holding precomputed bitmask and role-indicator tensors for all `LocusArchetype`s.

    Encapsulates the vectorized lookup tables required by [`ArchetypeClassifier`][serogen.loci.classifier.ArchetypeClassifier]
    and [`LocusSegmenter`][serogen.loci.segmenter.LocusSegmenter].
    """

    names: np.ndarray[tuple[int], np.dtype[np.str_]]
    categories: np.ndarray[tuple[int], np.dtype[np.str_]]
    forbidden_masks: np.ndarray[tuple[int], np.dtype[np.uint32]]
    cargo_masks: np.ndarray[tuple[int], np.dtype[np.uint32]]
    required_masks: np.ndarray[tuple[int], np.dtype[np.uint32]]
    all_relevant_masks: np.ndarray[tuple[int], np.dtype[np.uint32]]
    module_masks: np.ndarray[tuple[int, int], np.dtype[np.uint32]]
    num_modules: np.ndarray[tuple[int], np.dtype[np.int32]]
    active_module_mask: np.ndarray[tuple[int, int], np.dtype[np.bool_]]
    module_indicators: np.ndarray[tuple[int, int, int], np.dtype[np.float64]]
    forbidden_indicators: np.ndarray[tuple[int, int], np.dtype[np.float64]]
    relevant_indicators: np.ndarray[tuple[int, int], np.dtype[np.float64]]
    null_priors: np.ndarray[tuple[int], np.dtype[np.float64]]
    machinery_roles: np.ndarray[tuple[int], np.dtype[np.int32]]
    biosynthetic_mask: np.ndarray[tuple[int], np.dtype[np.bool_]]

    @classmethod
    def build(cls) -> Self:
        """Construct and freeze all archetype bitmask and indicator tensors from `LocusArchetype` specs."""
        specs = _get_archetype_specs()
        n_arch = len(LocusArchetype)
        n_roles = len(GeneRole)
        max_mods = max(s.num_modules for s in specs)

        names = np.array([s.name for s in specs], dtype="U16")
        categories = np.array([s.category.value for s in specs], dtype="U20")
        forbidden_masks = np.array([s.forbidden_mask for s in specs], dtype=np.uint32)
        cargo_masks = np.array([s.cargo_mask for s in specs], dtype=np.uint32)
        required_masks = np.array([s.required_mask for s in specs], dtype=np.uint32)
        all_relevant_masks = np.array([s.all_relevant_mask for s in specs], dtype=np.uint32)

        module_masks = np.zeros((n_arch, max_mods), dtype=np.uint32)
        num_modules = np.zeros(n_arch, dtype=np.int32)
        for a_idx, spec in enumerate(specs):
            num_modules[a_idx] = spec.num_modules
            for m_idx, m_mask in enumerate(spec.module_masks):
                module_masks[a_idx, m_idx] = m_mask

        module_indicators = np.zeros((n_arch, max_mods, n_roles), dtype=np.float64)
        forbidden_indicators = np.zeros((n_arch, n_roles), dtype=np.float64)
        relevant_indicators = np.zeros((n_arch, n_roles), dtype=np.float64)

        for a_idx in range(n_arch):
            f_mask = int(forbidden_masks[a_idx])
            r_mask = int(all_relevant_masks[a_idx])
            n_m = int(num_modules[a_idx])
            for r_idx in range(n_roles):
                bit = 1 << r_idx
                if f_mask & bit:
                    forbidden_indicators[a_idx, r_idx] = 1.0
                if r_mask & bit:
                    relevant_indicators[a_idx, r_idx] = 1.0
                for m_idx in range(n_m):
                    if int(module_masks[a_idx, m_idx]) & bit:
                        module_indicators[a_idx, m_idx, r_idx] = 1.0

        null_priors = np.full(n_roles, 0.005, dtype=np.float64)
        null_priors[int(GeneRole.UNKNOWN)] = 0.825
        null_priors[int(GeneRole.TRANSPOSASE)] = 0.015
        null_priors[int(GeneRole.INTEGRASE)] = 0.015
        null_priors /= null_priors.sum()

        active_module_mask = np.arange(max_mods)[None, :] < num_modules[:, None]

        machinery_roles = np.array(
            [
                int(GeneRole.FLIPPASE),
                int(GeneRole.POLYMERASE),
                int(GeneRole.COPOLYMERASE),
                int(GeneRole.KINASE),
                int(GeneRole.PHOSPHATASE),
                int(GeneRole.ABC_PERMEASE),
                int(GeneRole.ABC_ATPASE),
                int(GeneRole.LIGASE),
                int(GeneRole.TRANSLOCON),
                int(GeneRole.CAPSULE_ANCHOR),
                int(GeneRole.TRANSMEMBRANE_SYNTHASE),
                int(GeneRole.INITIATOR),
            ],
            dtype=np.int32,
        )
        biosynthetic_mask = np.array([r.is_biosynthetic for r in GeneRole], dtype=np.bool_)

        for arr in (
            names,
            categories,
            forbidden_masks,
            cargo_masks,
            required_masks,
            all_relevant_masks,
            module_masks,
            num_modules,
            active_module_mask,
            module_indicators,
            forbidden_indicators,
            relevant_indicators,
            null_priors,
            machinery_roles,
            biosynthetic_mask,
        ):
            arr.setflags(write=False)

        return cls(
            names=names,
            categories=categories,
            forbidden_masks=forbidden_masks,
            cargo_masks=cargo_masks,
            required_masks=required_masks,
            all_relevant_masks=all_relevant_masks,
            module_masks=module_masks,
            num_modules=num_modules,
            active_module_mask=active_module_mask,
            module_indicators=module_indicators,
            forbidden_indicators=forbidden_indicators,
            relevant_indicators=relevant_indicators,
            null_priors=null_priors,
            machinery_roles=machinery_roles,
            biosynthetic_mask=biosynthetic_mask,
        )

build classmethod

build() -> Self

Construct and freeze all archetype bitmask and indicator tensors from LocusArchetype specs.

Source code in src/serogen/loci/archetypes.py
@classmethod
def build(cls) -> Self:
    """Construct and freeze all archetype bitmask and indicator tensors from `LocusArchetype` specs."""
    specs = _get_archetype_specs()
    n_arch = len(LocusArchetype)
    n_roles = len(GeneRole)
    max_mods = max(s.num_modules for s in specs)

    names = np.array([s.name for s in specs], dtype="U16")
    categories = np.array([s.category.value for s in specs], dtype="U20")
    forbidden_masks = np.array([s.forbidden_mask for s in specs], dtype=np.uint32)
    cargo_masks = np.array([s.cargo_mask for s in specs], dtype=np.uint32)
    required_masks = np.array([s.required_mask for s in specs], dtype=np.uint32)
    all_relevant_masks = np.array([s.all_relevant_mask for s in specs], dtype=np.uint32)

    module_masks = np.zeros((n_arch, max_mods), dtype=np.uint32)
    num_modules = np.zeros(n_arch, dtype=np.int32)
    for a_idx, spec in enumerate(specs):
        num_modules[a_idx] = spec.num_modules
        for m_idx, m_mask in enumerate(spec.module_masks):
            module_masks[a_idx, m_idx] = m_mask

    module_indicators = np.zeros((n_arch, max_mods, n_roles), dtype=np.float64)
    forbidden_indicators = np.zeros((n_arch, n_roles), dtype=np.float64)
    relevant_indicators = np.zeros((n_arch, n_roles), dtype=np.float64)

    for a_idx in range(n_arch):
        f_mask = int(forbidden_masks[a_idx])
        r_mask = int(all_relevant_masks[a_idx])
        n_m = int(num_modules[a_idx])
        for r_idx in range(n_roles):
            bit = 1 << r_idx
            if f_mask & bit:
                forbidden_indicators[a_idx, r_idx] = 1.0
            if r_mask & bit:
                relevant_indicators[a_idx, r_idx] = 1.0
            for m_idx in range(n_m):
                if int(module_masks[a_idx, m_idx]) & bit:
                    module_indicators[a_idx, m_idx, r_idx] = 1.0

    null_priors = np.full(n_roles, 0.005, dtype=np.float64)
    null_priors[int(GeneRole.UNKNOWN)] = 0.825
    null_priors[int(GeneRole.TRANSPOSASE)] = 0.015
    null_priors[int(GeneRole.INTEGRASE)] = 0.015
    null_priors /= null_priors.sum()

    active_module_mask = np.arange(max_mods)[None, :] < num_modules[:, None]

    machinery_roles = np.array(
        [
            int(GeneRole.FLIPPASE),
            int(GeneRole.POLYMERASE),
            int(GeneRole.COPOLYMERASE),
            int(GeneRole.KINASE),
            int(GeneRole.PHOSPHATASE),
            int(GeneRole.ABC_PERMEASE),
            int(GeneRole.ABC_ATPASE),
            int(GeneRole.LIGASE),
            int(GeneRole.TRANSLOCON),
            int(GeneRole.CAPSULE_ANCHOR),
            int(GeneRole.TRANSMEMBRANE_SYNTHASE),
            int(GeneRole.INITIATOR),
        ],
        dtype=np.int32,
    )
    biosynthetic_mask = np.array([r.is_biosynthetic for r in GeneRole], dtype=np.bool_)

    for arr in (
        names,
        categories,
        forbidden_masks,
        cargo_masks,
        required_masks,
        all_relevant_masks,
        module_masks,
        num_modules,
        active_module_mask,
        module_indicators,
        forbidden_indicators,
        relevant_indicators,
        null_priors,
        machinery_roles,
        biosynthetic_mask,
    ):
        arr.setflags(write=False)

    return cls(
        names=names,
        categories=categories,
        forbidden_masks=forbidden_masks,
        cargo_masks=cargo_masks,
        required_masks=required_masks,
        all_relevant_masks=all_relevant_masks,
        module_masks=module_masks,
        num_modules=num_modules,
        active_module_mask=active_module_mask,
        module_indicators=module_indicators,
        forbidden_indicators=forbidden_indicators,
        relevant_indicators=relevant_indicators,
        null_priors=null_priors,
        machinery_roles=machinery_roles,
        biosynthetic_mask=biosynthetic_mask,
    )

roles_to_mask

roles_to_mask(roles: Iterable[int | GeneRole] | ndarray) -> int

Pack a collection of GeneRole values into a 32-bit integer presence bitmask.

Source code in src/serogen/loci/archetypes.py
def roles_to_mask(roles: Iterable[int | GeneRole] | np.ndarray) -> int:
    """Pack a collection of `GeneRole` values into a 32-bit integer presence bitmask."""
    mask = 0
    if isinstance(roles, np.ndarray):
        if roles.size == 0:
            return 0
        valid = roles[(roles >= 0) & (roles < 30)]
        if valid.size == 0:
            return 0
        return int(np.bitwise_or.reduce(1 << valid.astype(np.int64)))

    for r in roles:
        val = int(r)
        if 0 <= val < 30:
            mask |= 1 << val
    return mask

mask_to_roles

mask_to_roles(mask: int) -> tuple[GeneRole, ...]

Unpack a 32-bit integer presence bitmask into a sorted tuple of GeneRole enums.

Source code in src/serogen/loci/archetypes.py
def mask_to_roles(mask: int) -> tuple[GeneRole, ...]:
    """Unpack a 32-bit integer presence bitmask into a sorted tuple of `GeneRole` enums."""
    return tuple(role for role in GeneRole if (mask & (1 << int(role))) != 0)

get_archetype_tensors cached

get_archetype_tensors() -> ArchetypeTensors

Return the cached singleton ArchetypeTensors instance.

Source code in src/serogen/loci/archetypes.py
@lru_cache(maxsize=1)
def get_archetype_tensors() -> ArchetypeTensors:
    """Return the cached singleton `ArchetypeTensors` instance."""
    return ArchetypeTensors.build()

Archetype Classification (serogen.loci.classifier)

classifier

Probabilistic and bitmask-based classifier for biosynthetic locus archetypes.

Classes:

Name Description
ArchetypeClassifier

Scikit-learn compatible classifier and segment scorer for LocusArchetypes.

ArchetypeClassifier

Scikit-learn compatible classifier and segment scorer for LocusArchetypes.

Encapsulates all discrete bitmask evaluations and continuous noisy-OR log-likelihood-ratio (LLR) scoring over ArchetypeTensors. Works out-of-the-box with domain-calibrated weights (w_comp, w_incomp, w_llr, unknown_logit), and optionally supports empirical weight calibration via .fit(X, y) on labeled loci.

Methods:

Name Description
check_modules

Count how many required functional modules are satisfied for each archetype ((11,)).

check_forbidden

Return a boolean array (11,) indicating whether any forbidden role is present.

is_complete

Evaluate whether a 32-bit role presence mask satisfies each archetype.

classify_mask

Classify a single 32-bit role presence mask into its best-matching LocusArchetype.

is_boundary_relevant

Return True if role is a valid boundary/anchor/cargo gene for archetype (excluding MGEs/UNKNOWN).

to_probability_matrix

Convert 1D discrete role indices or a 2D probability matrix into a clean (L, 30) float64 matrix.

precompute_prefixes

Precompute \(O(L)\) prefix sums of log-complement and log-odds probabilities across a contig.

score_segments

Evaluate completeness, incompatibility, and LLR scores for \(K\) candidate segments in \(O(K \cdot |\mathcal{A}|)\).

score_components

Compute (llr_scores, completeness, incompatibility) matrices of shape (N, 11) across a batch.

decision_function

Compute unnormalized archetype logits of shape (N, 11) for input segments.

predict_proba

Compute calibrated softmax posterior probabilities (N, 11) across archetypes.

predict

Predict the integer LocusArchetype index (0..10) for each input locus or segment.

classify

Classify a single Locus, role sequence, or (L, 30) probability matrix into (archetype, completeness, score).

fit

Calibrate w_comp_, w_incomp_, w_llr_, and unknown_logit_ via L-BFGS-B cross-entropy minimization.

score

Compute classification accuracy on labeled segments (X, y).

Attributes:

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

1D array of archetype class labels (0..10).

active_weights tuple[float, float, float, float]

Return (w_comp, w_incomp, w_llr, unknown_logit), preferring fitted parameters if present.

Source code in src/serogen/loci/classifier.py
class ArchetypeClassifier:
    """Scikit-learn compatible classifier and segment scorer for `LocusArchetype`s.

    Encapsulates all discrete bitmask evaluations and continuous noisy-OR log-likelihood-ratio
    (LLR) scoring over [`ArchetypeTensors`][serogen.loci.archetypes.ArchetypeTensors]. Works out-of-the-box
    with domain-calibrated weights (`w_comp`, `w_incomp`, `w_llr`, `unknown_logit`), and optionally
    supports empirical weight calibration via `.fit(X, y)` on labeled loci.
    """

    def __init__(
        self,
        min_completeness: float = 0.80,
        max_incompatibility: float = 0.25,
        w_comp: float = 10.0,
        w_incomp: float = 15.0,
        w_llr: float = 0.15,
        unknown_logit: float = 4.5,
        tensors: ArchetypeTensors | None = None,
    ) -> None:
        self.min_completeness = min_completeness
        self.max_incompatibility = max_incompatibility
        self.w_comp = w_comp
        self.w_incomp = w_incomp
        self.w_llr = w_llr
        self.unknown_logit = unknown_logit
        self.tensors = tensors if tensors is not None else get_archetype_tensors()

    @property
    def classes_(self) -> np.ndarray[tuple[int], np.dtype[np.int32]]:
        """1D array of archetype class labels (`0..10`)."""
        return np.arange(len(LocusArchetype), dtype=np.int32)

    @property
    def active_weights(self) -> tuple[float, float, float, float]:
        """Return `(w_comp, w_incomp, w_llr, unknown_logit)`, preferring fitted parameters if present."""
        w_c = getattr(self, "w_comp_", self.w_comp)
        w_i = getattr(self, "w_incomp_", self.w_incomp)
        w_l = getattr(self, "w_llr_", self.w_llr)
        u_l = getattr(self, "unknown_logit_", self.unknown_logit)
        return float(w_c), float(w_i), float(w_l), float(u_l)

    # ------------------------------------------------------------------------------------------------------------------
    # Discrete Bitmask Operations
    # ------------------------------------------------------------------------------------------------------------------
    def check_modules(self, mask: int) -> np.ndarray[tuple[int], np.dtype[np.int32]]:
        """Count how many required functional modules are satisfied for each archetype (`(11,)`)."""
        m = np.uint32(mask)
        satisfied = (self.tensors.module_masks & m) != 0
        satisfied &= self.tensors.active_module_mask
        return np.sum(satisfied, axis=1, dtype=np.int32)

    def check_forbidden(self, mask: int) -> np.ndarray[tuple[int], np.dtype[np.bool_]]:
        """Return a boolean array `(11,)` indicating whether any forbidden role is present."""
        return (self.tensors.forbidden_masks & np.uint32(mask)) != 0

    def is_complete(
        self, mask: int, min_completeness: float | None = None
    ) -> np.ndarray[tuple[int], np.dtype[np.bool_]]:
        """Evaluate whether a 32-bit role presence mask satisfies each archetype."""
        thresh = self.min_completeness if min_completeness is None else min_completeness
        mod_counts = self.check_modules(mask)
        no_forbidden = ~self.check_forbidden(mask)
        has_modules = self.tensors.num_modules > 0
        denom = np.maximum(self.tensors.num_modules, 1)
        completeness = np.where(has_modules, mod_counts / denom, 0.0)
        return has_modules & no_forbidden & (completeness >= thresh)

    def classify_mask(
        self,
        mask: int,
        min_completeness: float | None = None,
    ) -> tuple[LocusArchetype, float]:
        """Classify a single 32-bit role presence mask into its best-matching `LocusArchetype`."""
        thresh = self.min_completeness if min_completeness is None else min_completeness
        mod_counts = self.check_modules(mask)
        no_forbidden = ~self.check_forbidden(mask)
        has_modules = self.tensors.num_modules > 0
        denom = np.maximum(self.tensors.num_modules, 1)
        completeness = np.where(has_modules & no_forbidden, mod_counts / denom, 0.0)

        cargo_overlap = np.array(
            [(int(c_mask) & int(mask)).bit_count() for c_mask in self.tensors.all_relevant_masks],
            dtype=np.float64,
        )
        ranking = completeness + 1e-3 * mod_counts + 1e-5 * cargo_overlap
        ranking[int(LocusArchetype.UNKNOWN)] = -1.0

        best_idx = int(np.argmax(ranking))
        best_comp = float(completeness[best_idx])
        if best_comp < thresh or best_comp == 0.0:
            return LocusArchetype.UNKNOWN, best_comp
        return LocusArchetype(best_idx), best_comp

    def is_boundary_relevant(self, archetype: LocusArchetype | int, role: GeneRole | int) -> bool:
        """Return True if `role` is a valid boundary/anchor/cargo gene for `archetype` (excluding MGEs/UNKNOWN)."""
        r_idx = int(role)
        if r_idx in (int(GeneRole.TRANSPOSASE), int(GeneRole.INTEGRASE), int(GeneRole.UNKNOWN)):
            return False
        a_idx = int(archetype)
        return (int(self.tensors.all_relevant_masks[a_idx]) & (1 << r_idx)) != 0

    # ------------------------------------------------------------------------------------------------------------------
    # Prefix-Sum Vectorized Soft Probability Scoring
    # ------------------------------------------------------------------------------------------------------------------
    @staticmethod
    def to_probability_matrix(
        roles_or_probs: np.ndarray | Sequence[int | GeneRole],
        confidence: float = 0.98,
    ) -> np.ndarray[tuple[int, int], np.dtype[np.float64]]:
        """Convert 1D discrete role indices or a 2D probability matrix into a clean `(L, 30)` float64 matrix."""
        arr = np.asarray(roles_or_probs)
        n_roles = len(GeneRole)
        if arr.ndim == 2:
            if arr.shape[1] != n_roles:
                raise ValueError(f"Expected 2D probability matrix with {n_roles} columns, got shape {arr.shape}.")
            probs = np.clip(arr.astype(np.float64, copy=False), 1e-12, 1.0)
            row_sums = probs.sum(axis=1, keepdims=True)
            return np.where(row_sums > 0.0, probs / row_sums, 1.0 / n_roles)

        if arr.ndim != 1:
            raise ValueError(f"Expected 1D role index array or 2D probability matrix, got {arr.ndim}D.")

        length = len(arr)
        if length == 0:
            return np.empty((0, n_roles), dtype=np.float64)

        smooth = (1.0 - confidence) / (n_roles - 1)
        probs = np.full((length, n_roles), smooth, dtype=np.float64)
        valid_mask = (arr >= 0) & (arr < n_roles)
        rows = np.nonzero(valid_mask)[0]
        cols = arr[valid_mask].astype(np.intp)
        probs[rows, cols] = confidence
        if not np.all(valid_mask):
            probs[~valid_mask, int(GeneRole.UNKNOWN)] = confidence
        return probs

    def precompute_prefixes(
        self,
        probs: np.ndarray[tuple[int, int], np.dtype[np.float64]],
    ) -> tuple[
        np.ndarray[tuple[int, int, int], np.dtype[np.float64]],
        np.ndarray[tuple[int, int], np.dtype[np.float64]],
        np.ndarray[tuple[int, int], np.dtype[np.float64]],
    ]:
        """Precompute $O(L)$ prefix sums of log-complement and log-odds probabilities across a contig."""
        t = self.tensors
        length = len(probs)
        n_arch, max_mods, _ = t.module_indicators.shape

        mod_probs = np.where(probs >= 0.12, probs, 0.0)
        mod_hit = np.clip(np.einsum("lr,amr->lam", mod_probs, t.module_indicators), 0.0, 1.0)
        log_not_mod = np.log(np.clip(1.0 - mod_hit, 1e-12, 1.0))

        forb_probs = np.where(probs >= 0.05, probs, 0.0)
        forb_hit = np.clip(forb_probs @ t.forbidden_indicators.T, 0.0, 1.0)
        log_not_forb = np.log(np.clip(1.0 - forb_hit, 1e-12, 1.0))

        null_mass = probs @ t.null_priors
        rel_mass = probs @ t.relevant_indicators.T
        arch_emission = 0.85 * rel_mass + 0.15 * null_mass[:, None]
        forb_penalty = np.log(np.clip(1.0 - 0.95 * forb_hit, 1e-6, 1.0))
        mge_mass = np.clip(
            probs[:, int(GeneRole.TRANSPOSASE)] + probs[:, int(GeneRole.INTEGRASE)],
            0.0,
            1.0,
        )
        step_llr = (np.log(np.clip(arch_emission, 1e-12, None)) - np.log(np.clip(null_mass[:, None], 1e-12, None))) * (
            1.0 - mge_mass[:, None]
        )
        step_llr += forb_penalty
        step_llr[:, int(LocusArchetype.UNKNOWN)] = 0.0

        pref_mod = np.zeros((length + 1, n_arch, max_mods), dtype=np.float64)
        pref_forb = np.zeros((length + 1, n_arch), dtype=np.float64)
        pref_llr = np.zeros((length + 1, n_arch), dtype=np.float64)

        if length > 0:
            np.cumsum(log_not_mod, axis=0, out=pref_mod[1:])
            np.cumsum(log_not_forb, axis=0, out=pref_forb[1:])
            np.cumsum(step_llr, axis=0, out=pref_llr[1:])

        return pref_mod, pref_forb, pref_llr

    def score_segments(
        self,
        starts: np.ndarray[tuple[int], np.dtype[np.intp]],
        ends: np.ndarray[tuple[int], np.dtype[np.intp]],
        *,
        probs: np.ndarray[tuple[int, int], np.dtype[np.float64]] | None = None,
        prefixes: tuple[np.ndarray, np.ndarray, np.ndarray] | None = None,
    ) -> tuple[
        np.ndarray[tuple[int, int], np.dtype[np.float64]],
        np.ndarray[tuple[int, int], np.dtype[np.float64]],
        np.ndarray[tuple[int, int], np.dtype[np.float64]],
    ]:
        """Evaluate completeness, incompatibility, and LLR scores for $K$ candidate segments in $O(K \\cdot |\\mathcal{A}|)$."""
        if prefixes is None:
            if probs is None:
                raise ValueError("Either `probs` or `prefixes` must be provided to `score_segments`.")
            prefixes = self.precompute_prefixes(probs)

        t = self.tensors
        pref_mod, pref_forb, pref_llr = prefixes
        seg_log_not_mod = pref_mod[ends] - pref_mod[starts]
        raw_mod_probs = 1.0 - np.exp(seg_log_not_mod)
        mod_probs = np.minimum(1.0, raw_mod_probs / 0.95) * t.active_module_mask[None, :, :]
        denom = np.maximum(t.num_modules, 1)[None, :]
        has_mods = (t.num_modules > 0)[None, :]
        completeness = np.where(has_mods, mod_probs.sum(axis=2) / denom, 0.0)

        seg_log_not_forb = pref_forb[ends] - pref_forb[starts]
        incompatibility = 1.0 - np.exp(seg_log_not_forb)

        llr_scores = pref_llr[ends] - pref_llr[starts]
        return completeness, incompatibility, llr_scores

    # ------------------------------------------------------------------------------------------------------------------
    # Scikit-Learn Estimator Interface & Locus/Loci Classification
    # ------------------------------------------------------------------------------------------------------------------
    def _score_single(
        self,
        item: Locus | np.ndarray | Sequence[int | GeneRole],
    ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
        if isinstance(item, Locus):
            if item.num_genes == 0:
                z = np.zeros(len(LocusArchetype), dtype=np.float64)
                return z, z, z
            probs = self.to_probability_matrix(item.roles)
        else:
            probs = self.to_probability_matrix(item)

        if len(probs) == 0:
            z = np.zeros(len(LocusArchetype), dtype=np.float64)
            return z, z, z

        comp, incomp, llr = self.score_segments(
            np.array([0], dtype=np.intp),
            np.array([len(probs)], dtype=np.intp),
            probs=probs,
        )
        return llr[0], comp[0], incomp[0]

    def score_components(
        self,
        X: ArchetypeInput,
    ) -> tuple[
        np.ndarray[tuple[int, int], np.dtype[np.float64]],
        np.ndarray[tuple[int, int], np.dtype[np.float64]],
        np.ndarray[tuple[int, int], np.dtype[np.float64]],
    ]:
        """Compute `(llr_scores, completeness, incompatibility)` matrices of shape `(N, 11)` across a batch."""
        items: list[Any]
        if isinstance(X, Loci):
            items = [X[i] for i in range(len(X))]
        elif isinstance(X, Locus):
            items = [X]
        elif isinstance(X, np.ndarray):
            if X.ndim in (1, 2):
                items = [X]
            else:
                items = [X[i] for i in range(len(X))]
        else:
            seq: list[Any] = list(X)
            if len(seq) == 0:
                e = np.empty((0, len(LocusArchetype)), dtype=np.float64)
                return e, e, e
            if isinstance(seq[0], (int, np.integer, GeneRole)):
                items = [seq]
            else:
                items = seq

        n = len(items)
        n_arch = len(LocusArchetype)
        scores = np.zeros((n, n_arch), dtype=np.float64)
        comps = np.zeros((n, n_arch), dtype=np.float64)
        incomps = np.zeros((n, n_arch), dtype=np.float64)

        for i, it in enumerate(items):
            s, c, inc = self._score_single(it)
            scores[i] = s
            comps[i] = c
            incomps[i] = inc
        return scores, comps, incomps

    def decision_function(self, X: ArchetypeInput) -> np.ndarray[tuple[int, int], np.dtype[np.float64]]:
        """Compute unnormalized archetype logits of shape `(N, 11)` for input segments."""
        scores, comps, incomps = self.score_components(X)
        w_c, w_i, w_l, u_l = self.active_weights
        logits = w_c * comps - w_i * incomps + w_l * np.maximum(scores, 0.0)
        logits[:, int(LocusArchetype.UNKNOWN)] = u_l
        return logits

    def predict_proba(self, X: ArchetypeInput) -> np.ndarray[tuple[int, int], np.dtype[np.float64]]:
        """Compute calibrated softmax posterior probabilities `(N, 11)` across archetypes."""
        logits = self.decision_function(X)
        if len(logits) == 0:
            return logits
        shifted = logits - np.max(logits, axis=1, keepdims=True)
        exp_l = np.exp(shifted)
        return exp_l / np.sum(exp_l, axis=1, keepdims=True)

    def predict(self, X: ArchetypeInput) -> np.ndarray[tuple[int], np.dtype[np.int32]]:
        """Predict the integer `LocusArchetype` index (`0..10`) for each input locus or segment."""
        scores, comps, incomps = self.score_components(X)
        n = len(scores)
        if n == 0:
            return np.empty(0, dtype=np.int32)

        w_c, w_i, w_l, _ = self.active_weights
        eff = comps * (1.0 - incomps) + 1e-2 * (w_l * np.maximum(scores, 0.0) / max(w_c, 1.0))
        eff[:, int(LocusArchetype.UNKNOWN)] = -1.0

        valid = (comps >= self.min_completeness) & (incomps <= self.max_incompatibility)
        valid[:, int(LocusArchetype.UNKNOWN)] = False

        masked_eff = np.where(valid, eff, -1.0)
        best_idx = np.argmax(masked_eff, axis=1).astype(np.int32)
        has_valid = np.any(valid, axis=1)
        return np.where(has_valid, best_idx, int(LocusArchetype.UNKNOWN)).astype(np.int32)

    def classify(
        self,
        X: Locus | np.ndarray | Sequence[int | GeneRole],
    ) -> tuple[LocusArchetype, float, float]:
        """Classify a single `Locus`, role sequence, or `(L, 30)` probability matrix into `(archetype, completeness, score)`."""
        scores, comps, incomps = self.score_components([X])
        s = scores[0]
        c = comps[0]
        inc = incomps[0]

        eff = c * (1.0 - inc) + 1e-2 * np.maximum(s, 0.0)
        eff[int(LocusArchetype.UNKNOWN)] = -1.0
        best_idx = int(np.argmax(eff))
        best_comp = float(c[best_idx])
        best_incomp = float(inc[best_idx])
        best_score = float(s[best_idx])

        if best_comp < self.min_completeness or best_incomp > self.max_incompatibility or best_score <= 0.0:
            return LocusArchetype.UNKNOWN, best_comp, best_score
        return LocusArchetype(best_idx), best_comp, best_score

    def fit(self, X: ArchetypeInput, y: Sequence[int | LocusArchetype] | np.ndarray) -> Self:
        """Calibrate `w_comp_`, `w_incomp_`, `w_llr_`, and `unknown_logit_` via L-BFGS-B cross-entropy minimization."""
        scores, comps, incomps = self.score_components(X)
        y_arr = np.asarray([int(lbl) for lbl in y], dtype=np.int32)
        n = len(y_arr)
        if n == 0:
            self.w_comp_ = self.w_comp
            self.w_incomp_ = self.w_incomp
            self.w_llr_ = self.w_llr
            self.unknown_logit_ = self.unknown_logit
            return self

        pos_scores = np.maximum(scores, 0.0)
        unk_idx = int(LocusArchetype.UNKNOWN)

        def _loss(params: np.ndarray) -> float:
            wc, wi, wl, ul = params
            logits = wc * comps - wi * incomps + wl * pos_scores
            logits[:, unk_idx] = ul
            logits = logits - np.max(logits, axis=1, keepdims=True)
            log_probs = logits - np.log(np.sum(np.exp(logits), axis=1, keepdims=True))
            nll = -float(np.mean(log_probs[np.arange(n), y_arr]))
            reg = 1e-3 * float(
                (wc - self.w_comp) ** 2
                + (wi - self.w_incomp) ** 2
                + (wl - self.w_llr) ** 2
                + (ul - self.unknown_logit) ** 2
            )
            return nll + reg

        init = np.array([self.w_comp, self.w_incomp, self.w_llr, self.unknown_logit], dtype=np.float64)
        bounds = [(1.0, 50.0), (1.0, 50.0), (0.01, 5.0), (0.0, 20.0)]
        res = minimize(_loss, init, method="L-BFGS-B", bounds=bounds)
        self.w_comp_ = float(res.x[0])
        self.w_incomp_ = float(res.x[1])
        self.w_llr_ = float(res.x[2])
        self.unknown_logit_ = float(res.x[3])
        return self

    def score(self, X: ArchetypeInput, y: Sequence[int | LocusArchetype] | np.ndarray) -> float:
        """Compute classification accuracy on labeled segments `(X, y)`."""
        y_arr = np.asarray([int(lbl) for lbl in y], dtype=np.int32)
        if len(y_arr) == 0:
            return 1.0
        preds = self.predict(X)
        return float(np.mean(preds == y_arr))

classes_ property

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

1D array of archetype class labels (0..10).

active_weights property

active_weights: tuple[float, float, float, float]

Return (w_comp, w_incomp, w_llr, unknown_logit), preferring fitted parameters if present.

check_modules

check_modules(mask: int) -> ndarray[tuple[int], dtype[int32]]

Count how many required functional modules are satisfied for each archetype ((11,)).

Source code in src/serogen/loci/classifier.py
def check_modules(self, mask: int) -> np.ndarray[tuple[int], np.dtype[np.int32]]:
    """Count how many required functional modules are satisfied for each archetype (`(11,)`)."""
    m = np.uint32(mask)
    satisfied = (self.tensors.module_masks & m) != 0
    satisfied &= self.tensors.active_module_mask
    return np.sum(satisfied, axis=1, dtype=np.int32)

check_forbidden

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

Return a boolean array (11,) indicating whether any forbidden role is present.

Source code in src/serogen/loci/classifier.py
def check_forbidden(self, mask: int) -> np.ndarray[tuple[int], np.dtype[np.bool_]]:
    """Return a boolean array `(11,)` indicating whether any forbidden role is present."""
    return (self.tensors.forbidden_masks & np.uint32(mask)) != 0

is_complete

is_complete(mask: int, min_completeness: float | None = None) -> ndarray[tuple[int], dtype[bool_]]

Evaluate whether a 32-bit role presence mask satisfies each archetype.

Source code in src/serogen/loci/classifier.py
def is_complete(
    self, mask: int, min_completeness: float | None = None
) -> np.ndarray[tuple[int], np.dtype[np.bool_]]:
    """Evaluate whether a 32-bit role presence mask satisfies each archetype."""
    thresh = self.min_completeness if min_completeness is None else min_completeness
    mod_counts = self.check_modules(mask)
    no_forbidden = ~self.check_forbidden(mask)
    has_modules = self.tensors.num_modules > 0
    denom = np.maximum(self.tensors.num_modules, 1)
    completeness = np.where(has_modules, mod_counts / denom, 0.0)
    return has_modules & no_forbidden & (completeness >= thresh)

classify_mask

classify_mask(mask: int, min_completeness: float | None = None) -> tuple[LocusArchetype, float]

Classify a single 32-bit role presence mask into its best-matching LocusArchetype.

Source code in src/serogen/loci/classifier.py
def classify_mask(
    self,
    mask: int,
    min_completeness: float | None = None,
) -> tuple[LocusArchetype, float]:
    """Classify a single 32-bit role presence mask into its best-matching `LocusArchetype`."""
    thresh = self.min_completeness if min_completeness is None else min_completeness
    mod_counts = self.check_modules(mask)
    no_forbidden = ~self.check_forbidden(mask)
    has_modules = self.tensors.num_modules > 0
    denom = np.maximum(self.tensors.num_modules, 1)
    completeness = np.where(has_modules & no_forbidden, mod_counts / denom, 0.0)

    cargo_overlap = np.array(
        [(int(c_mask) & int(mask)).bit_count() for c_mask in self.tensors.all_relevant_masks],
        dtype=np.float64,
    )
    ranking = completeness + 1e-3 * mod_counts + 1e-5 * cargo_overlap
    ranking[int(LocusArchetype.UNKNOWN)] = -1.0

    best_idx = int(np.argmax(ranking))
    best_comp = float(completeness[best_idx])
    if best_comp < thresh or best_comp == 0.0:
        return LocusArchetype.UNKNOWN, best_comp
    return LocusArchetype(best_idx), best_comp

is_boundary_relevant

is_boundary_relevant(archetype: LocusArchetype | int, role: GeneRole | int) -> bool

Return True if role is a valid boundary/anchor/cargo gene for archetype (excluding MGEs/UNKNOWN).

Source code in src/serogen/loci/classifier.py
def is_boundary_relevant(self, archetype: LocusArchetype | int, role: GeneRole | int) -> bool:
    """Return True if `role` is a valid boundary/anchor/cargo gene for `archetype` (excluding MGEs/UNKNOWN)."""
    r_idx = int(role)
    if r_idx in (int(GeneRole.TRANSPOSASE), int(GeneRole.INTEGRASE), int(GeneRole.UNKNOWN)):
        return False
    a_idx = int(archetype)
    return (int(self.tensors.all_relevant_masks[a_idx]) & (1 << r_idx)) != 0

to_probability_matrix staticmethod

to_probability_matrix(roles_or_probs: ndarray | Sequence[int | GeneRole], confidence: float = 0.98) -> ndarray[tuple[int, int], dtype[float64]]

Convert 1D discrete role indices or a 2D probability matrix into a clean (L, 30) float64 matrix.

Source code in src/serogen/loci/classifier.py
@staticmethod
def to_probability_matrix(
    roles_or_probs: np.ndarray | Sequence[int | GeneRole],
    confidence: float = 0.98,
) -> np.ndarray[tuple[int, int], np.dtype[np.float64]]:
    """Convert 1D discrete role indices or a 2D probability matrix into a clean `(L, 30)` float64 matrix."""
    arr = np.asarray(roles_or_probs)
    n_roles = len(GeneRole)
    if arr.ndim == 2:
        if arr.shape[1] != n_roles:
            raise ValueError(f"Expected 2D probability matrix with {n_roles} columns, got shape {arr.shape}.")
        probs = np.clip(arr.astype(np.float64, copy=False), 1e-12, 1.0)
        row_sums = probs.sum(axis=1, keepdims=True)
        return np.where(row_sums > 0.0, probs / row_sums, 1.0 / n_roles)

    if arr.ndim != 1:
        raise ValueError(f"Expected 1D role index array or 2D probability matrix, got {arr.ndim}D.")

    length = len(arr)
    if length == 0:
        return np.empty((0, n_roles), dtype=np.float64)

    smooth = (1.0 - confidence) / (n_roles - 1)
    probs = np.full((length, n_roles), smooth, dtype=np.float64)
    valid_mask = (arr >= 0) & (arr < n_roles)
    rows = np.nonzero(valid_mask)[0]
    cols = arr[valid_mask].astype(np.intp)
    probs[rows, cols] = confidence
    if not np.all(valid_mask):
        probs[~valid_mask, int(GeneRole.UNKNOWN)] = confidence
    return probs

precompute_prefixes

precompute_prefixes(probs: ndarray[tuple[int, int], dtype[float64]]) -> tuple[ndarray[tuple[int, int, int], dtype[float64]], ndarray[tuple[int, int], dtype[float64]], ndarray[tuple[int, int], dtype[float64]]]

Precompute \(O(L)\) prefix sums of log-complement and log-odds probabilities across a contig.

Source code in src/serogen/loci/classifier.py
def precompute_prefixes(
    self,
    probs: np.ndarray[tuple[int, int], np.dtype[np.float64]],
) -> tuple[
    np.ndarray[tuple[int, int, int], np.dtype[np.float64]],
    np.ndarray[tuple[int, int], np.dtype[np.float64]],
    np.ndarray[tuple[int, int], np.dtype[np.float64]],
]:
    """Precompute $O(L)$ prefix sums of log-complement and log-odds probabilities across a contig."""
    t = self.tensors
    length = len(probs)
    n_arch, max_mods, _ = t.module_indicators.shape

    mod_probs = np.where(probs >= 0.12, probs, 0.0)
    mod_hit = np.clip(np.einsum("lr,amr->lam", mod_probs, t.module_indicators), 0.0, 1.0)
    log_not_mod = np.log(np.clip(1.0 - mod_hit, 1e-12, 1.0))

    forb_probs = np.where(probs >= 0.05, probs, 0.0)
    forb_hit = np.clip(forb_probs @ t.forbidden_indicators.T, 0.0, 1.0)
    log_not_forb = np.log(np.clip(1.0 - forb_hit, 1e-12, 1.0))

    null_mass = probs @ t.null_priors
    rel_mass = probs @ t.relevant_indicators.T
    arch_emission = 0.85 * rel_mass + 0.15 * null_mass[:, None]
    forb_penalty = np.log(np.clip(1.0 - 0.95 * forb_hit, 1e-6, 1.0))
    mge_mass = np.clip(
        probs[:, int(GeneRole.TRANSPOSASE)] + probs[:, int(GeneRole.INTEGRASE)],
        0.0,
        1.0,
    )
    step_llr = (np.log(np.clip(arch_emission, 1e-12, None)) - np.log(np.clip(null_mass[:, None], 1e-12, None))) * (
        1.0 - mge_mass[:, None]
    )
    step_llr += forb_penalty
    step_llr[:, int(LocusArchetype.UNKNOWN)] = 0.0

    pref_mod = np.zeros((length + 1, n_arch, max_mods), dtype=np.float64)
    pref_forb = np.zeros((length + 1, n_arch), dtype=np.float64)
    pref_llr = np.zeros((length + 1, n_arch), dtype=np.float64)

    if length > 0:
        np.cumsum(log_not_mod, axis=0, out=pref_mod[1:])
        np.cumsum(log_not_forb, axis=0, out=pref_forb[1:])
        np.cumsum(step_llr, axis=0, out=pref_llr[1:])

    return pref_mod, pref_forb, pref_llr

score_segments

score_segments(starts: ndarray[tuple[int], dtype[intp]], ends: ndarray[tuple[int], dtype[intp]], *, probs: ndarray[tuple[int, int], dtype[float64]] | None = None, prefixes: tuple[ndarray, ndarray, ndarray] | None = None) -> tuple[ndarray[tuple[int, int], dtype[float64]], ndarray[tuple[int, int], dtype[float64]], ndarray[tuple[int, int], dtype[float64]]]

Evaluate completeness, incompatibility, and LLR scores for \(K\) candidate segments in \(O(K \cdot |\mathcal{A}|)\).

Source code in src/serogen/loci/classifier.py
def score_segments(
    self,
    starts: np.ndarray[tuple[int], np.dtype[np.intp]],
    ends: np.ndarray[tuple[int], np.dtype[np.intp]],
    *,
    probs: np.ndarray[tuple[int, int], np.dtype[np.float64]] | None = None,
    prefixes: tuple[np.ndarray, np.ndarray, np.ndarray] | None = None,
) -> tuple[
    np.ndarray[tuple[int, int], np.dtype[np.float64]],
    np.ndarray[tuple[int, int], np.dtype[np.float64]],
    np.ndarray[tuple[int, int], np.dtype[np.float64]],
]:
    """Evaluate completeness, incompatibility, and LLR scores for $K$ candidate segments in $O(K \\cdot |\\mathcal{A}|)$."""
    if prefixes is None:
        if probs is None:
            raise ValueError("Either `probs` or `prefixes` must be provided to `score_segments`.")
        prefixes = self.precompute_prefixes(probs)

    t = self.tensors
    pref_mod, pref_forb, pref_llr = prefixes
    seg_log_not_mod = pref_mod[ends] - pref_mod[starts]
    raw_mod_probs = 1.0 - np.exp(seg_log_not_mod)
    mod_probs = np.minimum(1.0, raw_mod_probs / 0.95) * t.active_module_mask[None, :, :]
    denom = np.maximum(t.num_modules, 1)[None, :]
    has_mods = (t.num_modules > 0)[None, :]
    completeness = np.where(has_mods, mod_probs.sum(axis=2) / denom, 0.0)

    seg_log_not_forb = pref_forb[ends] - pref_forb[starts]
    incompatibility = 1.0 - np.exp(seg_log_not_forb)

    llr_scores = pref_llr[ends] - pref_llr[starts]
    return completeness, incompatibility, llr_scores

score_components

score_components(X: ArchetypeInput) -> tuple[ndarray[tuple[int, int], dtype[float64]], ndarray[tuple[int, int], dtype[float64]], ndarray[tuple[int, int], dtype[float64]]]

Compute (llr_scores, completeness, incompatibility) matrices of shape (N, 11) across a batch.

Source code in src/serogen/loci/classifier.py
def score_components(
    self,
    X: ArchetypeInput,
) -> tuple[
    np.ndarray[tuple[int, int], np.dtype[np.float64]],
    np.ndarray[tuple[int, int], np.dtype[np.float64]],
    np.ndarray[tuple[int, int], np.dtype[np.float64]],
]:
    """Compute `(llr_scores, completeness, incompatibility)` matrices of shape `(N, 11)` across a batch."""
    items: list[Any]
    if isinstance(X, Loci):
        items = [X[i] for i in range(len(X))]
    elif isinstance(X, Locus):
        items = [X]
    elif isinstance(X, np.ndarray):
        if X.ndim in (1, 2):
            items = [X]
        else:
            items = [X[i] for i in range(len(X))]
    else:
        seq: list[Any] = list(X)
        if len(seq) == 0:
            e = np.empty((0, len(LocusArchetype)), dtype=np.float64)
            return e, e, e
        if isinstance(seq[0], (int, np.integer, GeneRole)):
            items = [seq]
        else:
            items = seq

    n = len(items)
    n_arch = len(LocusArchetype)
    scores = np.zeros((n, n_arch), dtype=np.float64)
    comps = np.zeros((n, n_arch), dtype=np.float64)
    incomps = np.zeros((n, n_arch), dtype=np.float64)

    for i, it in enumerate(items):
        s, c, inc = self._score_single(it)
        scores[i] = s
        comps[i] = c
        incomps[i] = inc
    return scores, comps, incomps

decision_function

decision_function(X: ArchetypeInput) -> ndarray[tuple[int, int], dtype[float64]]

Compute unnormalized archetype logits of shape (N, 11) for input segments.

Source code in src/serogen/loci/classifier.py
def decision_function(self, X: ArchetypeInput) -> np.ndarray[tuple[int, int], np.dtype[np.float64]]:
    """Compute unnormalized archetype logits of shape `(N, 11)` for input segments."""
    scores, comps, incomps = self.score_components(X)
    w_c, w_i, w_l, u_l = self.active_weights
    logits = w_c * comps - w_i * incomps + w_l * np.maximum(scores, 0.0)
    logits[:, int(LocusArchetype.UNKNOWN)] = u_l
    return logits

predict_proba

predict_proba(X: ArchetypeInput) -> ndarray[tuple[int, int], dtype[float64]]

Compute calibrated softmax posterior probabilities (N, 11) across archetypes.

Source code in src/serogen/loci/classifier.py
def predict_proba(self, X: ArchetypeInput) -> np.ndarray[tuple[int, int], np.dtype[np.float64]]:
    """Compute calibrated softmax posterior probabilities `(N, 11)` across archetypes."""
    logits = self.decision_function(X)
    if len(logits) == 0:
        return logits
    shifted = logits - np.max(logits, axis=1, keepdims=True)
    exp_l = np.exp(shifted)
    return exp_l / np.sum(exp_l, axis=1, keepdims=True)

predict

predict(X: ArchetypeInput) -> ndarray[tuple[int], dtype[int32]]

Predict the integer LocusArchetype index (0..10) for each input locus or segment.

Source code in src/serogen/loci/classifier.py
def predict(self, X: ArchetypeInput) -> np.ndarray[tuple[int], np.dtype[np.int32]]:
    """Predict the integer `LocusArchetype` index (`0..10`) for each input locus or segment."""
    scores, comps, incomps = self.score_components(X)
    n = len(scores)
    if n == 0:
        return np.empty(0, dtype=np.int32)

    w_c, w_i, w_l, _ = self.active_weights
    eff = comps * (1.0 - incomps) + 1e-2 * (w_l * np.maximum(scores, 0.0) / max(w_c, 1.0))
    eff[:, int(LocusArchetype.UNKNOWN)] = -1.0

    valid = (comps >= self.min_completeness) & (incomps <= self.max_incompatibility)
    valid[:, int(LocusArchetype.UNKNOWN)] = False

    masked_eff = np.where(valid, eff, -1.0)
    best_idx = np.argmax(masked_eff, axis=1).astype(np.int32)
    has_valid = np.any(valid, axis=1)
    return np.where(has_valid, best_idx, int(LocusArchetype.UNKNOWN)).astype(np.int32)

classify

classify(X: Locus | ndarray | Sequence[int | GeneRole]) -> tuple[LocusArchetype, float, float]

Classify a single Locus, role sequence, or (L, 30) probability matrix into (archetype, completeness, score).

Source code in src/serogen/loci/classifier.py
def classify(
    self,
    X: Locus | np.ndarray | Sequence[int | GeneRole],
) -> tuple[LocusArchetype, float, float]:
    """Classify a single `Locus`, role sequence, or `(L, 30)` probability matrix into `(archetype, completeness, score)`."""
    scores, comps, incomps = self.score_components([X])
    s = scores[0]
    c = comps[0]
    inc = incomps[0]

    eff = c * (1.0 - inc) + 1e-2 * np.maximum(s, 0.0)
    eff[int(LocusArchetype.UNKNOWN)] = -1.0
    best_idx = int(np.argmax(eff))
    best_comp = float(c[best_idx])
    best_incomp = float(inc[best_idx])
    best_score = float(s[best_idx])

    if best_comp < self.min_completeness or best_incomp > self.max_incompatibility or best_score <= 0.0:
        return LocusArchetype.UNKNOWN, best_comp, best_score
    return LocusArchetype(best_idx), best_comp, best_score

fit

fit(X: ArchetypeInput, y: Sequence[int | LocusArchetype] | ndarray) -> Self

Calibrate w_comp_, w_incomp_, w_llr_, and unknown_logit_ via L-BFGS-B cross-entropy minimization.

Source code in src/serogen/loci/classifier.py
def fit(self, X: ArchetypeInput, y: Sequence[int | LocusArchetype] | np.ndarray) -> Self:
    """Calibrate `w_comp_`, `w_incomp_`, `w_llr_`, and `unknown_logit_` via L-BFGS-B cross-entropy minimization."""
    scores, comps, incomps = self.score_components(X)
    y_arr = np.asarray([int(lbl) for lbl in y], dtype=np.int32)
    n = len(y_arr)
    if n == 0:
        self.w_comp_ = self.w_comp
        self.w_incomp_ = self.w_incomp
        self.w_llr_ = self.w_llr
        self.unknown_logit_ = self.unknown_logit
        return self

    pos_scores = np.maximum(scores, 0.0)
    unk_idx = int(LocusArchetype.UNKNOWN)

    def _loss(params: np.ndarray) -> float:
        wc, wi, wl, ul = params
        logits = wc * comps - wi * incomps + wl * pos_scores
        logits[:, unk_idx] = ul
        logits = logits - np.max(logits, axis=1, keepdims=True)
        log_probs = logits - np.log(np.sum(np.exp(logits), axis=1, keepdims=True))
        nll = -float(np.mean(log_probs[np.arange(n), y_arr]))
        reg = 1e-3 * float(
            (wc - self.w_comp) ** 2
            + (wi - self.w_incomp) ** 2
            + (wl - self.w_llr) ** 2
            + (ul - self.unknown_logit) ** 2
        )
        return nll + reg

    init = np.array([self.w_comp, self.w_incomp, self.w_llr, self.unknown_logit], dtype=np.float64)
    bounds = [(1.0, 50.0), (1.0, 50.0), (0.01, 5.0), (0.0, 20.0)]
    res = minimize(_loss, init, method="L-BFGS-B", bounds=bounds)
    self.w_comp_ = float(res.x[0])
    self.w_incomp_ = float(res.x[1])
    self.w_llr_ = float(res.x[2])
    self.unknown_logit_ = float(res.x[3])
    return self

score

score(X: ArchetypeInput, y: Sequence[int | LocusArchetype] | ndarray) -> float

Compute classification accuracy on labeled segments (X, y).

Source code in src/serogen/loci/classifier.py
def score(self, X: ArchetypeInput, y: Sequence[int | LocusArchetype] | np.ndarray) -> float:
    """Compute classification accuracy on labeled segments `(X, y)`."""
    y_arr = np.asarray([int(lbl) for lbl in y], dtype=np.int32)
    if len(y_arr) == 0:
        return 1.0
    preds = self.predict(X)
    return float(np.mean(preds == y_arr))

Reference Serotype Matching (serogen.loci.matcher)

matcher

Vectorized locus comparison, structural difference bitflags, and database matching.

Classes:

Name Description
MatchType

Composable bitflag enumeration describing how two biosynthetic loci differ.

Match

Scalar view of a pairwise comparison between a query locus and a target locus.

Matches

Vectorized Structure-of-Arrays (SoA) batch container of locus comparison results.

LocusNodeCostStrategy

Semantic node compatibility cost model for aligning two Locus graphs via GraphAligner.

LocusMatcher

Vectorized 1-to-many and batch-to-many biosynthetic locus matcher.

MatchType

Bases: IntFlag

Composable bitflag enumeration describing how two biosynthetic loci differ.

MatchType.EXACT (0) indicates that two loci share identical non-MGE gene content, gene order, relative transcriptional orientations, dominant strand, frameshift state, and mobile genetic element (MGE) state. Any combination of structural or allelic differences is represented as the bitwise OR of the corresponding variant flags.

Attributes:

Name Type Description
is_exact bool

True if no variant flags are set (self == MatchType.EXACT).

is_novel bool

True if the comparison involves gene gain/loss, synteny rearrangement, or archetype mismatch.

flag_count int

Number of active difference bitflags set in this MatchType.

Source code in src/serogen/loci/matcher.py
class MatchType(IntFlag):
    """Composable bitflag enumeration describing how two biosynthetic loci differ.

    `MatchType.EXACT` (`0`) indicates that two loci share identical non-MGE gene content,
    gene order, relative transcriptional orientations, dominant strand, frameshift state,
    and mobile genetic element (MGE) state. Any combination of structural or allelic
    differences is represented as the bitwise OR of the corresponding variant flags.
    """

    EXACT = 0
    STRAND_VARIANT = 1 << 0
    MGE_VARIANT = 1 << 1
    FRAMESHIFT_VARIANT = 1 << 2
    SYNTENY_VARIANT = 1 << 3
    GENE_VARIANT = 1 << 4
    ROLE_VARIANT = 1 << 5
    ARCHETYPE_MISMATCH = 1 << 6
    FRAGMENTED_VARIANT = 1 << 7

    @property
    def is_exact(self) -> bool:
        """True if no variant flags are set (`self == MatchType.EXACT`)."""
        return self == MatchType.EXACT

    @property
    def is_novel(self) -> bool:
        """True if the comparison involves gene gain/loss, synteny rearrangement, or archetype mismatch."""
        novel_mask = MatchType.GENE_VARIANT | MatchType.SYNTENY_VARIANT | MatchType.ARCHETYPE_MISMATCH
        return bool(self & novel_mask)

    @property
    def flag_count(self) -> int:
        """Number of active difference bitflags set in this `MatchType`."""
        return int(self).bit_count()

is_exact property

is_exact: bool

True if no variant flags are set (self == MatchType.EXACT).

is_novel property

is_novel: bool

True if the comparison involves gene gain/loss, synteny rearrangement, or archetype mismatch.

flag_count property

flag_count: int

Number of active difference bitflags set in this MatchType.

Match dataclass

Bases: ScalarView

Scalar view of a pairwise comparison between a query locus and a target locus.

Attributes:

Name Type Description
is_exact bool

True if the query and target loci are an exact match (MatchType.EXACT).

is_novel bool

True if the query differs from this target by gene content, synteny, or archetype.

Source code in src/serogen/loci/matcher.py
@dataclass(slots=True, frozen=True)
class Match(ScalarView):
    """Scalar view of a pairwise comparison between a query locus and a target locus."""

    query_index: int
    target_index: int
    match_type: MatchType
    score: float
    jaccard: float
    query_coverage: float
    target_coverage: float
    shared_genes: int
    missing_genes: int
    extra_genes: int
    mean_aa_identity: float = 1.0
    target_name: str = ""

    @property
    def is_exact(self) -> bool:
        """True if the query and target loci are an exact match (`MatchType.EXACT`)."""
        return self.match_type.is_exact

    @property
    def is_novel(self) -> bool:
        """True if the query differs from this target by gene content, synteny, or archetype."""
        return self.match_type.is_novel

is_exact property

is_exact: bool

True if the query and target loci are an exact match (MatchType.EXACT).

is_novel property

is_novel: bool

True if the query differs from this target by gene content, synteny, or archetype.

Matches dataclass

Bases: BatchedContainer[Match, 'Matches']

Vectorized Structure-of-Arrays (SoA) batch container of locus comparison results.

Methods:

Name Description
empty

Create an empty 0-length Matches container.

concat

Concatenate multiple Matches containers into a single batch.

sort

Sort matches by score, breaking ties by fewer MatchType bitflags, higher AA identity, and lower target_index.

best

Return the highest-scoring Match across the container (or None if empty).

best_per_query

Return a Matches container holding the single best target match for each unique query_index.

filter

Filter matches by minimum similarity metrics and/or permitted MatchType flags.

Attributes:

Name Type Description
is_novel ndarray[tuple[int], dtype[bool_]]

1D boolean array ((N,)) indicating whether each match involves gene, synteny, or archetype divergence.

Source code in src/serogen/loci/matcher.py
@dataclass(slots=True, frozen=True)
class Matches(BatchedContainer[Match, "Matches"]):
    """Vectorized Structure-of-Arrays (SoA) batch container of locus comparison results."""

    query_indices: np.ndarray[tuple[int], np.dtype[np.int32]]
    target_indices: np.ndarray[tuple[int], np.dtype[np.int32]]
    match_types: np.ndarray[tuple[int], np.dtype[np.uint32]]
    scores: np.ndarray[tuple[int], np.dtype[np.float32]]
    jaccards: np.ndarray[tuple[int], np.dtype[np.float32]]
    query_coverages: np.ndarray[tuple[int], np.dtype[np.float32]]
    target_coverages: np.ndarray[tuple[int], np.dtype[np.float32]]
    shared_genes: np.ndarray[tuple[int], np.dtype[np.int32]]
    missing_genes: np.ndarray[tuple[int], np.dtype[np.int32]]
    extra_genes: np.ndarray[tuple[int], np.dtype[np.int32]]
    mean_aa_identities: np.ndarray[tuple[int], np.dtype[np.float32]] = field(
        default_factory=lambda: np.empty(0, dtype=np.float32)
    )
    target_names: np.ndarray[tuple[int], np.dtype[np.str_]] = field(default_factory=lambda: np.empty(0, dtype="U1"))

    def __post_init__(self) -> None:
        n = len(self.scores)
        if len(self.mean_aa_identities) == 0 and n > 0:
            object.__setattr__(self, "mean_aa_identities", np.ones(n, dtype=np.float32))
        if len(self.target_names) == 0 and n > 0:
            object.__setattr__(self, "target_names", np.full(n, "", dtype="U1"))
        elif self.target_names.dtype.kind == "S":
            object.__setattr__(
                self, "target_names", np.char.decode(cast(Any, self.target_names), "utf-8", errors="replace")
            )
        elif self.target_names.dtype.kind != "U":
            object.__setattr__(self, "target_names", np.asarray(self.target_names, dtype=str))

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

    @property
    def is_novel(self) -> np.ndarray[tuple[int], np.dtype[np.bool_]]:
        """1D boolean array (`(N,)`) indicating whether each match involves gene, synteny, or archetype divergence."""
        if len(self) == 0:
            return np.empty(0, dtype=np.bool_)
        novel_mask = np.uint32(int(MatchType.GENE_VARIANT | MatchType.SYNTENY_VARIANT | MatchType.ARCHETYPE_MISMATCH))
        return (self.match_types & novel_mask) != 0

    @classmethod
    def empty(cls) -> Self:
        """Create an empty 0-length `Matches` container."""
        return cls(
            query_indices=np.empty(0, dtype=np.int32),
            target_indices=np.empty(0, dtype=np.int32),
            match_types=np.empty(0, dtype=np.uint32),
            scores=np.empty(0, dtype=np.float32),
            jaccards=np.empty(0, dtype=np.float32),
            query_coverages=np.empty(0, dtype=np.float32),
            target_coverages=np.empty(0, dtype=np.float32),
            shared_genes=np.empty(0, dtype=np.int32),
            missing_genes=np.empty(0, dtype=np.int32),
            extra_genes=np.empty(0, dtype=np.int32),
            mean_aa_identities=np.empty(0, dtype=np.float32),
            target_names=np.empty(0, dtype="U1"),
        )

    @classmethod
    def concat(cls, batches: Iterable[Self]) -> Self:
        """Concatenate multiple `Matches` containers into a single batch."""
        non_empty = [b for b in batches if len(b) > 0]
        if not non_empty:
            return cls.empty()
        if len(non_empty) == 1:
            return non_empty[0]
        return cls(
            query_indices=np.concatenate([b.query_indices for b in non_empty]),
            target_indices=np.concatenate([b.target_indices for b in non_empty]),
            match_types=np.concatenate([b.match_types for b in non_empty]),
            scores=np.concatenate([b.scores for b in non_empty]),
            jaccards=np.concatenate([b.jaccards for b in non_empty]),
            query_coverages=np.concatenate([b.query_coverages for b in non_empty]),
            target_coverages=np.concatenate([b.target_coverages for b in non_empty]),
            shared_genes=np.concatenate([b.shared_genes for b in non_empty]),
            missing_genes=np.concatenate([b.missing_genes for b in non_empty]),
            extra_genes=np.concatenate([b.extra_genes for b in non_empty]),
            mean_aa_identities=np.concatenate([b.mean_aa_identities for b in non_empty]),
            target_names=np.concatenate([b.target_names for b in non_empty]),
        )

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

    @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]
    ) -> Match | Self: ...

    def __getitem__(
        self, item: int | np.integer[Any] | slice | np.ndarray[tuple[int], Any] | list[Any]
    ) -> Match | Self:
        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"Matches index {item} out of bounds [0, {len(self) - 1}]")
            return Match(
                query_index=int(self.query_indices[idx]),
                target_index=int(self.target_indices[idx]),
                match_type=MatchType(int(self.match_types[idx])),
                score=float(self.scores[idx]),
                jaccard=float(self.jaccards[idx]),
                query_coverage=float(self.query_coverages[idx]),
                target_coverage=float(self.target_coverages[idx]),
                shared_genes=int(self.shared_genes[idx]),
                missing_genes=int(self.missing_genes[idx]),
                extra_genes=int(self.extra_genes[idx]),
                mean_aa_identity=float(self.mean_aa_identities[idx]) if len(self.mean_aa_identities) > idx else 1.0,
                target_name=str(self.target_names[idx]) if len(self.target_names) > idx else "",
            )

        indices = self._normalize_indices(item)
        if len(indices) == 0:
            return self.empty()
        return type(self)(
            query_indices=self.query_indices[indices],
            target_indices=self.target_indices[indices],
            match_types=self.match_types[indices],
            scores=self.scores[indices],
            jaccards=self.jaccards[indices],
            query_coverages=self.query_coverages[indices],
            target_coverages=self.target_coverages[indices],
            shared_genes=self.shared_genes[indices],
            missing_genes=self.missing_genes[indices],
            extra_genes=self.extra_genes[indices],
            mean_aa_identities=self.mean_aa_identities[indices]
            if len(self.mean_aa_identities) == len(self.scores)
            else np.ones(len(indices), dtype=np.float32),
            target_names=self.target_names[indices]
            if len(self.target_names) == len(self.scores)
            else np.full(len(indices), "", dtype="U1"),
        )

    def _popcounts(self) -> np.ndarray[tuple[int], np.dtype[np.int32]]:
        """Vectorized population count (number of set bits) for `self.match_types`."""
        x = self.match_types.copy()
        x = x - ((x >> np.uint32(1)) & np.uint32(0x55555555))
        x = (x & np.uint32(0x33333333)) + ((x >> np.uint32(2)) & np.uint32(0x33333333))
        x = (x + (x >> np.uint32(4))) & np.uint32(0x0F0F0F0F)
        return ((x * np.uint32(0x01010101)) >> np.uint32(24)).astype(np.int32)

    def sort(self, descending: bool = True) -> Self:
        """Sort matches by score, breaking ties by fewer `MatchType` bitflags, higher AA identity, and lower `target_index`."""
        if len(self) <= 1:
            return self
        pop = self._popcounts()
        if descending:
            order = np.lexsort((self.target_indices, -self.mean_aa_identities, pop, -self.scores))
        else:
            order = np.lexsort((-self.target_indices, self.mean_aa_identities, -pop, self.scores))
        return self[order]

    def best(self) -> Match | None:
        """Return the highest-scoring `Match` across the container (or `None` if empty)."""
        if len(self) == 0:
            return None
        pop = self._popcounts()
        best_idx = int(np.lexsort((self.target_indices, -self.mean_aa_identities, pop, -self.scores))[0])
        return self[best_idx]

    def best_per_query(self) -> Self:
        """Return a `Matches` container holding the single best target match for each unique `query_index`."""
        if len(self) == 0:
            return self.empty()
        pop = self._popcounts()
        order = np.lexsort((self.target_indices, -self.mean_aa_identities, pop, -self.scores, self.query_indices))
        sorted_q = self.query_indices[order]
        first_mask = np.empty(len(order), dtype=np.bool_)
        first_mask[0] = True
        if len(order) > 1:
            first_mask[1:] = sorted_q[1:] != sorted_q[:-1]
        return self[order[first_mask]]

    def filter(
        self,
        *,
        min_score: float = 0.0,
        min_jaccard: float = 0.0,
        min_coverage: float = 0.0,
        allowed_flags: MatchType | None = None,
    ) -> Self:
        """Filter matches by minimum similarity metrics and/or permitted `MatchType` flags."""
        if len(self) == 0:
            return self
        mask = (
            (self.scores >= min_score)
            & (self.jaccards >= min_jaccard)
            & (self.query_coverages >= min_coverage)
            & (self.target_coverages >= min_coverage)
        )
        if allowed_flags is not None:
            disallowed = ~np.uint32(int(allowed_flags))
            mask &= (self.match_types & disallowed) == 0
        return self[mask]

is_novel property

is_novel: ndarray[tuple[int], dtype[bool_]]

1D boolean array ((N,)) indicating whether each match involves gene, synteny, or archetype divergence.

empty classmethod

empty() -> Self

Create an empty 0-length Matches container.

Source code in src/serogen/loci/matcher.py
@classmethod
def empty(cls) -> Self:
    """Create an empty 0-length `Matches` container."""
    return cls(
        query_indices=np.empty(0, dtype=np.int32),
        target_indices=np.empty(0, dtype=np.int32),
        match_types=np.empty(0, dtype=np.uint32),
        scores=np.empty(0, dtype=np.float32),
        jaccards=np.empty(0, dtype=np.float32),
        query_coverages=np.empty(0, dtype=np.float32),
        target_coverages=np.empty(0, dtype=np.float32),
        shared_genes=np.empty(0, dtype=np.int32),
        missing_genes=np.empty(0, dtype=np.int32),
        extra_genes=np.empty(0, dtype=np.int32),
        mean_aa_identities=np.empty(0, dtype=np.float32),
        target_names=np.empty(0, dtype="U1"),
    )

concat classmethod

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

Concatenate multiple Matches containers into a single batch.

Source code in src/serogen/loci/matcher.py
@classmethod
def concat(cls, batches: Iterable[Self]) -> Self:
    """Concatenate multiple `Matches` containers into a single batch."""
    non_empty = [b for b in batches if len(b) > 0]
    if not non_empty:
        return cls.empty()
    if len(non_empty) == 1:
        return non_empty[0]
    return cls(
        query_indices=np.concatenate([b.query_indices for b in non_empty]),
        target_indices=np.concatenate([b.target_indices for b in non_empty]),
        match_types=np.concatenate([b.match_types for b in non_empty]),
        scores=np.concatenate([b.scores for b in non_empty]),
        jaccards=np.concatenate([b.jaccards for b in non_empty]),
        query_coverages=np.concatenate([b.query_coverages for b in non_empty]),
        target_coverages=np.concatenate([b.target_coverages for b in non_empty]),
        shared_genes=np.concatenate([b.shared_genes for b in non_empty]),
        missing_genes=np.concatenate([b.missing_genes for b in non_empty]),
        extra_genes=np.concatenate([b.extra_genes for b in non_empty]),
        mean_aa_identities=np.concatenate([b.mean_aa_identities for b in non_empty]),
        target_names=np.concatenate([b.target_names for b in non_empty]),
    )

sort

sort(descending: bool = True) -> Self

Sort matches by score, breaking ties by fewer MatchType bitflags, higher AA identity, and lower target_index.

Source code in src/serogen/loci/matcher.py
def sort(self, descending: bool = True) -> Self:
    """Sort matches by score, breaking ties by fewer `MatchType` bitflags, higher AA identity, and lower `target_index`."""
    if len(self) <= 1:
        return self
    pop = self._popcounts()
    if descending:
        order = np.lexsort((self.target_indices, -self.mean_aa_identities, pop, -self.scores))
    else:
        order = np.lexsort((-self.target_indices, self.mean_aa_identities, -pop, self.scores))
    return self[order]

best

best() -> Match | None

Return the highest-scoring Match across the container (or None if empty).

Source code in src/serogen/loci/matcher.py
def best(self) -> Match | None:
    """Return the highest-scoring `Match` across the container (or `None` if empty)."""
    if len(self) == 0:
        return None
    pop = self._popcounts()
    best_idx = int(np.lexsort((self.target_indices, -self.mean_aa_identities, pop, -self.scores))[0])
    return self[best_idx]

best_per_query

best_per_query() -> Self

Return a Matches container holding the single best target match for each unique query_index.

Source code in src/serogen/loci/matcher.py
def best_per_query(self) -> Self:
    """Return a `Matches` container holding the single best target match for each unique `query_index`."""
    if len(self) == 0:
        return self.empty()
    pop = self._popcounts()
    order = np.lexsort((self.target_indices, -self.mean_aa_identities, pop, -self.scores, self.query_indices))
    sorted_q = self.query_indices[order]
    first_mask = np.empty(len(order), dtype=np.bool_)
    first_mask[0] = True
    if len(order) > 1:
        first_mask[1:] = sorted_q[1:] != sorted_q[:-1]
    return self[order[first_mask]]

filter

filter(*, min_score: float = 0.0, min_jaccard: float = 0.0, min_coverage: float = 0.0, allowed_flags: MatchType | None = None) -> Self

Filter matches by minimum similarity metrics and/or permitted MatchType flags.

Source code in src/serogen/loci/matcher.py
def filter(
    self,
    *,
    min_score: float = 0.0,
    min_jaccard: float = 0.0,
    min_coverage: float = 0.0,
    allowed_flags: MatchType | None = None,
) -> Self:
    """Filter matches by minimum similarity metrics and/or permitted `MatchType` flags."""
    if len(self) == 0:
        return self
    mask = (
        (self.scores >= min_score)
        & (self.jaccards >= min_jaccard)
        & (self.query_coverages >= min_coverage)
        & (self.target_coverages >= min_coverage)
    )
    if allowed_flags is not None:
        disallowed = ~np.uint32(int(allowed_flags))
        mask &= (self.match_types & disallowed) == 0
    return self[mask]

LocusNodeCostStrategy dataclass

Bases: CostStrategy

Semantic node compatibility cost model for aligning two Locus graphs via GraphAligner.

Source code in src/serogen/loci/matcher.py
@dataclass(frozen=True, slots=True)
class LocusNodeCostStrategy(CostStrategy):
    """Semantic node compatibility cost model for aligning two `Locus` graphs via `GraphAligner`."""

    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]]:
        if len(labels_a) == 0 or len(labels_b) == 0:
            return np.empty((len(labels_a), len(labels_b)), dtype=np.float64)

        la = labels_a.astype(np.uint32)
        lb = labels_b.astype(np.uint32)

        edge_a = GeneNode.TAG_EDGE.extract_array(la)[:, None]
        edge_b = GeneNode.TAG_EDGE.extract_array(lb)[None, :]

        incompatible = edge_a != edge_b
        is_edge_pair = (edge_a == 1) & (edge_b == 1)

        orient_a = IntergenicType.ORIENTATION.extract_array(la)[:, None]
        orient_b = IntergenicType.ORIENTATION.extract_array(lb)[None, :]
        edge_cost = np.where(orient_a == orient_b, 0.0, 0.5)

        cid_a = GeneNode.CLUSTER_ID.extract_array(la)[:, None]
        cid_b = GeneNode.CLUSTER_ID.extract_array(lb)[None, :]
        role_a = GeneNode.ROLE_ID.extract_array(la)[:, None]
        role_b = GeneNode.ROLE_ID.extract_array(lb)[None, :]
        fs_a = GeneNode.IS_FRAMESHIFT.extract_array(la)[:, None]
        fs_b = GeneNode.IS_FRAMESHIFT.extract_array(lb)[None, :]

        both_clustered = (cid_a > 0) & (cid_b > 0)
        same_cid = both_clustered & (cid_a == cid_b)
        same_role = role_a == role_b
        same_fs = fs_a == fs_b

        gene_cost = np.full((len(la), len(lb)), np.inf, dtype=np.float64)
        gene_cost = np.where(same_cid & same_role & same_fs, 0.0, gene_cost)
        gene_cost = np.where(same_cid & same_role & ~same_fs, 0.05, gene_cost)
        gene_cost = np.where(same_cid & ~same_role, 0.15, gene_cost)
        unclustered_pair = (cid_a == 0) & (cid_b == 0)
        gene_cost = np.where(unclustered_pair & same_role & same_fs, 0.0, gene_cost)
        gene_cost = np.where(unclustered_pair & same_role & ~same_fs, 0.05, gene_cost)
        gene_cost = np.where(~same_cid & ~unclustered_pair & same_role, 1.5, gene_cost)

        out = np.where(is_edge_pair, edge_cost, gene_cost)
        return np.where(incompatible, np.inf, out)

LocusMatcher

Vectorized 1-to-many and batch-to-many biosynthetic locus matcher.

Indexes a reference database of target Loci (automatically clustering target proteins and building a single RandstrobeIndex over target proteins when translations are present) and compares query Locus or Loci instances in parallel via _match_loci_kernel.

Methods:

Name Description
assign_query_clusters

Assign target homology CLUSTER_IDs and compute per-gene AA identity in a single RandstrobeIndex pass.

match

Compare one or more query loci against all indexed target loci.

align

Perform detailed node-by-node bipartite GraphAligner alignment between query and targets[target_index].

Source code in src/serogen/loci/matcher.py
class LocusMatcher:
    """Vectorized 1-to-many and batch-to-many biosynthetic locus matcher.

    Indexes a reference database of target [`Loci`][serogen.loci.models.Loci] (automatically clustering
    target proteins and building a single [`RandstrobeIndex`][serogen.core.seq.kmers.RandstrobeIndex] over
    target proteins when translations are present) and compares query [`Locus`][serogen.loci.models.Locus]
    or [`Loci`][serogen.loci.models.Loci] instances in parallel via `_match_loci_kernel`.
    """

    def __init__(
        self,
        targets: Loci | Locus | Iterable[Locus],
        *,
        min_identity: float = 80.0,
        min_coverage: float = 0.8,
        min_bitscore: float = 0.0,
        min_score: int = 2,
        cluster_mode: Literal["cdhit", "mcl", "greedy_cover", "upgma", "connected_components"] = "cdhit",
        inflation: float = 2.0,
        k: int = 10,
        s: int = 5,
        aligner_k: int = 20,
    ) -> None:
        if isinstance(targets, Locus):
            target_batch = Loci.from_list([targets])
        elif isinstance(targets, Loci):
            target_batch = targets
        else:
            target_batch = Loci.from_list(list(targets))

        eff_min_identity = 25.0 if (cluster_mode == "mcl" and min_identity == 80.0) else float(min_identity)
        eff_min_bitscore = 40.0 if (cluster_mode == "mcl" and min_bitscore <= 0.0) else float(min_bitscore)

        self.min_identity = eff_min_identity
        self.min_coverage = min_coverage
        self.min_bitscore = eff_min_bitscore
        self.min_score = min_score
        self.cluster_mode: str = cluster_mode
        self.inflation = float(inflation)
        self.k = k
        self.s = s
        self.aligner_k = aligner_k
        self.aligner = PairwiseAligner(k=aligner_k)

        has_target_seqs = (
            target_batch.total_nodes > 0
            and len(target_batch.genes) == target_batch.total_nodes
            and len(target_batch.genes.translations) == target_batch.total_nodes
        )
        has_target_cids = target_batch.total_nodes > 0 and bool(np.any(target_batch.cluster_ids > 0))

        if has_target_seqs and not has_target_cids:
            clusterer = Clusterer(
                min_identity=self.min_identity,
                min_coverage=min_coverage,
                min_bitscore=self.min_bitscore,
                inflation=self.inflation,
                min_score=min_score,
                k=k,
                s=s,
                aligner_k=aligner_k,
                mode=cluster_mode,
            )
            target_batch = target_batch.cluster_genes(clusterer=clusterer)
            has_target_cids = bool(np.any(target_batch.cluster_ids > 0))

        self.targets: Loci = target_batch
        self.use_clusters: bool = has_target_cids
        self._has_target_seqs: bool = has_target_seqs
        self._target_partial_mask: np.ndarray[tuple[int], np.dtype[np.bool_]] = self._partial_node_mask(self.targets)

        if has_target_seqs:
            self._target_seqs = self.targets.genes.translations
            self._target_index: RandstrobeIndex | None = RandstrobeIndex.build(
                self._target_seqs,
                k=k,
                s=s,
                sort_by_hash=True,
            )
        else:
            self._target_seqs = Sequences.empty()
            self._target_index = None

        self._prepared_targets = _prepare_loci_arrays(
            self.targets,
            use_clusters=self.use_clusters,
            unclustered_base=-1,
        )

    @staticmethod
    def _partial_node_mask(loci: Loci) -> np.ndarray[tuple[int], np.dtype[np.bool_]]:
        """Return a 1D boolean mask (`(total_nodes,)`) marking partial/truncated or contig-break-adjacent genes."""
        n = loci.total_nodes
        if n == 0:
            return np.empty(0, dtype=np.bool_)
        mask = GeneNode.IS_FRAMESHIFT.extract_array(loci.node_types).astype(np.bool_)
        if len(loci.genes.is_partial) == n:
            mask |= loci.genes.is_partial
        if len(loci.edge_types) > 0:
            break_edges = IntergenicType.GAP_BIN.extract_array(loci.edge_types) == 14
            if np.any(break_edges):
                mask[loci.edge_sources[break_edges]] = True
                mask[loci.edge_targets[break_edges]] = True
        return mask

    def assign_query_clusters(
        self,
        queries: Loci,
    ) -> tuple[Loci, np.ndarray[tuple[int], np.dtype[np.float32]], np.ndarray[tuple[int, int], np.dtype[np.float32]]]:
        """Assign target homology `CLUSTER_ID`s and compute per-gene AA identity in a single `RandstrobeIndex` pass."""
        n_q_nodes = queries.total_nodes
        n_t_nodes = self.targets.total_nodes
        best_pidents = np.ones(n_q_nodes, dtype=np.float32)
        pident_mat = np.zeros((n_q_nodes, n_t_nodes), dtype=np.float32)

        if (
            not self._has_target_seqs
            or self._target_index is None
            or n_q_nodes == 0
            or len(queries.genes) != n_q_nodes
            or len(queries.genes.translations) != n_q_nodes
        ):
            return queries, best_pidents, pident_mat

        q_proteins = queries.genes.translations
        q_partial = self._partial_node_mask(queries)
        t_partial = self._target_partial_mask
        has_any_partial = bool(np.any(q_partial) or np.any(t_partial))
        seed_min = 1 if has_any_partial else max(1, self.min_score // 2)

        seeds = self._target_index.hits(q_proteins, min_score=seed_min)
        if len(seeds) == 0:
            return queries, best_pidents, pident_mat

        alignments = self.aligner.align_seeds(q_proteins, self._target_seqs, seeds)
        q_idx = seeds.query_indices.astype(np.int32)
        t_idx = seeds.target_indices.astype(np.int32)
        q_lens = q_proteins.lengths[q_idx]
        t_lens = self._target_seqs.lengths[t_idx]
        q_span = alignments.q_ends - alignments.q_starts
        t_span = alignments.t_ends - alignments.t_starts
        aligned_span = np.maximum(q_span, t_span)
        pair_is_partial = q_partial[q_idx] | (
            t_partial[t_idx] if len(t_partial) == n_t_nodes else np.zeros(len(t_idx), dtype=np.bool_)
        )
        denom = np.where(pair_is_partial, np.minimum(q_lens, t_lens), np.maximum(q_lens, t_lens))
        cov = np.clip(aligned_span / np.maximum(denom, 1), 0.0, 1.0)
        pids = np.clip((alignments.pidents / 100.0) * cov, 0.0, 1.0).astype(np.float32)
        np.maximum.at(pident_mat, (q_idx, t_idx), pids)

        if self.use_clusters and not bool(np.any(queries.cluster_ids > 0)):
            valid = alignments.pidents >= self.min_identity
            if self.min_bitscore > 0.0:
                valid &= alignments.bitscores >= self.min_bitscore
            if has_any_partial and self.min_score > 1:
                valid &= pair_is_partial | (seeds.scores >= self.min_score)
            if self.min_coverage > 0.0:
                q_cov = q_span / np.maximum(q_lens, 1)
                t_cov = t_span / np.maximum(t_lens, 1)
                unidir_cov = aligned_span / np.maximum(np.minimum(q_lens, t_lens), 1)
                both_cov = (q_cov >= self.min_coverage) & (t_cov >= self.min_coverage)
                valid &= np.where(pair_is_partial, unidir_cov >= self.min_coverage, both_cov)

            t_cids = self.targets.cluster_ids
            valid &= t_cids[t_idx] > 0
            assigned_cids = np.full(n_q_nodes, -1, dtype=np.int32)
            if np.any(valid):
                v_q = q_idx[valid]
                v_t = t_idx[valid]
                order = np.lexsort((alignments.scores[valid], pids[valid], v_q))
                v_q_sorted = v_q[order]
                v_t_sorted = v_t[order]
                assigned_cids[v_q_sorted] = t_cids[v_t_sorted] - 1
                best_pidents[v_q_sorted] = pids[valid][order]
            queries = queries.with_clusters(assigned_cids)

        return queries, best_pidents, pident_mat

    def match(
        self,
        query: Locus | Loci | Iterable[Locus],
        *,
        top_k: int | None = None,
        min_score: float = 0.0,
        sort: bool = True,
    ) -> Matches:
        """Compare one or more query loci against all indexed target loci."""
        if isinstance(query, Locus):
            q_batch = Loci.from_list([query])
        elif isinstance(query, Loci):
            q_batch = query
        else:
            q_batch = Loci.from_list(list(query))

        if len(q_batch) == 0 or len(self.targets) == 0:
            return Matches.empty()

        q_mapped, best_pidents, pident_mat = self.assign_query_clusters(q_batch)
        use_clusters = self.use_clusters or bool(np.any(q_mapped.cluster_ids > 0))
        has_target_pident = pident_mat.size > 0 and bool(np.any(pident_mat > 0.0))

        prep_t = (
            self._prepared_targets
            if use_clusters == self.use_clusters
            else _prepare_loci_arrays(self.targets, use_clusters=use_clusters, unclustered_base=-1)
        )
        q_unclustered_base = -1 - int(self.targets.total_nodes) - 1
        prep_q = _prepare_loci_arrays(
            q_mapped,
            use_clusters=use_clusters,
            unclustered_base=q_unclustered_base,
            gene_pidents=best_pidents,
        )

        # Map pident_mat from raw node indices to prepared core gene indices if identical counts
        core_pident_mat = (
            pident_mat
            if (has_target_pident and pident_mat.shape == (len(prep_q.core_keys), len(prep_t.core_keys)))
            else np.empty((0, 0), dtype=np.float32)
        )

        (
            out_q,
            out_t,
            out_flags,
            out_scores,
            out_jaccards,
            out_q_cov,
            out_t_cov,
            out_shared,
            out_missing,
            out_extra,
            out_aa_id,
        ) = _match_loci_kernel(
            prep_q.n_loci,
            prep_q.orig_strands,
            prep_q.archetypes,
            prep_q.is_fragmented,
            prep_q.core_offsets,
            prep_q.core_keys,
            prep_q.core_roles,
            prep_q.core_fs,
            prep_q.core_next_orient,
            prep_q.core_pidents,
            prep_q.mge_offsets,
            prep_q.mge_pos,
            prep_q.mge_keys,
            prep_t.n_loci,
            prep_t.orig_strands,
            prep_t.archetypes,
            prep_t.is_fragmented,
            prep_t.core_offsets,
            prep_t.core_keys,
            prep_t.core_roles,
            prep_t.core_fs,
            prep_t.core_next_orient,
            prep_t.mge_offsets,
            prep_t.mge_pos,
            prep_t.mge_keys,
            core_pident_mat,
            core_pident_mat.size > 0,
            int(LocusArchetype.UNKNOWN),
        )

        target_names = (
            self.targets.names[out_t]
            if len(self.targets.names) == len(self.targets)
            else np.full(len(out_t), "", dtype="U1")
        )
        res = Matches(
            query_indices=out_q,
            target_indices=out_t,
            match_types=out_flags,
            scores=out_scores,
            jaccards=out_jaccards,
            query_coverages=out_q_cov,
            target_coverages=out_t_cov,
            shared_genes=out_shared,
            missing_genes=out_missing,
            extra_genes=out_extra,
            mean_aa_identities=out_aa_id,
            target_names=target_names,
        )

        if min_score > 0.0:
            res = res.filter(min_score=min_score)

        if len(res) == 0:
            return res

        if sort or top_k is not None:
            pop = res._popcounts()
            order = np.lexsort((res.target_indices, -res.mean_aa_identities, pop, -res.scores, res.query_indices))
            res = res[order]
            if top_k is not None and top_k > 0:
                if top_k == 1:
                    return res.best_per_query()
                q_ids = res.query_indices
                keep = np.ones(len(res), dtype=np.bool_)
                rank = 0
                prev_q = -1
                for i in range(len(res)):
                    qi = int(q_ids[i])
                    if qi != prev_q:
                        prev_q = qi
                        rank = 1
                    else:
                        rank += 1
                    if rank > top_k:
                        keep[i] = False
                res = res[keep]

        return res

    def align(
        self,
        query: Locus,
        target_index: int | str,
        *,
        canonicalize_strand: bool = True,
        heal_mges: bool = False,
    ) -> Alignment:
        """Perform detailed node-by-node bipartite `GraphAligner` alignment between `query` and `targets[target_index]`."""
        q_batch, _, _ = self.assign_query_clusters(Loci.from_list([query]))
        q_loc = q_batch[0]
        t_loc = self.targets[target_index]
        if heal_mges:
            q_loc = q_loc.heal_mobile_elements(remove_nodes=True)
            t_loc = t_loc.heal_mobile_elements(remove_nodes=True)
        if canonicalize_strand:
            q_loc = q_loc.canonicalize_strand()
            t_loc = t_loc.canonicalize_strand()

        aligner = GraphAligner(cost_strategy=LocusNodeCostStrategy())
        return aligner.align(q_loc, t_loc)

assign_query_clusters

assign_query_clusters(queries: Loci) -> tuple[Loci, ndarray[tuple[int], dtype[float32]], ndarray[tuple[int, int], dtype[float32]]]

Assign target homology CLUSTER_IDs and compute per-gene AA identity in a single RandstrobeIndex pass.

Source code in src/serogen/loci/matcher.py
def assign_query_clusters(
    self,
    queries: Loci,
) -> tuple[Loci, np.ndarray[tuple[int], np.dtype[np.float32]], np.ndarray[tuple[int, int], np.dtype[np.float32]]]:
    """Assign target homology `CLUSTER_ID`s and compute per-gene AA identity in a single `RandstrobeIndex` pass."""
    n_q_nodes = queries.total_nodes
    n_t_nodes = self.targets.total_nodes
    best_pidents = np.ones(n_q_nodes, dtype=np.float32)
    pident_mat = np.zeros((n_q_nodes, n_t_nodes), dtype=np.float32)

    if (
        not self._has_target_seqs
        or self._target_index is None
        or n_q_nodes == 0
        or len(queries.genes) != n_q_nodes
        or len(queries.genes.translations) != n_q_nodes
    ):
        return queries, best_pidents, pident_mat

    q_proteins = queries.genes.translations
    q_partial = self._partial_node_mask(queries)
    t_partial = self._target_partial_mask
    has_any_partial = bool(np.any(q_partial) or np.any(t_partial))
    seed_min = 1 if has_any_partial else max(1, self.min_score // 2)

    seeds = self._target_index.hits(q_proteins, min_score=seed_min)
    if len(seeds) == 0:
        return queries, best_pidents, pident_mat

    alignments = self.aligner.align_seeds(q_proteins, self._target_seqs, seeds)
    q_idx = seeds.query_indices.astype(np.int32)
    t_idx = seeds.target_indices.astype(np.int32)
    q_lens = q_proteins.lengths[q_idx]
    t_lens = self._target_seqs.lengths[t_idx]
    q_span = alignments.q_ends - alignments.q_starts
    t_span = alignments.t_ends - alignments.t_starts
    aligned_span = np.maximum(q_span, t_span)
    pair_is_partial = q_partial[q_idx] | (
        t_partial[t_idx] if len(t_partial) == n_t_nodes else np.zeros(len(t_idx), dtype=np.bool_)
    )
    denom = np.where(pair_is_partial, np.minimum(q_lens, t_lens), np.maximum(q_lens, t_lens))
    cov = np.clip(aligned_span / np.maximum(denom, 1), 0.0, 1.0)
    pids = np.clip((alignments.pidents / 100.0) * cov, 0.0, 1.0).astype(np.float32)
    np.maximum.at(pident_mat, (q_idx, t_idx), pids)

    if self.use_clusters and not bool(np.any(queries.cluster_ids > 0)):
        valid = alignments.pidents >= self.min_identity
        if self.min_bitscore > 0.0:
            valid &= alignments.bitscores >= self.min_bitscore
        if has_any_partial and self.min_score > 1:
            valid &= pair_is_partial | (seeds.scores >= self.min_score)
        if self.min_coverage > 0.0:
            q_cov = q_span / np.maximum(q_lens, 1)
            t_cov = t_span / np.maximum(t_lens, 1)
            unidir_cov = aligned_span / np.maximum(np.minimum(q_lens, t_lens), 1)
            both_cov = (q_cov >= self.min_coverage) & (t_cov >= self.min_coverage)
            valid &= np.where(pair_is_partial, unidir_cov >= self.min_coverage, both_cov)

        t_cids = self.targets.cluster_ids
        valid &= t_cids[t_idx] > 0
        assigned_cids = np.full(n_q_nodes, -1, dtype=np.int32)
        if np.any(valid):
            v_q = q_idx[valid]
            v_t = t_idx[valid]
            order = np.lexsort((alignments.scores[valid], pids[valid], v_q))
            v_q_sorted = v_q[order]
            v_t_sorted = v_t[order]
            assigned_cids[v_q_sorted] = t_cids[v_t_sorted] - 1
            best_pidents[v_q_sorted] = pids[valid][order]
        queries = queries.with_clusters(assigned_cids)

    return queries, best_pidents, pident_mat

match

match(query: Locus | Loci | Iterable[Locus], *, top_k: int | None = None, min_score: float = 0.0, sort: bool = True) -> Matches

Compare one or more query loci against all indexed target loci.

Source code in src/serogen/loci/matcher.py
def match(
    self,
    query: Locus | Loci | Iterable[Locus],
    *,
    top_k: int | None = None,
    min_score: float = 0.0,
    sort: bool = True,
) -> Matches:
    """Compare one or more query loci against all indexed target loci."""
    if isinstance(query, Locus):
        q_batch = Loci.from_list([query])
    elif isinstance(query, Loci):
        q_batch = query
    else:
        q_batch = Loci.from_list(list(query))

    if len(q_batch) == 0 or len(self.targets) == 0:
        return Matches.empty()

    q_mapped, best_pidents, pident_mat = self.assign_query_clusters(q_batch)
    use_clusters = self.use_clusters or bool(np.any(q_mapped.cluster_ids > 0))
    has_target_pident = pident_mat.size > 0 and bool(np.any(pident_mat > 0.0))

    prep_t = (
        self._prepared_targets
        if use_clusters == self.use_clusters
        else _prepare_loci_arrays(self.targets, use_clusters=use_clusters, unclustered_base=-1)
    )
    q_unclustered_base = -1 - int(self.targets.total_nodes) - 1
    prep_q = _prepare_loci_arrays(
        q_mapped,
        use_clusters=use_clusters,
        unclustered_base=q_unclustered_base,
        gene_pidents=best_pidents,
    )

    # Map pident_mat from raw node indices to prepared core gene indices if identical counts
    core_pident_mat = (
        pident_mat
        if (has_target_pident and pident_mat.shape == (len(prep_q.core_keys), len(prep_t.core_keys)))
        else np.empty((0, 0), dtype=np.float32)
    )

    (
        out_q,
        out_t,
        out_flags,
        out_scores,
        out_jaccards,
        out_q_cov,
        out_t_cov,
        out_shared,
        out_missing,
        out_extra,
        out_aa_id,
    ) = _match_loci_kernel(
        prep_q.n_loci,
        prep_q.orig_strands,
        prep_q.archetypes,
        prep_q.is_fragmented,
        prep_q.core_offsets,
        prep_q.core_keys,
        prep_q.core_roles,
        prep_q.core_fs,
        prep_q.core_next_orient,
        prep_q.core_pidents,
        prep_q.mge_offsets,
        prep_q.mge_pos,
        prep_q.mge_keys,
        prep_t.n_loci,
        prep_t.orig_strands,
        prep_t.archetypes,
        prep_t.is_fragmented,
        prep_t.core_offsets,
        prep_t.core_keys,
        prep_t.core_roles,
        prep_t.core_fs,
        prep_t.core_next_orient,
        prep_t.mge_offsets,
        prep_t.mge_pos,
        prep_t.mge_keys,
        core_pident_mat,
        core_pident_mat.size > 0,
        int(LocusArchetype.UNKNOWN),
    )

    target_names = (
        self.targets.names[out_t]
        if len(self.targets.names) == len(self.targets)
        else np.full(len(out_t), "", dtype="U1")
    )
    res = Matches(
        query_indices=out_q,
        target_indices=out_t,
        match_types=out_flags,
        scores=out_scores,
        jaccards=out_jaccards,
        query_coverages=out_q_cov,
        target_coverages=out_t_cov,
        shared_genes=out_shared,
        missing_genes=out_missing,
        extra_genes=out_extra,
        mean_aa_identities=out_aa_id,
        target_names=target_names,
    )

    if min_score > 0.0:
        res = res.filter(min_score=min_score)

    if len(res) == 0:
        return res

    if sort or top_k is not None:
        pop = res._popcounts()
        order = np.lexsort((res.target_indices, -res.mean_aa_identities, pop, -res.scores, res.query_indices))
        res = res[order]
        if top_k is not None and top_k > 0:
            if top_k == 1:
                return res.best_per_query()
            q_ids = res.query_indices
            keep = np.ones(len(res), dtype=np.bool_)
            rank = 0
            prev_q = -1
            for i in range(len(res)):
                qi = int(q_ids[i])
                if qi != prev_q:
                    prev_q = qi
                    rank = 1
                else:
                    rank += 1
                if rank > top_k:
                    keep[i] = False
            res = res[keep]

    return res

align

align(query: Locus, target_index: int | str, *, canonicalize_strand: bool = True, heal_mges: bool = False) -> Alignment

Perform detailed node-by-node bipartite GraphAligner alignment between query and targets[target_index].

Source code in src/serogen/loci/matcher.py
def align(
    self,
    query: Locus,
    target_index: int | str,
    *,
    canonicalize_strand: bool = True,
    heal_mges: bool = False,
) -> Alignment:
    """Perform detailed node-by-node bipartite `GraphAligner` alignment between `query` and `targets[target_index]`."""
    q_batch, _, _ = self.assign_query_clusters(Loci.from_list([query]))
    q_loc = q_batch[0]
    t_loc = self.targets[target_index]
    if heal_mges:
        q_loc = q_loc.heal_mobile_elements(remove_nodes=True)
        t_loc = t_loc.heal_mobile_elements(remove_nodes=True)
    if canonicalize_strand:
        q_loc = q_loc.canonicalize_strand()
        t_loc = t_loc.canonicalize_strand()

    aligner = GraphAligner(cost_strategy=LocusNodeCostStrategy())
    return aligner.align(q_loc, t_loc)

Comments