Skip to content

kbank.biosynthesis

Bipartite gene-to-glycan graph alignment, biochemical rules, factorized 1D-CNN classifiers, and Relational MPNN de novo structure prediction.


Bipartite Gene-to-Glycan Alignment (kbank.biosynthesis.align)

align

Cross-domain geno-glycomic alignment between gene clusters and glycan graphs.

Provides TargetType, BiochemicalRule, BiochemicalRuleRegistry, GeneGlycanCostStrategy, GeneGlycanAligner, and infer_gt_linkage_assignments for mapping biosynthetic gene clusters (GeneCluster / GeneClusters from serogen) onto bipartite carbohydrate structures (Glycan / Glycans from kbank).

Classes:

Name Description
TargetType

Structural target category in a bipartite glycan graph for a biosynthetic gene role.

AssignmentEvidence

Provenance and evidence level for a gene-to-glycan component assignment.

BiosyntheticRule

Declarative rule defining what structural elements a GeneRole can synthesize.

BiosyntheticRuleRegistry

Builds and caches biochemical compatibility rules from GeneRole and Sugars.

PanGraphConsensusPrior

Archetype- and cross-species pan-graph consensus model for Init, Wzy, and GT -> linkage resolution.

BiosyntheticCostStrategy

Domain-specific cost strategy mapping GeneCluster nodes to Glycan bipartite nodes.

BiosyntheticAligner

Specialized GraphAligner pre-configured for matching GeneCluster to Glycan.

GeneGlycanAssignment

Resolved assignment of a gene (or theoretical GeneRole) to a structural element in a Glycan.

GeneGlycanAssignments

Ragged Structure-of-Arrays (SoA) container for cross-domain gene-to-glycan component assignments.

LocusMatchResult

Result of matching and scoring a candidate Locus against a target Glycan.

BiosyntheticAssigner

Assigns candidate biosynthetic Loci to Glycan structures and maps individual genes to linkages/residues.

Functions:

Name Description
infer_gt_linkage_assignments

Explicit helper mapping each glycosyltransferase in a Locus to its Glycan linkage.

TargetType

Bases: StrEnum

Structural target category in a bipartite glycan graph for a biosynthetic gene role.

Source code in src/kbank/biosynthesis/align.py
class TargetType(StrEnum):
    """Structural target category in a bipartite glycan graph for a biosynthetic gene role."""

    NONE = "NONE"
    REDUCING_END = "REDUCING_END"
    LINKAGE = "LINKAGE"
    MODIFIED_MONO = "MODIFIED_MONO"
    MONOSACCHARIDE = "MONOSACCHARIDE"
    ANY = "ANY"

AssignmentEvidence

Bases: IntEnum

Provenance and evidence level for a gene-to-glycan component assignment.

Source code in src/kbank/biosynthesis/align.py
class AssignmentEvidence(IntEnum):
    """Provenance and evidence level for a gene-to-glycan component assignment."""

    RULE_INFERRED = 0
    GRAPH_ALIGNED = 1
    LITERATURE = 2
    LOCUS_COMPLETED = 3
    PAN_GRAPH_CONSENSUS = 4

BiosyntheticRule dataclass

Declarative rule defining what structural elements a GeneRole can synthesize.

Source code in src/kbank/biosynthesis/align.py
@dataclass(slots=True, frozen=True)
class BiosyntheticRule:
    """Declarative rule defining what structural elements a `GeneRole` can synthesize."""

    role_idx: int
    role_name: str
    target_type: TargetType
    compatible_sugars: frozenset[int] = frozenset()
    target_modifiers: frozenset[int] = frozenset()
    matches_linkages: bool = False
    is_polymerase: bool = False

BiosyntheticRuleRegistry

Builds and caches biochemical compatibility rules from GeneRole and Sugars.

Methods:

Name Description
get_rule

Retrieve the biochemical rule for a given GeneRole index.

Source code in src/kbank/biosynthesis/align.py
class BiosyntheticRuleRegistry:
    """Builds and caches biochemical compatibility rules from `GeneRole` and `Sugars`."""

    def __init__(self, sugars_db: type[Sugars] | Sugars = Sugars) -> None:
        """Initialize the registry and precompute rules for all `GeneRole` members."""
        self.sugars_db = sugars_db
        self.rules: dict[int, BiosyntheticRule] = {}
        self._build_rules()

    def _build_rules(self) -> None:
        names = self.sugars_db.names
        classes = self.sugars_db.classes
        name_to_idx = self.sugars_db.name_to_index

        for role in GeneRole:
            spec = _ROLE_GLYCAN_SPECS[role]
            mod_indices = frozenset(name_to_idx[m] for m in spec.target_modifiers if m in name_to_idx)

            compat_indices: set[int] = set()
            for s_idx in range(len(names)):
                s_name = names[s_idx]
                s_cls = classes[s_idx]

                if spec.disallowed_classes and s_cls in spec.disallowed_classes:
                    continue

                if spec.allowed_names or spec.allowed_classes:
                    if s_name in spec.allowed_names or s_cls in spec.allowed_classes:
                        compat_indices.add(s_idx)
                elif spec.disallowed_classes:
                    compat_indices.add(s_idx)

            self.rules[int(role)] = BiosyntheticRule(
                role_idx=int(role),
                role_name=role.name,
                target_type=spec.target_type,
                compatible_sugars=frozenset(compat_indices),
                target_modifiers=mod_indices,
                matches_linkages=spec.matches_linkages,
                is_polymerase=(role is GeneRole.POLYMERASE),
            )

    def get_rule(self, role_idx: int | GeneRole) -> BiosyntheticRule:
        """Retrieve the biochemical rule for a given `GeneRole` index."""
        return self.rules.get(int(role_idx), self.rules[int(GeneRole.UNKNOWN)])

get_rule

get_rule(role_idx: int | GeneRole) -> BiosyntheticRule

Retrieve the biochemical rule for a given GeneRole index.

Source code in src/kbank/biosynthesis/align.py
def get_rule(self, role_idx: int | GeneRole) -> BiosyntheticRule:
    """Retrieve the biochemical rule for a given `GeneRole` index."""
    return self.rules.get(int(role_idx), self.rules[int(GeneRole.UNKNOWN)])

PanGraphConsensusPrior

Archetype- and cross-species pan-graph consensus model for Init, Wzy, and GT -> linkage resolution.

Pools node-level homology (GeneNode.cluster_id) and edge-level syntenic transitions (Loci.as_pan_graph() and Glycans.as_pan_graph()) across paired records to resolve: 1. INITIATOR homology cluster_id -> biological Und-PP initiating monosaccharide (WbaP -> D-Galp, WcaJ -> D-Glcp, WecA -> D-GlcNAc/D-GalNAc, Itr/PglC -> D-QuiNAc/D-FucNAc/D-Gal). 2. GLYCOSYLTRANSFERASE & POLYMERASE homology cluster_id -> (donor, anomer, acceptor_carbon, acceptor) and retaining vs. inverting stereochemical mechanism. 3. Consecutive syntenic GT transitions (cluster_a -> cluster_b) in the pan-locus network paired with consecutive glycosidic linkages (e_1 -> e_2) in the pan-glycan network.

Methods:

Name Description
preferred_init_sugar

Return the highest-scoring initiating sugar index in candidate_sugars for cluster_id, or None.

preferred_precursor_sugar

Return the multi-record consensus sugar synthesized by precursor cluster_id among candidate_sugars.

has_init_consensus

Return True if cluster_id has multi-record pan-graph or classifier consensus for initiating on sugar_idx.

gt_edge_bonus

Compute cross-archetype pan-graph homology & synteny cost bonus and whether pan-consensus holds.

fit

Fit a PanGraphConsensusPrior across all paired records and rotate glycans to biological reducing ends.

Source code in src/kbank/biosynthesis/align.py
 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
class PanGraphConsensusPrior:
    """Archetype- and cross-species pan-graph consensus model for `Init`, `Wzy`, and `GT` -> linkage resolution.

    Pools node-level homology (`GeneNode.cluster_id`) and edge-level syntenic transitions
    (`Loci.as_pan_graph()` and `Glycans.as_pan_graph()`) across paired records to resolve:
    1. `INITIATOR` homology `cluster_id` -> biological Und-PP initiating monosaccharide (`WbaP` -> `D-Galp`,
       `WcaJ` -> `D-Glcp`, `WecA` -> `D-GlcNAc`/`D-GalNAc`, `Itr`/`PglC` -> `D-QuiNAc`/`D-FucNAc`/`D-Gal`).
    2. `GLYCOSYLTRANSFERASE` & `POLYMERASE` homology `cluster_id` -> `(donor, anomer, acceptor_carbon, acceptor)`
       and retaining vs. inverting stereochemical mechanism.
    3. Consecutive syntenic GT transitions `(cluster_a -> cluster_b)` in the pan-locus network paired with
       consecutive glycosidic linkages `(e_1 -> e_2)` in the pan-glycan network.
    """

    def __init__(self) -> None:
        """Initialize an empty `PanGraphConsensusPrior`."""
        self.init_cluster_sugar: dict[int, dict[int, float]] = {}
        self.cluster_record_counts: dict[int, int] = {}
        self.cluster_sig_full: dict[int, dict[tuple[int, ...], float]] = {}
        self.cluster_sig_dl: dict[int, dict[tuple[int, ...], float]] = {}
        self.cluster_sig_da: dict[int, dict[tuple[int, ...], float]] = {}
        self.cluster_mech: dict[int, dict[int, float]] = {}
        self.syntenic_pair_dl: dict[tuple[int, int], dict[tuple[tuple[int, ...], tuple[int, ...]], float]] = {}
        self.init_adj_gt_step1: dict[tuple[int, int], dict[tuple[int, ...], float]] = {}
        self.precursor_gt_donor: dict[tuple[int, int], dict[int, float]] = {}
        self.precursor_cluster_sugar: dict[int, dict[int, float]] = {}
        self.syntenic_pair_record_counts: dict[tuple[int, int], int] = {}
        self.pan_locus_edge_counts: dict[tuple[int, int], int] = {}
        self.pan_glycan_node_counts: dict[int, int] = {}
        self.pan_glycan_bigram_counts: dict[tuple[tuple[int, ...], tuple[int, ...]], int] = {}
        self.modifier_cluster_spec: dict[int, dict[tuple[int, int, int], float]] = {}
        self.fine_to_med: dict[int, int] = {}
        self.rescued_loci: list[Locus] = []
        self.classifier_suite: Any | None = None

    def preferred_init_sugar(
        self,
        cluster_id: int,
        candidate_sugars: set[int],
        aa_seq: bytes | None = None,
    ) -> int | None:
        """Return the highest-scoring initiating sugar index in `candidate_sugars` for `cluster_id`, or `None`."""
        if (
            cluster_id > 0
            and cluster_id in self.init_cluster_sugar
            and self.cluster_record_counts.get(cluster_id, 0) >= 2
        ):
            s_map = self.init_cluster_sugar[cluster_id]
            best_s: int | None = None
            best_val = 0.0
            for s_idx in candidate_sugars:
                val = s_map.get(s_idx, 0.0)
                if val > best_val:
                    best_val = val
                    best_s = s_idx
            if best_s is not None and best_val >= 0.5:
                return best_s
        if self.classifier_suite is not None:
            clf_s, clf_p = self.classifier_suite.preferred_init_sugar(cluster_id, candidate_sugars, aa_seq=aa_seq)
            if clf_s is not None and clf_p >= 0.40:
                return clf_s
        if cluster_id > 0 and cluster_id in self.init_cluster_sugar:
            s_map = self.init_cluster_sugar[cluster_id]
            best_s = None
            best_val = 0.0
            for s_idx in candidate_sugars:
                val = s_map.get(s_idx, 0.0)
                if val > best_val:
                    best_val = val
                    best_s = s_idx
            return best_s if best_val >= 0.5 else None
        return None

    def preferred_precursor_sugar(self, cluster_id: int, candidate_sugars: set[int]) -> int | None:
        """Return the multi-record consensus sugar synthesized by precursor `cluster_id` among `candidate_sugars`."""
        for c_tier in (cluster_id, self.fine_to_med.get(cluster_id, 0)):
            if c_tier <= 0 or self.cluster_record_counts.get(c_tier, 0) < 2:
                continue
            s_map = self.precursor_cluster_sugar.get(c_tier, {})
            if not s_map:
                continue
            cand_w = {s_idx: s_map.get(s_idx, 0.0) for s_idx in candidate_sugars if s_map.get(s_idx, 0.0) > 0.0}
            if not cand_w:
                continue
            tot_cand = sum(cand_w.values())
            best_s, best_w = max(cand_w.items(), key=lambda kv: kv[1])
            if best_w >= 1.5 and best_w >= 0.55 * tot_cand:
                return best_s
        return None

    def has_init_consensus(self, cluster_id: int, sugar_idx: int, aa_seq: bytes | None = None) -> bool:
        """Return True if `cluster_id` has multi-record pan-graph or classifier consensus for initiating on `sugar_idx`."""
        if cluster_id > 0 and self.cluster_record_counts.get(cluster_id, 0) >= 2:
            s_map = self.init_cluster_sugar.get(cluster_id, {})
            tot = sum(s_map.values())
            if tot > 0 and s_map.get(sugar_idx, 0.0) >= 0.45 * tot:
                return True
        if self.classifier_suite is not None:
            clf_s, clf_p = self.classifier_suite.preferred_init_sugar(cluster_id, {sugar_idx}, aa_seq=aa_seq)
            if clf_s == sugar_idx and clf_p >= 0.65:
                return True
        return False

    def gt_edge_bonus(
        self,
        cluster_id: int,
        glycan: Glycan,
        edge_idx: int,
        adj_clusters: Sequence[tuple[int, int]] = (),
        adj_init_clusters: Sequence[int] = (),
        adj_precursor_clusters: Sequence[int] = (),
        adj_precursor_sugars: set[int] | frozenset[int] = frozenset(),
        aa_seq: bytes | None = None,
    ) -> tuple[float, bool]:
        """Compute cross-archetype pan-graph homology & synteny cost bonus and whether pan-consensus holds."""
        sig_full, sig_dl, sig_da, mech = _edge_signatures(glycan, edge_idx)
        bonus = 0.0
        has_consensus = False

        red_idx = glycan.reducing_end_index
        wzy_k, _, _ = glycan.polymer_edge_and_assembly_depths(red_idx)
        all_core = [
            k
            for k, e in enumerate(glycan.edges)
            if not glycan.nodes[e.source].is_modifier and not glycan.nodes[e.target].is_modifier
        ]
        intra_core = [k for k in all_core if k != wzy_k]
        cand_edges = intra_core if (intra_core and edge_idx != wzy_k) else all_core

        for c_tier, tier_scale in ((cluster_id, 1.0), (self.fine_to_med.get(cluster_id, 0), 0.72)):
            if c_tier <= 0 or self.cluster_record_counts.get(c_tier, 0) < 2:
                continue

            full_map = self.cluster_sig_full.get(c_tier, {})
            dl_map = self.cluster_sig_dl.get(c_tier, {})
            da_map = self.cluster_sig_da.get(c_tier, {})

            gly_full_w = {
                _edge_signatures(glycan, k)[0]: full_map.get(_edge_signatures(glycan, k)[0], 0.0) for k in cand_edges
            }
            gly_dl_w = {
                _edge_signatures(glycan, k)[1]: dl_map.get(_edge_signatures(glycan, k)[1], 0.0) for k in cand_edges
            }
            gly_da_w = {
                _edge_signatures(glycan, k)[2]: da_map.get(_edge_signatures(glycan, k)[2], 0.0) for k in cand_edges
            }

            w_full = gly_full_w.get(sig_full, 0.0)
            max_gly_full = max(gly_full_w.values(), default=0.0)
            other_full = max((v for k_s, v in gly_full_w.items() if k_s != sig_full), default=0.0)

            w_dl = gly_dl_w.get(sig_dl, 0.0)
            max_gly_dl = max(gly_dl_w.values(), default=0.0)
            other_dl = max((v for k_s, v in gly_dl_w.items() if k_s != sig_dl), default=0.0)

            w_da = gly_da_w.get(sig_da, 0.0)
            max_gly_da = max(gly_da_w.values(), default=0.0)
            other_da = max((v for k_s, v in gly_da_w.items() if k_s != sig_da), default=0.0)

            if w_full >= 0.60 and w_full == max_gly_full and w_full >= other_full * 1.20 and w_da >= max_gly_da * 0.65:
                bonus -= 0.13 * tier_scale
                has_consensus = True
            elif max_gly_full >= 0.85 and w_full < max_gly_full * 0.50:
                bonus += 0.09 * tier_scale

            if w_dl >= 0.60 and w_dl == max_gly_dl and w_dl >= other_dl * 1.20 and w_da >= max_gly_da * 0.65:
                bonus -= 0.11 * tier_scale
                has_consensus = True
            elif max_gly_dl >= 0.85 and w_dl < max_gly_dl * 0.50:
                bonus += 0.11 * tier_scale

            if w_da >= 0.65 and w_da == max_gly_da and w_da >= other_da * 1.20:
                bonus -= 0.08 * tier_scale
                has_consensus = True
            elif max_gly_da >= 0.90 and w_da < max_gly_da * 0.45:
                bonus += 0.16 * tier_scale

            mech_map = self.cluster_mech.get(c_tier, {})
            tot_mech = sum(mech_map.values())
            if mech > 0 and tot_mech >= 1.2:
                frac_mech = mech_map.get(mech, 0.0) / tot_mech
                if frac_mech >= 0.68:
                    bonus -= 0.03 * tier_scale * frac_mech
                elif frac_mech <= 0.18:
                    bonus += 0.12 * tier_scale

        e_curr = glycan.edges[edge_idx]
        donor_s = glycan.nodes[e_curr.source].sugar

        # Signal 1: (INITIATOR_cluster -> GT_cluster) step-1 anchoring onto the biological reducing end
        if e_curr.target == red_idx and cluster_id > 0:
            step1_edges = [k for k in cand_edges if glycan.edges[k].target == red_idx]
            for c_init in adj_init_clusters:
                for c_tier in (cluster_id, self.fine_to_med.get(cluster_id, 0)):
                    if c_tier > 0 and self.cluster_record_counts.get(c_tier, 0) >= 2:
                        step1_w = self.init_adj_gt_step1.get((c_init, c_tier), {}).get(sig_dl, 0.0)
                        if step1_w >= 0.85:
                            bonus -= 0.07
                            has_consensus = True
                            break
                if self.cluster_record_counts.get(c_init, 0) >= 2 and len(step1_edges) == 1:
                    bonus -= 0.055
                    has_consensus = True
                    break

        # Signal 2: (Precursor_Operon -> GT_cluster) syntenic donor-sugar coupling
        if cluster_id > 0:
            for c_prec in adj_precursor_clusters:
                for c_tier in (cluster_id, self.fine_to_med.get(cluster_id, 0)):
                    if c_tier <= 0 or self.cluster_record_counts.get(c_tier, 0) < 2:
                        continue
                    prec_map = self.precursor_gt_donor.get((c_prec, c_tier), {})
                    if prec_map:
                        max_prec_w = max(prec_map.values(), default=0.0)
                        w_prec = prec_map.get(donor_s, 0.0)
                        if w_prec >= 0.85 and w_prec >= max_prec_w * 0.80:
                            bonus -= 0.06
                            has_consensus = True
                            break

        if adj_precursor_sugars and donor_s in adj_precursor_sugars:
            bonus -= 0.055
            matching_cand_donors = {
                glycan.nodes[glycan.edges[k].source].sugar
                for k in cand_edges
                if glycan.nodes[glycan.edges[k].source].sugar in adj_precursor_sugars
            }
            matching_cand_sigs = {
                _edge_signatures(glycan, k)[1]
                for k in cand_edges
                if glycan.nodes[glycan.edges[k].source].sugar == donor_s
            }
            if len(matching_cand_donors) == 1 and len(matching_cand_sigs) == 1:
                has_consensus = True

        # Signal 3: Syntenic (GT_A -> GT_B) x (Linkage_1 -> Linkage_2) bigrams weighted by Pan-Glycan network
        if cluster_id > 0:
            for c_other, dir_flag in adj_clusters:
                if c_other <= 0:
                    continue
                for c_o_tier, c_s_tier in (
                    (c_other, cluster_id),
                    (self.fine_to_med.get(c_other, 0), self.fine_to_med.get(cluster_id, 0)),
                ):
                    if c_o_tier <= 0 or c_s_tier <= 0:
                        continue
                    pair_key = (c_o_tier, c_s_tier) if dir_flag < 0 else (c_s_tier, c_o_tier)
                    if self.syntenic_pair_record_counts.get(pair_key, 0) < 2:
                        continue
                    pair_map = self.syntenic_pair_dl.get(pair_key, {})
                    if not pair_map:
                        continue
                    for k_other in cand_edges:
                        if k_other == edge_idx:
                            continue
                        e_other = glycan.edges[k_other]
                        if e_curr.target == e_other.source or e_curr.source == e_other.target:
                            _, sig_dl_other, _, _ = _edge_signatures(glycan, k_other)
                            sig_pair = (sig_dl_other, sig_dl) if dir_flag < 0 else (sig_dl, sig_dl_other)
                            sup = pair_map.get(sig_pair, 0.0)
                            if sup >= 0.85:
                                bonus -= 0.06
                                has_consensus = True
                                break

                # Cross-network anchor: if neighbor GT c_other has multi-record consensus on an adjacent edge k_other
                # and (dl_other, dl_curr) is a recurring disaccharide motif in the global Pan-Glycan network
                if not has_consensus and self.cluster_record_counts.get(c_other, 0) >= 2:
                    o_dl_map = self.cluster_sig_dl.get(c_other, {})
                    if o_dl_map:
                        cand_o_w = {
                            _edge_signatures(glycan, k)[1]: o_dl_map.get(_edge_signatures(glycan, k)[1], 0.0)
                            for k in cand_edges
                        }
                        max_o_w = max(cand_o_w.values(), default=0.0)
                        if max_o_w >= 0.80:
                            for k_other in cand_edges:
                                if k_other == edge_idx:
                                    continue
                                _, dl_other, _, _ = _edge_signatures(glycan, k_other)
                                if cand_o_w.get(dl_other, 0.0) < max_o_w:
                                    continue
                                e_other = glycan.edges[k_other]
                                if e_curr.target == e_other.source or e_curr.source == e_other.target:
                                    sig_pair = (dl_other, sig_dl) if dir_flag < 0 else (sig_dl, dl_other)
                                    bg_cnt = self.pan_glycan_bigram_counts.get(sig_pair, 0)
                                    if bg_cnt >= 2:
                                        bonus -= 0.045
                                        adj_sigs = {
                                            _edge_signatures(glycan, k_c)[1]
                                            for k_c in cand_edges
                                            if k_c != k_other
                                            and (
                                                glycan.edges[k_c].target == e_other.source
                                                or glycan.edges[k_c].source == e_other.target
                                            )
                                        }
                                        if len(adj_sigs) == 1:
                                            has_consensus = True
                                        break

        # Hybrid fallback: for singleton/unresolved GT clusters or unclustered genes (cluster_id <= 0),
        # query the factorized 1D-CNN BiosyntheticClassifierSuite (donor, linkage, mechanism)
        if self.classifier_suite is not None and (
            not has_consensus or self.cluster_record_counts.get(cluster_id, 0) < 2
        ):
            clf_b, clf_cons = self.classifier_suite.gt_edge_score(
                cluster_id,
                glycan,
                edge_idx,
                aa_seq=aa_seq,
            )
            scale_clf = 1.0 if self.cluster_record_counts.get(cluster_id, 0) < 2 else 0.55
            bonus += scale_clf * clf_b
            if clf_cons:
                has_consensus = True

        return float(np.clip(bonus, -0.32, 0.45)), has_consensus

    @classmethod
    def fit(
        cls,
        glycans: Sequence[Glycan],
        loci: Loci | Sequence[Locus],
        archetypes: Sequence[LocusArchetype | int] | np.ndarray,
        is_repeating: Sequence[bool] | np.ndarray,
        *,
        assigner: BiosyntheticAssigner | None = None,
        em_iterations: int = 5,
        classifier_suite: Any | None = None,
    ) -> tuple[Self, list[Glycan]]:
        """Fit a `PanGraphConsensusPrior` across all paired records and rotate glycans to biological reducing ends."""
        from collections import Counter, defaultdict

        from scipy.optimize import linear_sum_assignment

        from kbank.biosynthesis.classifier import BiosyntheticClassifierSuite
        from kbank.glycan import Glycans

        prior = cls()
        suite = classifier_suite if classifier_suite is not None else BiosyntheticClassifierSuite.pretrained()
        if suite is not None and suite.is_fitted:
            prior.classifier_suite = suite
        n = len(glycans)
        if n == 0:
            return prior, list(glycans)

        loci_batch = loci if isinstance(loci, Loci) else Loci.from_list(list(loci))
        glycans_batch = Glycans.from_list(list(glycans))

        # 1. Extract pan-locus and pan-glycan consensus transition topologies via .as_pan_graph()
        if len(loci_batch.node_types) > 0:
            role_cluster_mask = int(GeneNode.mask("ROLE_ID", "CLUSTER_ID"))
            pan_l_adj, pan_l_nodes, _ = loci_batch.as_pan_graph(
                node_mask=role_cluster_mask,
                canonicalize_strands=False,
            )
            coo_l = pan_l_adj.tocoo()
            for r_i, c_i, cnt in zip(coo_l.row, coo_l.col, coo_l.data, strict=False):
                c_src = int(GeneNode(int(pan_l_nodes[int(r_i)])).cluster_id)
                c_tgt = int(GeneNode(int(pan_l_nodes[int(c_i)])).cluster_id)
                if c_src > 0 and c_tgt > 0:
                    prior.pan_locus_edge_counts[(c_src, c_tgt)] = prior.pan_locus_edge_counts.get(
                        (c_src, c_tgt), 0
                    ) + int(cnt)

        if len(glycans_batch.node_types) > 0:
            _, pan_g_nodes, pan_g_counts = glycans_batch.as_pan_graph()
            for g_n, g_c in zip(pan_g_nodes, pan_g_counts, strict=False):
                prior.pan_glycan_node_counts[int(g_n)] = int(g_c)

        # Build Medium/Coarse superfamily cluster hierarchy when protein sequences are available (by_role=False)
        cluster_rec_sets: dict[int, set[int]] = defaultdict(set)
        if (
            len(loci_batch.node_types) > 0
            and len(loci_batch.genes) == len(loci_batch.node_types)
            and bool(np.any(loci_batch.cluster_ids > 0))
        ):
            med_loci = loci_batch.cluster_genes(
                mode="mcl",
                min_identity=20.0,
                min_bitscore=35.0,
                inflation=1.6,
                by_role=False,
            )
            for fine_c, med_c in zip(loci_batch.cluster_ids, med_loci.cluster_ids, strict=False):
                fc = int(fine_c)
                mc = int(med_c)
                if fc > 0 and mc > 0:
                    prior.fine_to_med[fc] = mc + 100000

        # 2. Pass 0: Accumulate INITIATOR & Precursor cluster -> sugar specificity & rotate biological reducing ends
        canon_init_names = {
            b"GlcNAc",
            b"GalNAc",
            b"QuiNAc",
            b"FucNAc",
            b"FucN4N",
            b"QuiN4N",
            b"Bac",
            b"Gal",
            b"Glc",
        }
        canon_init_ids = {Sugars.name_to_index[nm] for nm in canon_init_names if nm in Sugars.name_to_index}
        housekeeping_names = {b"Glc", b"Gal", b"GlcNAc"}
        housekeeping_ids = {Sugars.name_to_index[nm] for nm in housekeeping_names if nm in Sugars.name_to_index}
        precursor_roles = {
            GeneRole.OXIDOREDUCTASE,
            GeneRole.EPIMERASE,
            GeneRole.AMINOTRANSFERASE,
            GeneRole.ALDOLASE,
            GeneRole.NUCLEOTIDASE,
            GeneRole.NUCLEOTIDYLTRANSFERASE,
        }
        helper = assigner if assigner is not None else BiosyntheticAssigner()
        init_sugar_acc: dict[int, dict[int, float]] = defaultdict(lambda: defaultdict(float))
        prec_sugar_acc: dict[int, dict[int, float]] = defaultdict(lambda: defaultdict(float))
        mod_spec_acc: dict[int, dict[tuple[int, int, int], float]] = defaultdict(lambda: defaultdict(float))
        mod_roles_set = {
            GeneRole.ACETYLTRANSFERASE,
            GeneRole.PYRUVYLTRANSFERASE,
            GeneRole.METHYLTRANSFERASE,
            GeneRole.PHOSPHOTRANSFERASE,
            GeneRole.FORMYLTRANSFERASE,
            GeneRole.ALANINE_TRANSFERASE,
        }

        raw_locus_list = [loci_batch[i] for i in range(n)]
        for i in range(n):
            loc = raw_locus_list[i]
            gly = glycans[i]
            if len(loc.nodes) == 0 or len(gly.nodes) == 0:
                continue
            for g_node in loc.nodes:
                cid = int(g_node.cluster_id)
                if cid > 0:
                    cluster_rec_sets[cid].add(i)
                    mc = prior.fine_to_med.get(cid, 0)
                    if mc > 0:
                        cluster_rec_sets[mc].add(i)

            n_ac_mods, _ = helper._find_n_acetyl_nodes(gly)
            for g_node in loc.nodes:
                if g_node.role in mod_roles_set and g_node.cluster_id > 0:
                    cid = int(g_node.cluster_id)
                    m_targets = helper.registry.get_rule(g_node.role).target_modifiers
                    for e_mod in gly.edges:
                        if 0 <= e_mod.source < len(gly.nodes) and 0 <= e_mod.target < len(gly.nodes):
                            s_m = gly.nodes[e_mod.source]
                            t_m = gly.nodes[e_mod.target]
                            if (
                                s_m.is_modifier
                                and e_mod.source not in n_ac_mods
                                and s_m.sugar in m_targets
                                and not t_m.is_modifier
                            ):
                                m_key = (
                                    int(s_m.sugar),
                                    int(t_m.sugar),
                                    int(e_mod.edge_type.chemical_int),
                                )
                                for t_cid in (cid, prior.fine_to_med.get(cid, 0)):
                                    if t_cid > 0:
                                        mod_spec_acc[t_cid][m_key] += 1.0

            core_sugars = {nd.sugar for nd in gly.nodes if not nd.is_modifier}
            for g_node in loc.nodes:
                if g_node.role in precursor_roles and g_node.cluster_id > 0:
                    cid = int(g_node.cluster_id)
                    compat = helper.registry.get_rule(g_node.role).compatible_sugars
                    cand_s = (core_sugars & compat) if compat else core_sugars
                    spec_s = (cand_s - housekeeping_ids) or cand_s
                    if spec_s:
                        w_p = 2.0 if len(spec_s) == 1 else (1.0 / len(spec_s))
                        for t_cid in (cid, prior.fine_to_med.get(cid, 0)):
                            if t_cid > 0:
                                for s_idx in spec_s:
                                    prec_sugar_acc[t_cid][int(s_idx)] += w_p

            cyc = gly.cycle_nodes()
            cand_nodes = cyc if cyc else tuple(idx for idx, nd in enumerate(gly.nodes) if not nd.is_modifier)
            cyc_sugars = {gly.nodes[idx].sugar for idx in cand_nodes}
            canon_in_cyc = cyc_sugars & canon_init_ids
            target_sugars = canon_in_cyc if canon_in_cyc else cyc_sugars
            if not target_sugars:
                continue

            w_init = 3.0 if len(target_sugars) == 1 else (1.0 / len(target_sugars))
            for g_node in loc.nodes:
                if g_node.role == GeneRole.INITIATOR and g_node.cluster_id > 0:
                    for s_idx in target_sugars:
                        init_sugar_acc[int(g_node.cluster_id)][int(s_idx)] += w_init

        prior.cluster_record_counts = {c: len(recs) for c, recs in cluster_rec_sets.items()}
        prior.init_cluster_sugar = {c: dict(cnts) for c, cnts in init_sugar_acc.items()}
        prior.precursor_cluster_sugar = {c: dict(cnts) for c, cnts in prec_sugar_acc.items()}
        prior.modifier_cluster_spec = {c: dict(cnts) for c, cnts in mod_spec_acc.items()}

        rotated_glycans: list[Glycan] = []
        locus_list: list[Locus] = []
        bigram_acc: Counter[tuple[tuple[int, ...], tuple[int, ...]]] = Counter()

        for i in range(n):
            gly = glycans[i]
            loc = raw_locus_list[i]
            arch = LocusArchetype(int(archetypes[i]))
            rep = bool(is_repeating[i])
            if len(gly.nodes) > 0 and rep:
                gly = helper.resolve_biological_reducing_end(
                    gly,
                    loc if len(loc.nodes) > 0 else None,
                    archetype=arch,
                    pan_prior=prior,
                )
            if len(loc.nodes) > 0 and len(gly.nodes) > 0:
                loc = helper.rescue_cassette_roles(
                    loc,
                    gly,
                    archetype=arch,
                    is_repeating=rep,
                    pan_cluster_counts=prior.cluster_record_counts,
                )
                for g_node in loc.nodes:
                    cid = int(g_node.cluster_id)
                    if cid > 0:
                        cluster_rec_sets[cid].add(i)
                        mc = prior.fine_to_med.get(cid, 0)
                        if mc > 0:
                            cluster_rec_sets[mc].add(i)

            rotated_glycans.append(gly)
            locus_list.append(loc)

            # Accumulate global Pan-Glycan consecutive linkage bigrams across ALL glycans (paired & unpaired)
            core_e = [
                k
                for k, e in enumerate(gly.edges)
                if not gly.nodes[e.source].is_modifier and not gly.nodes[e.target].is_modifier
            ]
            for k1 in core_e:
                e1 = gly.edges[k1]
                _, dl1, _, _ = _edge_signatures(gly, k1)
                for k2 in core_e:
                    if k1 == k2:
                        continue
                    e2 = gly.edges[k2]
                    if e1.source == e2.target or e1.target == e2.source:
                        _, dl2, _, _ = _edge_signatures(gly, k2)
                        bigram_acc[(dl1, dl2)] += 1

        prior.cluster_record_counts = {c: len(recs) for c, recs in cluster_rec_sets.items()}
        prior.pan_glycan_bigram_counts = dict(bigram_acc)
        prior.rescued_loci = locus_list
        if prior.classifier_suite is not None:
            prior.classifier_suite.precompute_for_loci(locus_list)

        # 3. Iterative EM across all paired records to refine GT cluster_id -> linkage & syntenic pair priors
        paired_indices = [i for i in range(n) if len(locus_list[i].nodes) > 0 and len(rotated_glycans[i].nodes) > 0]
        if not paired_indices:
            return prior, rotated_glycans

        cost_strat = BiosyntheticCostStrategy(rule_registry=helper.registry, pan_prior=prior)

        for _em_round in range(max(1, em_iterations)):
            sig_full_acc: dict[int, dict[tuple[int, ...], float]] = defaultdict(lambda: defaultdict(float))
            sig_dl_acc: dict[int, dict[tuple[int, ...], float]] = defaultdict(lambda: defaultdict(float))
            sig_da_acc: dict[int, dict[tuple[int, ...], float]] = defaultdict(lambda: defaultdict(float))
            mech_acc: dict[int, dict[int, float]] = defaultdict(lambda: defaultdict(float))
            syn_pair_acc: dict[tuple[int, int], dict[tuple[tuple[int, ...], tuple[int, ...]], float]] = defaultdict(
                lambda: defaultdict(float)
            )
            syn_pair_recs: dict[tuple[int, int], set[int]] = defaultdict(set)
            init_step1_acc: dict[tuple[int, int], dict[tuple[int, ...], float]] = defaultdict(
                lambda: defaultdict(float)
            )
            prec_donor_acc: dict[tuple[int, int], dict[int, float]] = defaultdict(lambda: defaultdict(float))

            for i in paired_indices:
                loc = locus_list[i]
                gly = rotated_glycans[i]
                n_res = len(gly.nodes)
                n_edges = len(gly.edges)
                if n_edges == 0:
                    continue

                link_genes = [
                    g_idx
                    for g_idx, g_node in enumerate(loc.nodes)
                    if g_node.role in (GeneRole.GLYCOSYLTRANSFERASE, GeneRole.POLYMERASE)
                ]
                core_edges = [
                    k
                    for k, e in enumerate(gly.edges)
                    if not gly.nodes[e.source].is_modifier and not gly.nodes[e.target].is_modifier
                ]
                if not link_genes or not core_edges:
                    continue

                _, loc_labels = loc.as_graph(as_bipartite=True)
                _, gly_labels = gly.as_graph(as_bipartite=True)
                c_mat = cost_strat.compute_cost(loc_labels, gly_labels, graph_a=loc, graph_b=gly)

                bip_cols = [n_res + k for k in core_edges]
                raw_sub = c_mat[np.ix_(link_genes, bip_cols)]
                sub_c = np.where(np.isfinite(raw_sub) & (raw_sub < 1e4), raw_sub, 10.0)
                r_ind, c_ind = linear_sum_assignment(sub_c)

                red_idx = gly.reducing_end_index
                init_genes_in_loc = [
                    (idx_g, int(nd.cluster_id))
                    for idx_g, nd in enumerate(loc.nodes)
                    if nd.role == GeneRole.INITIATOR and nd.cluster_id > 0
                ]
                prec_genes_in_loc = [
                    (idx_g, int(nd.cluster_id))
                    for idx_g, nd in enumerate(loc.nodes)
                    if nd.role in precursor_roles and nd.cluster_id > 0
                ]

                gt_gene_to_edge: dict[int, tuple[int, float]] = {}
                n_matched = len(r_ind)
                for idx_m in range(n_matched):
                    r_m = int(r_ind[idx_m])
                    c_m = int(c_ind[idx_m])
                    if sub_c[r_m, c_m] >= 9.0:
                        continue
                    g_idx = link_genes[r_m]
                    k_loc = core_edges[c_m]
                    c_id = int(loc.nodes[g_idx].cluster_id)
                    if c_id <= 0:
                        continue

                    c_mm = float(sub_c[r_m, c_m])
                    sig_m = _edge_signatures(gly, k_loc)[1]
                    alt_sum = 0.0
                    for idx_o in range(n_matched):
                        if idx_o == idx_m:
                            continue
                        r_o = int(r_ind[idx_o])
                        c_o = int(c_ind[idx_o])
                        sig_o = _edge_signatures(gly, core_edges[c_o])[1]
                        if sig_m and sig_m == sig_o:
                            continue
                        c_oo = float(sub_c[r_o, c_o])
                        delta_sw = max(0.0, (float(sub_c[r_m, c_o]) + float(sub_c[r_o, c_m])) - (c_mm + c_oo))
                        alt_sum += float(np.exp(-min(delta_sw / 0.12, 30.0)))

                    w_assign = 1.0 / (1.0 + alt_sum)
                    gt_gene_to_edge[g_idx] = (k_loc, w_assign)

                    s_full, s_dl, s_da, mech = _edge_signatures(gly, k_loc)
                    for target_cid in (c_id, prior.fine_to_med.get(c_id, 0)):
                        if target_cid <= 0:
                            continue
                        sig_full_acc[target_cid][s_full] += w_assign
                        sig_dl_acc[target_cid][s_dl] += w_assign
                        sig_da_acc[target_cid][s_da] += w_assign
                        if mech > 0:
                            mech_acc[target_cid][mech] += w_assign

                    e_matched = gly.edges[k_loc]
                    donor_s = gly.nodes[e_matched.source].sugar
                    if e_matched.target == red_idx:
                        for idx_init, c_init in init_genes_in_loc:
                            if abs(g_idx - idx_init) <= 2:
                                for target_cid in (c_id, prior.fine_to_med.get(c_id, 0)):
                                    if target_cid > 0:
                                        init_step1_acc[(c_init, target_cid)][s_dl] += w_assign

                    for idx_prec, c_prec in prec_genes_in_loc:
                        if abs(g_idx - idx_prec) <= 3:
                            for target_cid in (c_id, prior.fine_to_med.get(c_id, 0)):
                                if target_cid > 0:
                                    prec_donor_acc[(c_prec, target_cid)][donor_s] += w_assign

                # Accumulate syntenic transition pairs among GT/Polymerase genes weighted by Pan-Glycan bigrams
                gt_indices = sorted(gt_gene_to_edge.keys())
                for pos_a in range(len(gt_indices) - 1):
                    g_a = gt_indices[pos_a]
                    g_b = gt_indices[pos_a + 1]
                    if g_b - g_a > 3:
                        continue
                    c_a = int(loc.nodes[g_a].cluster_id)
                    c_b = int(loc.nodes[g_b].cluster_id)
                    if c_a <= 0 or c_b <= 0:
                        continue
                    k_a, w_a = gt_gene_to_edge[g_a]
                    k_b, w_b = gt_gene_to_edge[g_b]
                    if k_a == k_b:
                        continue
                    e_a = gly.edges[k_a]
                    e_b = gly.edges[k_b]
                    if e_a.target == e_b.source or e_a.source == e_b.target:
                        _, dl_a, _, _ = _edge_signatures(gly, k_a)
                        _, dl_b, _, _ = _edge_signatures(gly, k_b)
                        bg_cnt = prior.pan_glycan_bigram_counts.get((dl_a, dl_b), 0)
                        bg_mult = 1.0 + 0.18 * float(np.log1p(bg_cnt))
                        for ca_tier, cb_tier in (
                            (c_a, c_b),
                            (prior.fine_to_med.get(c_a, 0), prior.fine_to_med.get(c_b, 0)),
                        ):
                            if ca_tier > 0 and cb_tier > 0:
                                syn_pair_acc[(ca_tier, cb_tier)][(dl_a, dl_b)] += w_a * w_b * bg_mult
                                syn_pair_recs[(ca_tier, cb_tier)].add(i)

            prior.cluster_sig_full = {c: dict(m) for c, m in sig_full_acc.items()}
            prior.cluster_sig_dl = {c: dict(m) for c, m in sig_dl_acc.items()}
            prior.cluster_sig_da = {c: dict(m) for c, m in sig_da_acc.items()}
            prior.cluster_mech = {c: dict(m) for c, m in mech_acc.items()}
            prior.syntenic_pair_dl = {p: dict(m) for p, m in syn_pair_acc.items()}
            prior.syntenic_pair_record_counts = {p: len(rs) for p, rs in syn_pair_recs.items()}
            prior.init_adj_gt_step1 = {p: dict(m) for p, m in init_step1_acc.items()}
            prior.precursor_gt_donor = {p: dict(m) for p, m in prec_donor_acc.items()}

        return prior, rotated_glycans

preferred_init_sugar

preferred_init_sugar(
    cluster_id: int,
    candidate_sugars: set[int],
    aa_seq: bytes | None = None,
) -> int | None

Return the highest-scoring initiating sugar index in candidate_sugars for cluster_id, or None.

Source code in src/kbank/biosynthesis/align.py
def preferred_init_sugar(
    self,
    cluster_id: int,
    candidate_sugars: set[int],
    aa_seq: bytes | None = None,
) -> int | None:
    """Return the highest-scoring initiating sugar index in `candidate_sugars` for `cluster_id`, or `None`."""
    if (
        cluster_id > 0
        and cluster_id in self.init_cluster_sugar
        and self.cluster_record_counts.get(cluster_id, 0) >= 2
    ):
        s_map = self.init_cluster_sugar[cluster_id]
        best_s: int | None = None
        best_val = 0.0
        for s_idx in candidate_sugars:
            val = s_map.get(s_idx, 0.0)
            if val > best_val:
                best_val = val
                best_s = s_idx
        if best_s is not None and best_val >= 0.5:
            return best_s
    if self.classifier_suite is not None:
        clf_s, clf_p = self.classifier_suite.preferred_init_sugar(cluster_id, candidate_sugars, aa_seq=aa_seq)
        if clf_s is not None and clf_p >= 0.40:
            return clf_s
    if cluster_id > 0 and cluster_id in self.init_cluster_sugar:
        s_map = self.init_cluster_sugar[cluster_id]
        best_s = None
        best_val = 0.0
        for s_idx in candidate_sugars:
            val = s_map.get(s_idx, 0.0)
            if val > best_val:
                best_val = val
                best_s = s_idx
        return best_s if best_val >= 0.5 else None
    return None

preferred_precursor_sugar

preferred_precursor_sugar(
    cluster_id: int, candidate_sugars: set[int]
) -> int | None

Return the multi-record consensus sugar synthesized by precursor cluster_id among candidate_sugars.

Source code in src/kbank/biosynthesis/align.py
def preferred_precursor_sugar(self, cluster_id: int, candidate_sugars: set[int]) -> int | None:
    """Return the multi-record consensus sugar synthesized by precursor `cluster_id` among `candidate_sugars`."""
    for c_tier in (cluster_id, self.fine_to_med.get(cluster_id, 0)):
        if c_tier <= 0 or self.cluster_record_counts.get(c_tier, 0) < 2:
            continue
        s_map = self.precursor_cluster_sugar.get(c_tier, {})
        if not s_map:
            continue
        cand_w = {s_idx: s_map.get(s_idx, 0.0) for s_idx in candidate_sugars if s_map.get(s_idx, 0.0) > 0.0}
        if not cand_w:
            continue
        tot_cand = sum(cand_w.values())
        best_s, best_w = max(cand_w.items(), key=lambda kv: kv[1])
        if best_w >= 1.5 and best_w >= 0.55 * tot_cand:
            return best_s
    return None

has_init_consensus

has_init_consensus(
    cluster_id: int,
    sugar_idx: int,
    aa_seq: bytes | None = None,
) -> bool

Return True if cluster_id has multi-record pan-graph or classifier consensus for initiating on sugar_idx.

Source code in src/kbank/biosynthesis/align.py
def has_init_consensus(self, cluster_id: int, sugar_idx: int, aa_seq: bytes | None = None) -> bool:
    """Return True if `cluster_id` has multi-record pan-graph or classifier consensus for initiating on `sugar_idx`."""
    if cluster_id > 0 and self.cluster_record_counts.get(cluster_id, 0) >= 2:
        s_map = self.init_cluster_sugar.get(cluster_id, {})
        tot = sum(s_map.values())
        if tot > 0 and s_map.get(sugar_idx, 0.0) >= 0.45 * tot:
            return True
    if self.classifier_suite is not None:
        clf_s, clf_p = self.classifier_suite.preferred_init_sugar(cluster_id, {sugar_idx}, aa_seq=aa_seq)
        if clf_s == sugar_idx and clf_p >= 0.65:
            return True
    return False

gt_edge_bonus

gt_edge_bonus(
    cluster_id: int,
    glycan: Glycan,
    edge_idx: int,
    adj_clusters: Sequence[tuple[int, int]] = (),
    adj_init_clusters: Sequence[int] = (),
    adj_precursor_clusters: Sequence[int] = (),
    adj_precursor_sugars: set[int]
    | frozenset[int] = frozenset(),
    aa_seq: bytes | None = None,
) -> tuple[float, bool]

Compute cross-archetype pan-graph homology & synteny cost bonus and whether pan-consensus holds.

Source code in src/kbank/biosynthesis/align.py
def gt_edge_bonus(
    self,
    cluster_id: int,
    glycan: Glycan,
    edge_idx: int,
    adj_clusters: Sequence[tuple[int, int]] = (),
    adj_init_clusters: Sequence[int] = (),
    adj_precursor_clusters: Sequence[int] = (),
    adj_precursor_sugars: set[int] | frozenset[int] = frozenset(),
    aa_seq: bytes | None = None,
) -> tuple[float, bool]:
    """Compute cross-archetype pan-graph homology & synteny cost bonus and whether pan-consensus holds."""
    sig_full, sig_dl, sig_da, mech = _edge_signatures(glycan, edge_idx)
    bonus = 0.0
    has_consensus = False

    red_idx = glycan.reducing_end_index
    wzy_k, _, _ = glycan.polymer_edge_and_assembly_depths(red_idx)
    all_core = [
        k
        for k, e in enumerate(glycan.edges)
        if not glycan.nodes[e.source].is_modifier and not glycan.nodes[e.target].is_modifier
    ]
    intra_core = [k for k in all_core if k != wzy_k]
    cand_edges = intra_core if (intra_core and edge_idx != wzy_k) else all_core

    for c_tier, tier_scale in ((cluster_id, 1.0), (self.fine_to_med.get(cluster_id, 0), 0.72)):
        if c_tier <= 0 or self.cluster_record_counts.get(c_tier, 0) < 2:
            continue

        full_map = self.cluster_sig_full.get(c_tier, {})
        dl_map = self.cluster_sig_dl.get(c_tier, {})
        da_map = self.cluster_sig_da.get(c_tier, {})

        gly_full_w = {
            _edge_signatures(glycan, k)[0]: full_map.get(_edge_signatures(glycan, k)[0], 0.0) for k in cand_edges
        }
        gly_dl_w = {
            _edge_signatures(glycan, k)[1]: dl_map.get(_edge_signatures(glycan, k)[1], 0.0) for k in cand_edges
        }
        gly_da_w = {
            _edge_signatures(glycan, k)[2]: da_map.get(_edge_signatures(glycan, k)[2], 0.0) for k in cand_edges
        }

        w_full = gly_full_w.get(sig_full, 0.0)
        max_gly_full = max(gly_full_w.values(), default=0.0)
        other_full = max((v for k_s, v in gly_full_w.items() if k_s != sig_full), default=0.0)

        w_dl = gly_dl_w.get(sig_dl, 0.0)
        max_gly_dl = max(gly_dl_w.values(), default=0.0)
        other_dl = max((v for k_s, v in gly_dl_w.items() if k_s != sig_dl), default=0.0)

        w_da = gly_da_w.get(sig_da, 0.0)
        max_gly_da = max(gly_da_w.values(), default=0.0)
        other_da = max((v for k_s, v in gly_da_w.items() if k_s != sig_da), default=0.0)

        if w_full >= 0.60 and w_full == max_gly_full and w_full >= other_full * 1.20 and w_da >= max_gly_da * 0.65:
            bonus -= 0.13 * tier_scale
            has_consensus = True
        elif max_gly_full >= 0.85 and w_full < max_gly_full * 0.50:
            bonus += 0.09 * tier_scale

        if w_dl >= 0.60 and w_dl == max_gly_dl and w_dl >= other_dl * 1.20 and w_da >= max_gly_da * 0.65:
            bonus -= 0.11 * tier_scale
            has_consensus = True
        elif max_gly_dl >= 0.85 and w_dl < max_gly_dl * 0.50:
            bonus += 0.11 * tier_scale

        if w_da >= 0.65 and w_da == max_gly_da and w_da >= other_da * 1.20:
            bonus -= 0.08 * tier_scale
            has_consensus = True
        elif max_gly_da >= 0.90 and w_da < max_gly_da * 0.45:
            bonus += 0.16 * tier_scale

        mech_map = self.cluster_mech.get(c_tier, {})
        tot_mech = sum(mech_map.values())
        if mech > 0 and tot_mech >= 1.2:
            frac_mech = mech_map.get(mech, 0.0) / tot_mech
            if frac_mech >= 0.68:
                bonus -= 0.03 * tier_scale * frac_mech
            elif frac_mech <= 0.18:
                bonus += 0.12 * tier_scale

    e_curr = glycan.edges[edge_idx]
    donor_s = glycan.nodes[e_curr.source].sugar

    # Signal 1: (INITIATOR_cluster -> GT_cluster) step-1 anchoring onto the biological reducing end
    if e_curr.target == red_idx and cluster_id > 0:
        step1_edges = [k for k in cand_edges if glycan.edges[k].target == red_idx]
        for c_init in adj_init_clusters:
            for c_tier in (cluster_id, self.fine_to_med.get(cluster_id, 0)):
                if c_tier > 0 and self.cluster_record_counts.get(c_tier, 0) >= 2:
                    step1_w = self.init_adj_gt_step1.get((c_init, c_tier), {}).get(sig_dl, 0.0)
                    if step1_w >= 0.85:
                        bonus -= 0.07
                        has_consensus = True
                        break
            if self.cluster_record_counts.get(c_init, 0) >= 2 and len(step1_edges) == 1:
                bonus -= 0.055
                has_consensus = True
                break

    # Signal 2: (Precursor_Operon -> GT_cluster) syntenic donor-sugar coupling
    if cluster_id > 0:
        for c_prec in adj_precursor_clusters:
            for c_tier in (cluster_id, self.fine_to_med.get(cluster_id, 0)):
                if c_tier <= 0 or self.cluster_record_counts.get(c_tier, 0) < 2:
                    continue
                prec_map = self.precursor_gt_donor.get((c_prec, c_tier), {})
                if prec_map:
                    max_prec_w = max(prec_map.values(), default=0.0)
                    w_prec = prec_map.get(donor_s, 0.0)
                    if w_prec >= 0.85 and w_prec >= max_prec_w * 0.80:
                        bonus -= 0.06
                        has_consensus = True
                        break

    if adj_precursor_sugars and donor_s in adj_precursor_sugars:
        bonus -= 0.055
        matching_cand_donors = {
            glycan.nodes[glycan.edges[k].source].sugar
            for k in cand_edges
            if glycan.nodes[glycan.edges[k].source].sugar in adj_precursor_sugars
        }
        matching_cand_sigs = {
            _edge_signatures(glycan, k)[1]
            for k in cand_edges
            if glycan.nodes[glycan.edges[k].source].sugar == donor_s
        }
        if len(matching_cand_donors) == 1 and len(matching_cand_sigs) == 1:
            has_consensus = True

    # Signal 3: Syntenic (GT_A -> GT_B) x (Linkage_1 -> Linkage_2) bigrams weighted by Pan-Glycan network
    if cluster_id > 0:
        for c_other, dir_flag in adj_clusters:
            if c_other <= 0:
                continue
            for c_o_tier, c_s_tier in (
                (c_other, cluster_id),
                (self.fine_to_med.get(c_other, 0), self.fine_to_med.get(cluster_id, 0)),
            ):
                if c_o_tier <= 0 or c_s_tier <= 0:
                    continue
                pair_key = (c_o_tier, c_s_tier) if dir_flag < 0 else (c_s_tier, c_o_tier)
                if self.syntenic_pair_record_counts.get(pair_key, 0) < 2:
                    continue
                pair_map = self.syntenic_pair_dl.get(pair_key, {})
                if not pair_map:
                    continue
                for k_other in cand_edges:
                    if k_other == edge_idx:
                        continue
                    e_other = glycan.edges[k_other]
                    if e_curr.target == e_other.source or e_curr.source == e_other.target:
                        _, sig_dl_other, _, _ = _edge_signatures(glycan, k_other)
                        sig_pair = (sig_dl_other, sig_dl) if dir_flag < 0 else (sig_dl, sig_dl_other)
                        sup = pair_map.get(sig_pair, 0.0)
                        if sup >= 0.85:
                            bonus -= 0.06
                            has_consensus = True
                            break

            # Cross-network anchor: if neighbor GT c_other has multi-record consensus on an adjacent edge k_other
            # and (dl_other, dl_curr) is a recurring disaccharide motif in the global Pan-Glycan network
            if not has_consensus and self.cluster_record_counts.get(c_other, 0) >= 2:
                o_dl_map = self.cluster_sig_dl.get(c_other, {})
                if o_dl_map:
                    cand_o_w = {
                        _edge_signatures(glycan, k)[1]: o_dl_map.get(_edge_signatures(glycan, k)[1], 0.0)
                        for k in cand_edges
                    }
                    max_o_w = max(cand_o_w.values(), default=0.0)
                    if max_o_w >= 0.80:
                        for k_other in cand_edges:
                            if k_other == edge_idx:
                                continue
                            _, dl_other, _, _ = _edge_signatures(glycan, k_other)
                            if cand_o_w.get(dl_other, 0.0) < max_o_w:
                                continue
                            e_other = glycan.edges[k_other]
                            if e_curr.target == e_other.source or e_curr.source == e_other.target:
                                sig_pair = (dl_other, sig_dl) if dir_flag < 0 else (sig_dl, dl_other)
                                bg_cnt = self.pan_glycan_bigram_counts.get(sig_pair, 0)
                                if bg_cnt >= 2:
                                    bonus -= 0.045
                                    adj_sigs = {
                                        _edge_signatures(glycan, k_c)[1]
                                        for k_c in cand_edges
                                        if k_c != k_other
                                        and (
                                            glycan.edges[k_c].target == e_other.source
                                            or glycan.edges[k_c].source == e_other.target
                                        )
                                    }
                                    if len(adj_sigs) == 1:
                                        has_consensus = True
                                    break

    # Hybrid fallback: for singleton/unresolved GT clusters or unclustered genes (cluster_id <= 0),
    # query the factorized 1D-CNN BiosyntheticClassifierSuite (donor, linkage, mechanism)
    if self.classifier_suite is not None and (
        not has_consensus or self.cluster_record_counts.get(cluster_id, 0) < 2
    ):
        clf_b, clf_cons = self.classifier_suite.gt_edge_score(
            cluster_id,
            glycan,
            edge_idx,
            aa_seq=aa_seq,
        )
        scale_clf = 1.0 if self.cluster_record_counts.get(cluster_id, 0) < 2 else 0.55
        bonus += scale_clf * clf_b
        if clf_cons:
            has_consensus = True

    return float(np.clip(bonus, -0.32, 0.45)), has_consensus

fit classmethod

fit(
    glycans: Sequence[Glycan],
    loci: Loci | Sequence[Locus],
    archetypes: Sequence[LocusArchetype | int] | ndarray,
    is_repeating: Sequence[bool] | ndarray,
    *,
    assigner: BiosyntheticAssigner | None = None,
    em_iterations: int = 5,
    classifier_suite: Any | None = None,
) -> tuple[Self, list[Glycan]]

Fit a PanGraphConsensusPrior across all paired records and rotate glycans to biological reducing ends.

Source code in src/kbank/biosynthesis/align.py
 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
@classmethod
def fit(
    cls,
    glycans: Sequence[Glycan],
    loci: Loci | Sequence[Locus],
    archetypes: Sequence[LocusArchetype | int] | np.ndarray,
    is_repeating: Sequence[bool] | np.ndarray,
    *,
    assigner: BiosyntheticAssigner | None = None,
    em_iterations: int = 5,
    classifier_suite: Any | None = None,
) -> tuple[Self, list[Glycan]]:
    """Fit a `PanGraphConsensusPrior` across all paired records and rotate glycans to biological reducing ends."""
    from collections import Counter, defaultdict

    from scipy.optimize import linear_sum_assignment

    from kbank.biosynthesis.classifier import BiosyntheticClassifierSuite
    from kbank.glycan import Glycans

    prior = cls()
    suite = classifier_suite if classifier_suite is not None else BiosyntheticClassifierSuite.pretrained()
    if suite is not None and suite.is_fitted:
        prior.classifier_suite = suite
    n = len(glycans)
    if n == 0:
        return prior, list(glycans)

    loci_batch = loci if isinstance(loci, Loci) else Loci.from_list(list(loci))
    glycans_batch = Glycans.from_list(list(glycans))

    # 1. Extract pan-locus and pan-glycan consensus transition topologies via .as_pan_graph()
    if len(loci_batch.node_types) > 0:
        role_cluster_mask = int(GeneNode.mask("ROLE_ID", "CLUSTER_ID"))
        pan_l_adj, pan_l_nodes, _ = loci_batch.as_pan_graph(
            node_mask=role_cluster_mask,
            canonicalize_strands=False,
        )
        coo_l = pan_l_adj.tocoo()
        for r_i, c_i, cnt in zip(coo_l.row, coo_l.col, coo_l.data, strict=False):
            c_src = int(GeneNode(int(pan_l_nodes[int(r_i)])).cluster_id)
            c_tgt = int(GeneNode(int(pan_l_nodes[int(c_i)])).cluster_id)
            if c_src > 0 and c_tgt > 0:
                prior.pan_locus_edge_counts[(c_src, c_tgt)] = prior.pan_locus_edge_counts.get(
                    (c_src, c_tgt), 0
                ) + int(cnt)

    if len(glycans_batch.node_types) > 0:
        _, pan_g_nodes, pan_g_counts = glycans_batch.as_pan_graph()
        for g_n, g_c in zip(pan_g_nodes, pan_g_counts, strict=False):
            prior.pan_glycan_node_counts[int(g_n)] = int(g_c)

    # Build Medium/Coarse superfamily cluster hierarchy when protein sequences are available (by_role=False)
    cluster_rec_sets: dict[int, set[int]] = defaultdict(set)
    if (
        len(loci_batch.node_types) > 0
        and len(loci_batch.genes) == len(loci_batch.node_types)
        and bool(np.any(loci_batch.cluster_ids > 0))
    ):
        med_loci = loci_batch.cluster_genes(
            mode="mcl",
            min_identity=20.0,
            min_bitscore=35.0,
            inflation=1.6,
            by_role=False,
        )
        for fine_c, med_c in zip(loci_batch.cluster_ids, med_loci.cluster_ids, strict=False):
            fc = int(fine_c)
            mc = int(med_c)
            if fc > 0 and mc > 0:
                prior.fine_to_med[fc] = mc + 100000

    # 2. Pass 0: Accumulate INITIATOR & Precursor cluster -> sugar specificity & rotate biological reducing ends
    canon_init_names = {
        b"GlcNAc",
        b"GalNAc",
        b"QuiNAc",
        b"FucNAc",
        b"FucN4N",
        b"QuiN4N",
        b"Bac",
        b"Gal",
        b"Glc",
    }
    canon_init_ids = {Sugars.name_to_index[nm] for nm in canon_init_names if nm in Sugars.name_to_index}
    housekeeping_names = {b"Glc", b"Gal", b"GlcNAc"}
    housekeeping_ids = {Sugars.name_to_index[nm] for nm in housekeeping_names if nm in Sugars.name_to_index}
    precursor_roles = {
        GeneRole.OXIDOREDUCTASE,
        GeneRole.EPIMERASE,
        GeneRole.AMINOTRANSFERASE,
        GeneRole.ALDOLASE,
        GeneRole.NUCLEOTIDASE,
        GeneRole.NUCLEOTIDYLTRANSFERASE,
    }
    helper = assigner if assigner is not None else BiosyntheticAssigner()
    init_sugar_acc: dict[int, dict[int, float]] = defaultdict(lambda: defaultdict(float))
    prec_sugar_acc: dict[int, dict[int, float]] = defaultdict(lambda: defaultdict(float))
    mod_spec_acc: dict[int, dict[tuple[int, int, int], float]] = defaultdict(lambda: defaultdict(float))
    mod_roles_set = {
        GeneRole.ACETYLTRANSFERASE,
        GeneRole.PYRUVYLTRANSFERASE,
        GeneRole.METHYLTRANSFERASE,
        GeneRole.PHOSPHOTRANSFERASE,
        GeneRole.FORMYLTRANSFERASE,
        GeneRole.ALANINE_TRANSFERASE,
    }

    raw_locus_list = [loci_batch[i] for i in range(n)]
    for i in range(n):
        loc = raw_locus_list[i]
        gly = glycans[i]
        if len(loc.nodes) == 0 or len(gly.nodes) == 0:
            continue
        for g_node in loc.nodes:
            cid = int(g_node.cluster_id)
            if cid > 0:
                cluster_rec_sets[cid].add(i)
                mc = prior.fine_to_med.get(cid, 0)
                if mc > 0:
                    cluster_rec_sets[mc].add(i)

        n_ac_mods, _ = helper._find_n_acetyl_nodes(gly)
        for g_node in loc.nodes:
            if g_node.role in mod_roles_set and g_node.cluster_id > 0:
                cid = int(g_node.cluster_id)
                m_targets = helper.registry.get_rule(g_node.role).target_modifiers
                for e_mod in gly.edges:
                    if 0 <= e_mod.source < len(gly.nodes) and 0 <= e_mod.target < len(gly.nodes):
                        s_m = gly.nodes[e_mod.source]
                        t_m = gly.nodes[e_mod.target]
                        if (
                            s_m.is_modifier
                            and e_mod.source not in n_ac_mods
                            and s_m.sugar in m_targets
                            and not t_m.is_modifier
                        ):
                            m_key = (
                                int(s_m.sugar),
                                int(t_m.sugar),
                                int(e_mod.edge_type.chemical_int),
                            )
                            for t_cid in (cid, prior.fine_to_med.get(cid, 0)):
                                if t_cid > 0:
                                    mod_spec_acc[t_cid][m_key] += 1.0

        core_sugars = {nd.sugar for nd in gly.nodes if not nd.is_modifier}
        for g_node in loc.nodes:
            if g_node.role in precursor_roles and g_node.cluster_id > 0:
                cid = int(g_node.cluster_id)
                compat = helper.registry.get_rule(g_node.role).compatible_sugars
                cand_s = (core_sugars & compat) if compat else core_sugars
                spec_s = (cand_s - housekeeping_ids) or cand_s
                if spec_s:
                    w_p = 2.0 if len(spec_s) == 1 else (1.0 / len(spec_s))
                    for t_cid in (cid, prior.fine_to_med.get(cid, 0)):
                        if t_cid > 0:
                            for s_idx in spec_s:
                                prec_sugar_acc[t_cid][int(s_idx)] += w_p

        cyc = gly.cycle_nodes()
        cand_nodes = cyc if cyc else tuple(idx for idx, nd in enumerate(gly.nodes) if not nd.is_modifier)
        cyc_sugars = {gly.nodes[idx].sugar for idx in cand_nodes}
        canon_in_cyc = cyc_sugars & canon_init_ids
        target_sugars = canon_in_cyc if canon_in_cyc else cyc_sugars
        if not target_sugars:
            continue

        w_init = 3.0 if len(target_sugars) == 1 else (1.0 / len(target_sugars))
        for g_node in loc.nodes:
            if g_node.role == GeneRole.INITIATOR and g_node.cluster_id > 0:
                for s_idx in target_sugars:
                    init_sugar_acc[int(g_node.cluster_id)][int(s_idx)] += w_init

    prior.cluster_record_counts = {c: len(recs) for c, recs in cluster_rec_sets.items()}
    prior.init_cluster_sugar = {c: dict(cnts) for c, cnts in init_sugar_acc.items()}
    prior.precursor_cluster_sugar = {c: dict(cnts) for c, cnts in prec_sugar_acc.items()}
    prior.modifier_cluster_spec = {c: dict(cnts) for c, cnts in mod_spec_acc.items()}

    rotated_glycans: list[Glycan] = []
    locus_list: list[Locus] = []
    bigram_acc: Counter[tuple[tuple[int, ...], tuple[int, ...]]] = Counter()

    for i in range(n):
        gly = glycans[i]
        loc = raw_locus_list[i]
        arch = LocusArchetype(int(archetypes[i]))
        rep = bool(is_repeating[i])
        if len(gly.nodes) > 0 and rep:
            gly = helper.resolve_biological_reducing_end(
                gly,
                loc if len(loc.nodes) > 0 else None,
                archetype=arch,
                pan_prior=prior,
            )
        if len(loc.nodes) > 0 and len(gly.nodes) > 0:
            loc = helper.rescue_cassette_roles(
                loc,
                gly,
                archetype=arch,
                is_repeating=rep,
                pan_cluster_counts=prior.cluster_record_counts,
            )
            for g_node in loc.nodes:
                cid = int(g_node.cluster_id)
                if cid > 0:
                    cluster_rec_sets[cid].add(i)
                    mc = prior.fine_to_med.get(cid, 0)
                    if mc > 0:
                        cluster_rec_sets[mc].add(i)

        rotated_glycans.append(gly)
        locus_list.append(loc)

        # Accumulate global Pan-Glycan consecutive linkage bigrams across ALL glycans (paired & unpaired)
        core_e = [
            k
            for k, e in enumerate(gly.edges)
            if not gly.nodes[e.source].is_modifier and not gly.nodes[e.target].is_modifier
        ]
        for k1 in core_e:
            e1 = gly.edges[k1]
            _, dl1, _, _ = _edge_signatures(gly, k1)
            for k2 in core_e:
                if k1 == k2:
                    continue
                e2 = gly.edges[k2]
                if e1.source == e2.target or e1.target == e2.source:
                    _, dl2, _, _ = _edge_signatures(gly, k2)
                    bigram_acc[(dl1, dl2)] += 1

    prior.cluster_record_counts = {c: len(recs) for c, recs in cluster_rec_sets.items()}
    prior.pan_glycan_bigram_counts = dict(bigram_acc)
    prior.rescued_loci = locus_list
    if prior.classifier_suite is not None:
        prior.classifier_suite.precompute_for_loci(locus_list)

    # 3. Iterative EM across all paired records to refine GT cluster_id -> linkage & syntenic pair priors
    paired_indices = [i for i in range(n) if len(locus_list[i].nodes) > 0 and len(rotated_glycans[i].nodes) > 0]
    if not paired_indices:
        return prior, rotated_glycans

    cost_strat = BiosyntheticCostStrategy(rule_registry=helper.registry, pan_prior=prior)

    for _em_round in range(max(1, em_iterations)):
        sig_full_acc: dict[int, dict[tuple[int, ...], float]] = defaultdict(lambda: defaultdict(float))
        sig_dl_acc: dict[int, dict[tuple[int, ...], float]] = defaultdict(lambda: defaultdict(float))
        sig_da_acc: dict[int, dict[tuple[int, ...], float]] = defaultdict(lambda: defaultdict(float))
        mech_acc: dict[int, dict[int, float]] = defaultdict(lambda: defaultdict(float))
        syn_pair_acc: dict[tuple[int, int], dict[tuple[tuple[int, ...], tuple[int, ...]], float]] = defaultdict(
            lambda: defaultdict(float)
        )
        syn_pair_recs: dict[tuple[int, int], set[int]] = defaultdict(set)
        init_step1_acc: dict[tuple[int, int], dict[tuple[int, ...], float]] = defaultdict(
            lambda: defaultdict(float)
        )
        prec_donor_acc: dict[tuple[int, int], dict[int, float]] = defaultdict(lambda: defaultdict(float))

        for i in paired_indices:
            loc = locus_list[i]
            gly = rotated_glycans[i]
            n_res = len(gly.nodes)
            n_edges = len(gly.edges)
            if n_edges == 0:
                continue

            link_genes = [
                g_idx
                for g_idx, g_node in enumerate(loc.nodes)
                if g_node.role in (GeneRole.GLYCOSYLTRANSFERASE, GeneRole.POLYMERASE)
            ]
            core_edges = [
                k
                for k, e in enumerate(gly.edges)
                if not gly.nodes[e.source].is_modifier and not gly.nodes[e.target].is_modifier
            ]
            if not link_genes or not core_edges:
                continue

            _, loc_labels = loc.as_graph(as_bipartite=True)
            _, gly_labels = gly.as_graph(as_bipartite=True)
            c_mat = cost_strat.compute_cost(loc_labels, gly_labels, graph_a=loc, graph_b=gly)

            bip_cols = [n_res + k for k in core_edges]
            raw_sub = c_mat[np.ix_(link_genes, bip_cols)]
            sub_c = np.where(np.isfinite(raw_sub) & (raw_sub < 1e4), raw_sub, 10.0)
            r_ind, c_ind = linear_sum_assignment(sub_c)

            red_idx = gly.reducing_end_index
            init_genes_in_loc = [
                (idx_g, int(nd.cluster_id))
                for idx_g, nd in enumerate(loc.nodes)
                if nd.role == GeneRole.INITIATOR and nd.cluster_id > 0
            ]
            prec_genes_in_loc = [
                (idx_g, int(nd.cluster_id))
                for idx_g, nd in enumerate(loc.nodes)
                if nd.role in precursor_roles and nd.cluster_id > 0
            ]

            gt_gene_to_edge: dict[int, tuple[int, float]] = {}
            n_matched = len(r_ind)
            for idx_m in range(n_matched):
                r_m = int(r_ind[idx_m])
                c_m = int(c_ind[idx_m])
                if sub_c[r_m, c_m] >= 9.0:
                    continue
                g_idx = link_genes[r_m]
                k_loc = core_edges[c_m]
                c_id = int(loc.nodes[g_idx].cluster_id)
                if c_id <= 0:
                    continue

                c_mm = float(sub_c[r_m, c_m])
                sig_m = _edge_signatures(gly, k_loc)[1]
                alt_sum = 0.0
                for idx_o in range(n_matched):
                    if idx_o == idx_m:
                        continue
                    r_o = int(r_ind[idx_o])
                    c_o = int(c_ind[idx_o])
                    sig_o = _edge_signatures(gly, core_edges[c_o])[1]
                    if sig_m and sig_m == sig_o:
                        continue
                    c_oo = float(sub_c[r_o, c_o])
                    delta_sw = max(0.0, (float(sub_c[r_m, c_o]) + float(sub_c[r_o, c_m])) - (c_mm + c_oo))
                    alt_sum += float(np.exp(-min(delta_sw / 0.12, 30.0)))

                w_assign = 1.0 / (1.0 + alt_sum)
                gt_gene_to_edge[g_idx] = (k_loc, w_assign)

                s_full, s_dl, s_da, mech = _edge_signatures(gly, k_loc)
                for target_cid in (c_id, prior.fine_to_med.get(c_id, 0)):
                    if target_cid <= 0:
                        continue
                    sig_full_acc[target_cid][s_full] += w_assign
                    sig_dl_acc[target_cid][s_dl] += w_assign
                    sig_da_acc[target_cid][s_da] += w_assign
                    if mech > 0:
                        mech_acc[target_cid][mech] += w_assign

                e_matched = gly.edges[k_loc]
                donor_s = gly.nodes[e_matched.source].sugar
                if e_matched.target == red_idx:
                    for idx_init, c_init in init_genes_in_loc:
                        if abs(g_idx - idx_init) <= 2:
                            for target_cid in (c_id, prior.fine_to_med.get(c_id, 0)):
                                if target_cid > 0:
                                    init_step1_acc[(c_init, target_cid)][s_dl] += w_assign

                for idx_prec, c_prec in prec_genes_in_loc:
                    if abs(g_idx - idx_prec) <= 3:
                        for target_cid in (c_id, prior.fine_to_med.get(c_id, 0)):
                            if target_cid > 0:
                                prec_donor_acc[(c_prec, target_cid)][donor_s] += w_assign

            # Accumulate syntenic transition pairs among GT/Polymerase genes weighted by Pan-Glycan bigrams
            gt_indices = sorted(gt_gene_to_edge.keys())
            for pos_a in range(len(gt_indices) - 1):
                g_a = gt_indices[pos_a]
                g_b = gt_indices[pos_a + 1]
                if g_b - g_a > 3:
                    continue
                c_a = int(loc.nodes[g_a].cluster_id)
                c_b = int(loc.nodes[g_b].cluster_id)
                if c_a <= 0 or c_b <= 0:
                    continue
                k_a, w_a = gt_gene_to_edge[g_a]
                k_b, w_b = gt_gene_to_edge[g_b]
                if k_a == k_b:
                    continue
                e_a = gly.edges[k_a]
                e_b = gly.edges[k_b]
                if e_a.target == e_b.source or e_a.source == e_b.target:
                    _, dl_a, _, _ = _edge_signatures(gly, k_a)
                    _, dl_b, _, _ = _edge_signatures(gly, k_b)
                    bg_cnt = prior.pan_glycan_bigram_counts.get((dl_a, dl_b), 0)
                    bg_mult = 1.0 + 0.18 * float(np.log1p(bg_cnt))
                    for ca_tier, cb_tier in (
                        (c_a, c_b),
                        (prior.fine_to_med.get(c_a, 0), prior.fine_to_med.get(c_b, 0)),
                    ):
                        if ca_tier > 0 and cb_tier > 0:
                            syn_pair_acc[(ca_tier, cb_tier)][(dl_a, dl_b)] += w_a * w_b * bg_mult
                            syn_pair_recs[(ca_tier, cb_tier)].add(i)

        prior.cluster_sig_full = {c: dict(m) for c, m in sig_full_acc.items()}
        prior.cluster_sig_dl = {c: dict(m) for c, m in sig_dl_acc.items()}
        prior.cluster_sig_da = {c: dict(m) for c, m in sig_da_acc.items()}
        prior.cluster_mech = {c: dict(m) for c, m in mech_acc.items()}
        prior.syntenic_pair_dl = {p: dict(m) for p, m in syn_pair_acc.items()}
        prior.syntenic_pair_record_counts = {p: len(rs) for p, rs in syn_pair_recs.items()}
        prior.init_adj_gt_step1 = {p: dict(m) for p, m in init_step1_acc.items()}
        prior.precursor_gt_donor = {p: dict(m) for p, m in prec_donor_acc.items()}

    return prior, rotated_glycans

BiosyntheticCostStrategy

Bases: CostStrategy

Domain-specific cost strategy mapping GeneCluster nodes to Glycan bipartite nodes.

Methods:

Name Description
compute_cost

Compute (N_gene, N_glycan) pairwise biochemical + pan-graph + assembly-depth cost matrix.

Source code in src/kbank/biosynthesis/align.py
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
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
class BiosyntheticCostStrategy(CostStrategy):
    """Domain-specific cost strategy mapping `GeneCluster` nodes to `Glycan` bipartite nodes."""

    def __init__(
        self,
        rule_registry: BiosyntheticRuleRegistry | None = None,
        pan_prior: PanGraphConsensusPrior | None = None,
    ) -> None:
        """Initialize `BiosyntheticCostStrategy` with a `BiosyntheticRuleRegistry` and `PanGraphConsensusPrior`."""
        self.registry = rule_registry or BiosyntheticRuleRegistry()
        self.pan_prior = pan_prior

    def compute_cost(
        self,
        labels_a: np.ndarray[tuple[int], np.dtype[Any]],
        labels_b: np.ndarray[tuple[int], np.dtype[Any]],
        penalty: float = 1e6,
        graph_a: GraphModel | None = None,
        graph_b: GraphModel | None = None,
    ) -> np.ndarray[tuple[int, int], np.dtype[np.float64]]:
        """Compute `(N_gene, N_glycan)` pairwise biochemical + pan-graph + assembly-depth cost matrix."""
        is_swapped = isinstance(graph_a, Glycan) and isinstance(graph_b, Locus)
        if is_swapped:
            gene_labels, glycan_labels = labels_b, labels_a
            glycan_graph = graph_a
            gene_graph = graph_b
        else:
            gene_labels, glycan_labels = labels_a, labels_b
            glycan_graph = graph_b if isinstance(graph_b, Glycan) else None
            gene_graph = graph_a if isinstance(graph_a, Locus) else None

        gene_translations = (
            gene_graph.genes.translations if (isinstance(gene_graph, Locus) and len(gene_graph.genes) > 0) else None
        )

        n_genes = len(gene_labels)
        n_glycan = len(glycan_labels)
        cost = np.full((n_genes, n_glycan), np.inf, dtype=np.float64)

        parent_to_mods: dict[int, set[int]] = {j: set() for j in range(n_glycan)}
        reducing_end_indices: set[int] = set()
        linkage_to_donor_sugar: dict[int, int] = {}
        modifier_edge_indices: set[int] = set()
        n_acetyl_mod_nodes: set[int] = set()
        n_acetylated_sugar_nodes: set[int] = set()
        wzy_bipartite_idx: int | None = None
        edge_step_norm: dict[int, float] = {}
        n_res = 0

        if glycan_graph is not None:
            n_acetyl_mod_nodes, n_acetylated_sugar_nodes = BiosyntheticAssigner._find_n_acetyl_nodes(glycan_graph)
            n_res = len(glycan_graph.nodes)
            for j, res in enumerate(glycan_graph.nodes):
                if res.is_reducing_end and not res.is_modifier:
                    reducing_end_indices.add(j)
            if not reducing_end_indices and n_res > 0:
                for j, res in enumerate(glycan_graph.nodes):
                    if not res.is_modifier:
                        reducing_end_indices.add(j)
                        break

            red_idx = min(reducing_end_indices) if reducing_end_indices else 0
            wzy_edge_k, _, raw_edge_steps = glycan_graph.polymer_edge_and_assembly_depths(red_idx)
            if wzy_edge_k is not None:
                wzy_bipartite_idx = n_res + wzy_edge_k
            if raw_edge_steps:
                min_s = min(raw_edge_steps.values())
                max_s = max(raw_edge_steps.values())
                span_s = max(max_s - min_s, 1)
                for k_e, s_val in raw_edge_steps.items():
                    base_norm = (float(s_val - min_s) / float(span_s)) if max_s > min_s else 0.5
                    if 0 <= k_e < len(glycan_graph.edges) and glycan_graph.edges[k_e].edge_type.is_branch:
                        base_norm = min(1.0, base_norm + 0.25)
                    edge_step_norm[n_res + k_e] = base_norm

            for k, edge in enumerate(glycan_graph.edges):
                src, tgt = edge.source, edge.target
                s_res = glycan_graph.nodes[src]
                t_res = glycan_graph.nodes[tgt]
                bipartite_edge_idx = n_res + k
                if s_res.is_modifier and not t_res.is_modifier:
                    if src not in n_acetyl_mod_nodes:
                        parent_to_mods[tgt].add(s_res.sugar)
                    modifier_edge_indices.add(bipartite_edge_idx)
                elif t_res.is_modifier and not s_res.is_modifier:
                    if tgt not in n_acetyl_mod_nodes:
                        parent_to_mods[src].add(t_res.sugar)
                    modifier_edge_indices.add(bipartite_edge_idx)
                elif s_res.is_modifier and t_res.is_modifier:
                    modifier_edge_indices.add(bipartite_edge_idx)
                if bipartite_edge_idx < n_glycan:
                    linkage_to_donor_sugar[bipartite_edge_idx] = s_res.sugar
        else:
            for j, raw_g in enumerate(glycan_labels):
                val = int(raw_g)
                if not (val & TAG_EDGE):
                    res = ResidueType(val)
                    if res.is_reducing_end and not res.is_modifier:
                        reducing_end_indices.add(j)
                elif wzy_bipartite_idx is None and LinkageType(val).is_polymer:
                    wzy_bipartite_idx = j
            if not reducing_end_indices and n_glycan > 0 and not (int(glycan_labels[0]) & TAG_EDGE):
                reducing_end_indices.add(0)

        # Precompute GT order relative to INITIATOR in the locus for biosynthetic step tie-breaking
        precursor_roles = {
            GeneRole.OXIDOREDUCTASE,
            GeneRole.EPIMERASE,
            GeneRole.AMINOTRANSFERASE,
            GeneRole.ALDOLASE,
            GeneRole.NUCLEOTIDASE,
            GeneRole.NUCLEOTIDYLTRANSFERASE,
        }
        has_polymerase_in_locus = False
        init_gene_indices: list[int] = []
        gt_gene_indices: list[int] = []
        prec_gene_indices: list[int] = []
        gene_nodes_by_i: dict[int, GeneNode] = {}
        for i, raw_gene in enumerate(gene_labels):
            g_val = int(raw_gene)
            if g_val & TAG_EDGE:
                continue
            gn = GeneNode(g_val)
            gene_nodes_by_i[i] = gn
            if gn.role == GeneRole.POLYMERASE:
                has_polymerase_in_locus = True
            elif gn.role == GeneRole.INITIATOR:
                init_gene_indices.append(i)
            elif gn.role == GeneRole.GLYCOSYLTRANSFERASE:
                gt_gene_indices.append(i)
            elif gn.role in precursor_roles:
                prec_gene_indices.append(i)

        gt_order_norm: dict[int, float] = {}
        if gt_gene_indices:
            if init_gene_indices:
                anchor_i = init_gene_indices[0]
                ordered_gts = sorted(gt_gene_indices, key=lambda idx_g: (abs(idx_g - anchor_i), idx_g))
            else:
                ordered_gts = list(gt_gene_indices)
            n_gts = len(ordered_gts)
            for rank_idx, idx_g in enumerate(ordered_gts):
                gt_order_norm[idx_g] = (float(rank_idx) / float(n_gts - 1)) if n_gts > 1 else 0.5

        # Defer duplicate consecutive processive repeat edges from 1-to-1 Hungarian matching when intra_gt_edges > n_gts
        deferred_processive_cols: set[int] = set()
        if glycan_graph is not None and gt_gene_indices:
            intra_core_edges = [
                k
                for k, e in enumerate(glycan_graph.edges)
                if (n_res + k) not in modifier_edge_indices
                and ((n_res + k) != wzy_bipartite_idx or not has_polymerase_in_locus)
            ]
            excess_edges = len(intra_core_edges) - len(gt_gene_indices)
            if excess_edges > 0:
                for k1 in intra_core_edges:
                    if excess_edges <= 0:
                        break
                    e1 = glycan_graph.edges[k1]
                    sig1 = _edge_signatures(glycan_graph, k1)[0][:7]
                    for k2 in intra_core_edges:
                        if (
                            k1 == k2
                            or (n_res + k1) in deferred_processive_cols
                            or (n_res + k2) in deferred_processive_cols
                        ):
                            continue
                        e2 = glycan_graph.edges[k2]
                        if e1.source == e2.target and _edge_signatures(glycan_graph, k2)[0][:7] == sig1:
                            deferred_processive_cols.add(n_res + k2)
                            excess_edges -= 1
                            break

        core_sugars_in_glycan: set[int] = (
            {nd.sugar for nd in glycan_graph.nodes if not nd.is_modifier} if glycan_graph is not None else set()
        )
        gt_prec_sugars: dict[int, set[int]] = {idx_g: set() for idx_g in gt_gene_indices}
        if self.pan_prior is not None and gt_gene_indices and prec_gene_indices and core_sugars_in_glycan:
            sugar_to_prec_indices: dict[int, list[int]] = {}
            for p_i in prec_gene_indices:
                p_cid = int(gene_nodes_by_i[p_i].cluster_id)
                pref_s = self.pan_prior.preferred_precursor_sugar(p_cid, core_sugars_in_glycan)
                if pref_s is not None:
                    sugar_to_prec_indices.setdefault(pref_s, []).append(p_i)
            for s_idx, p_list in sugar_to_prec_indices.items():
                dists = {g_i: min(abs(g_i - p_i) for p_i in p_list) for g_i in gt_gene_indices}
                min_d = min(dists.values())
                if min_d <= 3:
                    for g_i, d_val in dists.items():
                        if d_val == min_d:
                            gt_prec_sugars[g_i].add(s_idx)

        for i, gn in gene_nodes_by_i.items():
            if gn.role == GeneRole.UNKNOWN:
                continue
            rule = self.registry.get_rule(gn.role_idx)
            if rule.target_type == TargetType.NONE:
                continue

            c_id = int(gn.cluster_id)
            aa_seq_i: bytes | None = (
                bytes(gene_translations[i].seq)
                if (gene_translations is not None and i < len(gene_translations))
                else None
            )
            adj_clusters: list[tuple[int, int]] = []
            adj_init_clusters: list[int] = []
            adj_prec_clusters: list[int] = []
            if self.pan_prior is not None and gn.role in (GeneRole.GLYCOSYLTRANSFERASE, GeneRole.POLYMERASE):
                for prev_i in gt_gene_indices:
                    if prev_i < i and (i - prev_i) <= 4:
                        adj_clusters.append((int(gene_nodes_by_i[prev_i].cluster_id), -1))
                    elif prev_i > i and (prev_i - i) <= 4:
                        adj_clusters.append((int(gene_nodes_by_i[prev_i].cluster_id), +1))
                for init_i in init_gene_indices:
                    if abs(i - init_i) <= 2 and gene_nodes_by_i[init_i].cluster_id > 0:
                        adj_init_clusters.append(int(gene_nodes_by_i[init_i].cluster_id))
                for prec_i in prec_gene_indices:
                    if abs(i - prec_i) <= 3 and gene_nodes_by_i[prec_i].cluster_id > 0:
                        adj_prec_clusters.append(int(gene_nodes_by_i[prec_i].cluster_id))

            for j, raw_gly in enumerate(glycan_labels):
                gly_val = int(raw_gly)
                is_link = bool(gly_val & TAG_EDGE)

                if is_link:
                    if j in modifier_edge_indices:
                        continue
                    if rule.matches_linkages:
                        if rule.is_polymerase:
                            if wzy_bipartite_idx is not None:
                                base_wzy = 0.01 if j == wzy_bipartite_idx else 0.65
                            else:
                                base_wzy = 0.04
                            if (
                                self.pan_prior is not None
                                and (c_id > 0 or aa_seq_i is not None)
                                and glycan_graph is not None
                            ):
                                edge_k = j - n_res
                                if 0 <= edge_k < len(glycan_graph.edges):
                                    wzy_b, _ = self.pan_prior.gt_edge_bonus(
                                        c_id,
                                        glycan_graph,
                                        edge_k,
                                        adj_clusters=adj_clusters,
                                        aa_seq=aa_seq_i,
                                    )
                                    base_wzy += 0.5 * wzy_b
                            cost[i, j] = float(np.clip(base_wzy, 0.003, 0.85))
                        else:
                            # Intra-unit GT (or synthase)
                            if j in deferred_processive_cols:
                                continue
                            if (
                                gn.role == GeneRole.GLYCOSYLTRANSFERASE
                                and wzy_bipartite_idx is not None
                                and j == wzy_bipartite_idx
                                and has_polymerase_in_locus
                            ):
                                cost[i, j] = 0.65
                                continue

                            donor_s = linkage_to_donor_sugar.get(j, -1)
                            base_c = 0.14 if (donor_s in rule.compatible_sugars or not rule.compatible_sugars) else 0.38
                            if gn.role == GeneRole.GLYCOSYLTRANSFERASE and glycan_graph is not None:
                                edge_k = j - n_res
                                if 0 <= edge_k < len(glycan_graph.edges):
                                    if self.pan_prior is not None and (c_id > 0 or aa_seq_i is not None):
                                        pan_bonus, _ = self.pan_prior.gt_edge_bonus(
                                            c_id,
                                            glycan_graph,
                                            edge_k,
                                            adj_clusters=adj_clusters,
                                            adj_init_clusters=adj_init_clusters,
                                            adj_precursor_clusters=adj_prec_clusters,
                                            adj_precursor_sugars=gt_prec_sugars.get(i, set()),
                                            aa_seq=aa_seq_i,
                                        )
                                        base_c += pan_bonus
                                    if i in gt_order_norm and j in edge_step_norm:
                                        base_c += 0.045 * abs(gt_order_norm[i] - edge_step_norm[j])
                            cost[i, j] = float(np.clip(base_c, 0.005, 0.85))
                else:
                    res = ResidueType(gly_val)
                    sugar_idx = res.sugar
                    is_red = j in reducing_end_indices
                    attached_mods = parent_to_mods.get(j, set())

                    if rule.target_type == TargetType.REDUCING_END:
                        if is_red and sugar_idx in rule.compatible_sugars:
                            c_red = 0.015
                            if self.pan_prior is not None and self.pan_prior.has_init_consensus(
                                c_id, sugar_idx, aa_seq=aa_seq_i
                            ):
                                c_red = 0.005
                            cost[i, j] = c_red
                        elif not res.is_modifier and sugar_idx in rule.compatible_sugars:
                            cost[i, j] = 0.50

                    elif rule.target_type == TargetType.MODIFIED_MONO:
                        if res.is_modifier:
                            if j not in n_acetyl_mod_nodes and sugar_idx in rule.target_modifiers:
                                cost[i, j] = 0.05
                        elif gn.role == GeneRole.DEACETYLASE:
                            if j not in n_acetylated_sugar_nodes and sugar_idx in rule.target_modifiers:
                                cost[i, j] = 0.08
                        elif attached_mods & rule.target_modifiers:
                            cost[i, j] = 0.12

                    elif rule.target_type == TargetType.MONOSACCHARIDE:
                        if not res.is_modifier and sugar_idx in rule.compatible_sugars:
                            cost[i, j] = 0.10

        return cost.T if is_swapped else cost

compute_cost

compute_cost(
    labels_a: ndarray[tuple[int], dtype[Any]],
    labels_b: ndarray[tuple[int], dtype[Any]],
    penalty: float = 1000000.0,
    graph_a: GraphModel | None = None,
    graph_b: GraphModel | None = None,
) -> ndarray[tuple[int, int], dtype[float64]]

Compute (N_gene, N_glycan) pairwise biochemical + pan-graph + assembly-depth cost matrix.

Source code in src/kbank/biosynthesis/align.py
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
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
def compute_cost(
    self,
    labels_a: np.ndarray[tuple[int], np.dtype[Any]],
    labels_b: np.ndarray[tuple[int], np.dtype[Any]],
    penalty: float = 1e6,
    graph_a: GraphModel | None = None,
    graph_b: GraphModel | None = None,
) -> np.ndarray[tuple[int, int], np.dtype[np.float64]]:
    """Compute `(N_gene, N_glycan)` pairwise biochemical + pan-graph + assembly-depth cost matrix."""
    is_swapped = isinstance(graph_a, Glycan) and isinstance(graph_b, Locus)
    if is_swapped:
        gene_labels, glycan_labels = labels_b, labels_a
        glycan_graph = graph_a
        gene_graph = graph_b
    else:
        gene_labels, glycan_labels = labels_a, labels_b
        glycan_graph = graph_b if isinstance(graph_b, Glycan) else None
        gene_graph = graph_a if isinstance(graph_a, Locus) else None

    gene_translations = (
        gene_graph.genes.translations if (isinstance(gene_graph, Locus) and len(gene_graph.genes) > 0) else None
    )

    n_genes = len(gene_labels)
    n_glycan = len(glycan_labels)
    cost = np.full((n_genes, n_glycan), np.inf, dtype=np.float64)

    parent_to_mods: dict[int, set[int]] = {j: set() for j in range(n_glycan)}
    reducing_end_indices: set[int] = set()
    linkage_to_donor_sugar: dict[int, int] = {}
    modifier_edge_indices: set[int] = set()
    n_acetyl_mod_nodes: set[int] = set()
    n_acetylated_sugar_nodes: set[int] = set()
    wzy_bipartite_idx: int | None = None
    edge_step_norm: dict[int, float] = {}
    n_res = 0

    if glycan_graph is not None:
        n_acetyl_mod_nodes, n_acetylated_sugar_nodes = BiosyntheticAssigner._find_n_acetyl_nodes(glycan_graph)
        n_res = len(glycan_graph.nodes)
        for j, res in enumerate(glycan_graph.nodes):
            if res.is_reducing_end and not res.is_modifier:
                reducing_end_indices.add(j)
        if not reducing_end_indices and n_res > 0:
            for j, res in enumerate(glycan_graph.nodes):
                if not res.is_modifier:
                    reducing_end_indices.add(j)
                    break

        red_idx = min(reducing_end_indices) if reducing_end_indices else 0
        wzy_edge_k, _, raw_edge_steps = glycan_graph.polymer_edge_and_assembly_depths(red_idx)
        if wzy_edge_k is not None:
            wzy_bipartite_idx = n_res + wzy_edge_k
        if raw_edge_steps:
            min_s = min(raw_edge_steps.values())
            max_s = max(raw_edge_steps.values())
            span_s = max(max_s - min_s, 1)
            for k_e, s_val in raw_edge_steps.items():
                base_norm = (float(s_val - min_s) / float(span_s)) if max_s > min_s else 0.5
                if 0 <= k_e < len(glycan_graph.edges) and glycan_graph.edges[k_e].edge_type.is_branch:
                    base_norm = min(1.0, base_norm + 0.25)
                edge_step_norm[n_res + k_e] = base_norm

        for k, edge in enumerate(glycan_graph.edges):
            src, tgt = edge.source, edge.target
            s_res = glycan_graph.nodes[src]
            t_res = glycan_graph.nodes[tgt]
            bipartite_edge_idx = n_res + k
            if s_res.is_modifier and not t_res.is_modifier:
                if src not in n_acetyl_mod_nodes:
                    parent_to_mods[tgt].add(s_res.sugar)
                modifier_edge_indices.add(bipartite_edge_idx)
            elif t_res.is_modifier and not s_res.is_modifier:
                if tgt not in n_acetyl_mod_nodes:
                    parent_to_mods[src].add(t_res.sugar)
                modifier_edge_indices.add(bipartite_edge_idx)
            elif s_res.is_modifier and t_res.is_modifier:
                modifier_edge_indices.add(bipartite_edge_idx)
            if bipartite_edge_idx < n_glycan:
                linkage_to_donor_sugar[bipartite_edge_idx] = s_res.sugar
    else:
        for j, raw_g in enumerate(glycan_labels):
            val = int(raw_g)
            if not (val & TAG_EDGE):
                res = ResidueType(val)
                if res.is_reducing_end and not res.is_modifier:
                    reducing_end_indices.add(j)
            elif wzy_bipartite_idx is None and LinkageType(val).is_polymer:
                wzy_bipartite_idx = j
        if not reducing_end_indices and n_glycan > 0 and not (int(glycan_labels[0]) & TAG_EDGE):
            reducing_end_indices.add(0)

    # Precompute GT order relative to INITIATOR in the locus for biosynthetic step tie-breaking
    precursor_roles = {
        GeneRole.OXIDOREDUCTASE,
        GeneRole.EPIMERASE,
        GeneRole.AMINOTRANSFERASE,
        GeneRole.ALDOLASE,
        GeneRole.NUCLEOTIDASE,
        GeneRole.NUCLEOTIDYLTRANSFERASE,
    }
    has_polymerase_in_locus = False
    init_gene_indices: list[int] = []
    gt_gene_indices: list[int] = []
    prec_gene_indices: list[int] = []
    gene_nodes_by_i: dict[int, GeneNode] = {}
    for i, raw_gene in enumerate(gene_labels):
        g_val = int(raw_gene)
        if g_val & TAG_EDGE:
            continue
        gn = GeneNode(g_val)
        gene_nodes_by_i[i] = gn
        if gn.role == GeneRole.POLYMERASE:
            has_polymerase_in_locus = True
        elif gn.role == GeneRole.INITIATOR:
            init_gene_indices.append(i)
        elif gn.role == GeneRole.GLYCOSYLTRANSFERASE:
            gt_gene_indices.append(i)
        elif gn.role in precursor_roles:
            prec_gene_indices.append(i)

    gt_order_norm: dict[int, float] = {}
    if gt_gene_indices:
        if init_gene_indices:
            anchor_i = init_gene_indices[0]
            ordered_gts = sorted(gt_gene_indices, key=lambda idx_g: (abs(idx_g - anchor_i), idx_g))
        else:
            ordered_gts = list(gt_gene_indices)
        n_gts = len(ordered_gts)
        for rank_idx, idx_g in enumerate(ordered_gts):
            gt_order_norm[idx_g] = (float(rank_idx) / float(n_gts - 1)) if n_gts > 1 else 0.5

    # Defer duplicate consecutive processive repeat edges from 1-to-1 Hungarian matching when intra_gt_edges > n_gts
    deferred_processive_cols: set[int] = set()
    if glycan_graph is not None and gt_gene_indices:
        intra_core_edges = [
            k
            for k, e in enumerate(glycan_graph.edges)
            if (n_res + k) not in modifier_edge_indices
            and ((n_res + k) != wzy_bipartite_idx or not has_polymerase_in_locus)
        ]
        excess_edges = len(intra_core_edges) - len(gt_gene_indices)
        if excess_edges > 0:
            for k1 in intra_core_edges:
                if excess_edges <= 0:
                    break
                e1 = glycan_graph.edges[k1]
                sig1 = _edge_signatures(glycan_graph, k1)[0][:7]
                for k2 in intra_core_edges:
                    if (
                        k1 == k2
                        or (n_res + k1) in deferred_processive_cols
                        or (n_res + k2) in deferred_processive_cols
                    ):
                        continue
                    e2 = glycan_graph.edges[k2]
                    if e1.source == e2.target and _edge_signatures(glycan_graph, k2)[0][:7] == sig1:
                        deferred_processive_cols.add(n_res + k2)
                        excess_edges -= 1
                        break

    core_sugars_in_glycan: set[int] = (
        {nd.sugar for nd in glycan_graph.nodes if not nd.is_modifier} if glycan_graph is not None else set()
    )
    gt_prec_sugars: dict[int, set[int]] = {idx_g: set() for idx_g in gt_gene_indices}
    if self.pan_prior is not None and gt_gene_indices and prec_gene_indices and core_sugars_in_glycan:
        sugar_to_prec_indices: dict[int, list[int]] = {}
        for p_i in prec_gene_indices:
            p_cid = int(gene_nodes_by_i[p_i].cluster_id)
            pref_s = self.pan_prior.preferred_precursor_sugar(p_cid, core_sugars_in_glycan)
            if pref_s is not None:
                sugar_to_prec_indices.setdefault(pref_s, []).append(p_i)
        for s_idx, p_list in sugar_to_prec_indices.items():
            dists = {g_i: min(abs(g_i - p_i) for p_i in p_list) for g_i in gt_gene_indices}
            min_d = min(dists.values())
            if min_d <= 3:
                for g_i, d_val in dists.items():
                    if d_val == min_d:
                        gt_prec_sugars[g_i].add(s_idx)

    for i, gn in gene_nodes_by_i.items():
        if gn.role == GeneRole.UNKNOWN:
            continue
        rule = self.registry.get_rule(gn.role_idx)
        if rule.target_type == TargetType.NONE:
            continue

        c_id = int(gn.cluster_id)
        aa_seq_i: bytes | None = (
            bytes(gene_translations[i].seq)
            if (gene_translations is not None and i < len(gene_translations))
            else None
        )
        adj_clusters: list[tuple[int, int]] = []
        adj_init_clusters: list[int] = []
        adj_prec_clusters: list[int] = []
        if self.pan_prior is not None and gn.role in (GeneRole.GLYCOSYLTRANSFERASE, GeneRole.POLYMERASE):
            for prev_i in gt_gene_indices:
                if prev_i < i and (i - prev_i) <= 4:
                    adj_clusters.append((int(gene_nodes_by_i[prev_i].cluster_id), -1))
                elif prev_i > i and (prev_i - i) <= 4:
                    adj_clusters.append((int(gene_nodes_by_i[prev_i].cluster_id), +1))
            for init_i in init_gene_indices:
                if abs(i - init_i) <= 2 and gene_nodes_by_i[init_i].cluster_id > 0:
                    adj_init_clusters.append(int(gene_nodes_by_i[init_i].cluster_id))
            for prec_i in prec_gene_indices:
                if abs(i - prec_i) <= 3 and gene_nodes_by_i[prec_i].cluster_id > 0:
                    adj_prec_clusters.append(int(gene_nodes_by_i[prec_i].cluster_id))

        for j, raw_gly in enumerate(glycan_labels):
            gly_val = int(raw_gly)
            is_link = bool(gly_val & TAG_EDGE)

            if is_link:
                if j in modifier_edge_indices:
                    continue
                if rule.matches_linkages:
                    if rule.is_polymerase:
                        if wzy_bipartite_idx is not None:
                            base_wzy = 0.01 if j == wzy_bipartite_idx else 0.65
                        else:
                            base_wzy = 0.04
                        if (
                            self.pan_prior is not None
                            and (c_id > 0 or aa_seq_i is not None)
                            and glycan_graph is not None
                        ):
                            edge_k = j - n_res
                            if 0 <= edge_k < len(glycan_graph.edges):
                                wzy_b, _ = self.pan_prior.gt_edge_bonus(
                                    c_id,
                                    glycan_graph,
                                    edge_k,
                                    adj_clusters=adj_clusters,
                                    aa_seq=aa_seq_i,
                                )
                                base_wzy += 0.5 * wzy_b
                        cost[i, j] = float(np.clip(base_wzy, 0.003, 0.85))
                    else:
                        # Intra-unit GT (or synthase)
                        if j in deferred_processive_cols:
                            continue
                        if (
                            gn.role == GeneRole.GLYCOSYLTRANSFERASE
                            and wzy_bipartite_idx is not None
                            and j == wzy_bipartite_idx
                            and has_polymerase_in_locus
                        ):
                            cost[i, j] = 0.65
                            continue

                        donor_s = linkage_to_donor_sugar.get(j, -1)
                        base_c = 0.14 if (donor_s in rule.compatible_sugars or not rule.compatible_sugars) else 0.38
                        if gn.role == GeneRole.GLYCOSYLTRANSFERASE and glycan_graph is not None:
                            edge_k = j - n_res
                            if 0 <= edge_k < len(glycan_graph.edges):
                                if self.pan_prior is not None and (c_id > 0 or aa_seq_i is not None):
                                    pan_bonus, _ = self.pan_prior.gt_edge_bonus(
                                        c_id,
                                        glycan_graph,
                                        edge_k,
                                        adj_clusters=adj_clusters,
                                        adj_init_clusters=adj_init_clusters,
                                        adj_precursor_clusters=adj_prec_clusters,
                                        adj_precursor_sugars=gt_prec_sugars.get(i, set()),
                                        aa_seq=aa_seq_i,
                                    )
                                    base_c += pan_bonus
                                if i in gt_order_norm and j in edge_step_norm:
                                    base_c += 0.045 * abs(gt_order_norm[i] - edge_step_norm[j])
                        cost[i, j] = float(np.clip(base_c, 0.005, 0.85))
            else:
                res = ResidueType(gly_val)
                sugar_idx = res.sugar
                is_red = j in reducing_end_indices
                attached_mods = parent_to_mods.get(j, set())

                if rule.target_type == TargetType.REDUCING_END:
                    if is_red and sugar_idx in rule.compatible_sugars:
                        c_red = 0.015
                        if self.pan_prior is not None and self.pan_prior.has_init_consensus(
                            c_id, sugar_idx, aa_seq=aa_seq_i
                        ):
                            c_red = 0.005
                        cost[i, j] = c_red
                    elif not res.is_modifier and sugar_idx in rule.compatible_sugars:
                        cost[i, j] = 0.50

                elif rule.target_type == TargetType.MODIFIED_MONO:
                    if res.is_modifier:
                        if j not in n_acetyl_mod_nodes and sugar_idx in rule.target_modifiers:
                            cost[i, j] = 0.05
                    elif gn.role == GeneRole.DEACETYLASE:
                        if j not in n_acetylated_sugar_nodes and sugar_idx in rule.target_modifiers:
                            cost[i, j] = 0.08
                    elif attached_mods & rule.target_modifiers:
                        cost[i, j] = 0.12

                elif rule.target_type == TargetType.MONOSACCHARIDE:
                    if not res.is_modifier and sugar_idx in rule.compatible_sugars:
                        cost[i, j] = 0.10

    return cost.T if is_swapped else cost

BiosyntheticAligner

Bases: GraphAligner

Specialized GraphAligner pre-configured for matching GeneCluster to Glycan.

Source code in src/kbank/biosynthesis/align.py
class BiosyntheticAligner(GraphAligner):
    """Specialized `GraphAligner` pre-configured for matching `GeneCluster` to `Glycan`."""

    def __init__(
        self,
        config: AlignmentConfig | None = None,
        embedding_strategy: EmbeddingStrategy | None = None,
        sugars_db: type[Sugars] | Sugars = Sugars,
        pan_prior: PanGraphConsensusPrior | None = None,
    ) -> None:
        """Initialize `GeneGlycanAligner`."""
        cfg = config or AlignmentConfig(iterations=2, topo_weight=0.05, as_bipartite=True)
        registry = BiosyntheticRuleRegistry(sugars_db=sugars_db)
        super().__init__(
            cost_strategy=BiosyntheticCostStrategy(rule_registry=registry, pan_prior=pan_prior),
            embedding_strategy=embedding_strategy or WeisfeilerLehmanEmbedding(iterations=cfg.iterations),
            config=cfg,
        )

GeneGlycanAssignment dataclass

Resolved assignment of a gene (or theoretical GeneRole) to a structural element in a Glycan.

Methods:

Name Description
format_short

Compact summary token (e.g. 'Init[g0]->*D-Galp', 'AcT[g4]*->OAcp', or 'OxR->L-Rhap').

Attributes:

Name Type Description
is_aligned bool

True if this assignment links a specific ORF (gene_index >= 0) from a paired Locus.

is_pan_consensus bool

True if this assignment is supported by multi-record pan-graph homology or synteny consensus.

is_locus_completed bool

True if this assignment targets a modifier node artificially completed from a paired Locus.

is_backbone_linkage bool

True if this assignment targets a main-chain backbone glycosidic linkage.

is_branch_linkage bool

True if this assignment targets a side-chain branch glycosidic linkage.

is_polymer_linkage bool

True if this assignment targets the inter-repeat Wzy polymer glycosidic linkage.

is_backbone_residue bool

True if this assignment targets a main-chain backbone core monosaccharide node.

is_branch_residue bool

True if this assignment targets a side-chain branch core monosaccharide node.

target_index int

0-based index into glycan.edges (if linkage) or glycan.nodes (if residue/modifier).

component_int int

Bit-packed 32-bit integer (LinkageType with TAG_EDGE=1 or ResidueType with TAG_EDGE=0).

component_label str

Human-readable label of the target glycan component (e.g. '*D-Galp', 'a1-3', 'OAcp').

Source code in src/kbank/biosynthesis/align.py
@dataclass(slots=True, frozen=True)
class GeneGlycanAssignment:
    """Resolved assignment of a gene (or theoretical `GeneRole`) to a structural element in a `Glycan`."""

    gene_index: int
    role: GeneRole
    bipartite_glycan_index: int
    target_type: TargetType
    residue: ResidueType | None = None
    linkage: LinkageType | None = None
    edge_index: int | None = None
    evidence: AssignmentEvidence = AssignmentEvidence.GRAPH_ALIGNED
    score: float = 1.0

    @property
    def is_aligned(self) -> bool:
        """True if this assignment links a specific ORF (`gene_index >= 0`) from a paired `Locus`."""
        return self.gene_index >= 0 and self.evidence != AssignmentEvidence.RULE_INFERRED

    @property
    def is_pan_consensus(self) -> bool:
        """True if this assignment is supported by multi-record pan-graph homology or synteny consensus."""
        return self.evidence == AssignmentEvidence.PAN_GRAPH_CONSENSUS

    @property
    def is_locus_completed(self) -> bool:
        """True if this assignment targets a modifier node artificially completed from a paired `Locus`."""
        return self.evidence == AssignmentEvidence.LOCUS_COMPLETED

    @property
    def is_backbone_linkage(self) -> bool:
        """True if this assignment targets a main-chain backbone glycosidic linkage."""
        return bool(self.linkage is not None and self.linkage.is_backbone)

    @property
    def is_branch_linkage(self) -> bool:
        """True if this assignment targets a side-chain branch glycosidic linkage."""
        return bool(self.linkage is not None and self.linkage.is_branch)

    @property
    def is_polymer_linkage(self) -> bool:
        """True if this assignment targets the inter-repeat `Wzy` polymer glycosidic linkage."""
        return bool(self.linkage is not None and self.linkage.is_polymer)

    @property
    def is_backbone_residue(self) -> bool:
        """True if this assignment targets a main-chain backbone core monosaccharide node."""
        return bool(self.residue is not None and self.residue.is_backbone)

    @property
    def is_branch_residue(self) -> bool:
        """True if this assignment targets a side-chain branch core monosaccharide node."""
        return bool(self.residue is not None and self.residue.is_branch)

    @property
    def target_index(self) -> int:
        """0-based index into `glycan.edges` (if linkage) or `glycan.nodes` (if residue/modifier)."""
        if self.edge_index is not None and self.edge_index >= 0:
            return self.edge_index
        return self.bipartite_glycan_index

    @property
    def component_int(self) -> int:
        """Bit-packed 32-bit integer (`LinkageType` with `TAG_EDGE=1` or `ResidueType` with `TAG_EDGE=0`)."""
        if self.linkage is not None:
            return int(self.linkage)
        if self.residue is not None:
            return int(self.residue)
        return 0

    @property
    def component_label(self) -> str:
        """Human-readable label of the target glycan component (e.g. `'*D-Galp'`, `'a1-3'`, `'OAcp'`)."""
        if self.linkage is not None:
            return self.linkage.name.decode("ascii", errors="replace")
        if self.residue is not None:
            return repr(self.residue)
        return "?"

    def format_short(self) -> str:
        """Compact summary token (e.g. `'Init[g0]->*D-Galp'`, `'AcT[g4]*->OAcp'`, or `'OxR->L-Rhap'`)."""
        role_str = self.role.abbreviation
        gene_tag = f"[g{self.gene_index}]" if self.gene_index >= 0 else ""
        comp_mark = "*" if self.evidence == AssignmentEvidence.LOCUS_COMPLETED else ""
        return f"{role_str}{gene_tag}{comp_mark}->{self.component_label}"

is_aligned property

is_aligned: bool

True if this assignment links a specific ORF (gene_index >= 0) from a paired Locus.

is_pan_consensus property

is_pan_consensus: bool

True if this assignment is supported by multi-record pan-graph homology or synteny consensus.

is_locus_completed property

is_locus_completed: bool

True if this assignment targets a modifier node artificially completed from a paired Locus.

is_backbone_linkage property

is_backbone_linkage: bool

True if this assignment targets a main-chain backbone glycosidic linkage.

is_branch_linkage property

is_branch_linkage: bool

True if this assignment targets a side-chain branch glycosidic linkage.

is_polymer_linkage property

is_polymer_linkage: bool

True if this assignment targets the inter-repeat Wzy polymer glycosidic linkage.

is_backbone_residue property

is_backbone_residue: bool

True if this assignment targets a main-chain backbone core monosaccharide node.

is_branch_residue property

is_branch_residue: bool

True if this assignment targets a side-chain branch core monosaccharide node.

target_index property

target_index: int

0-based index into glycan.edges (if linkage) or glycan.nodes (if residue/modifier).

component_int property

component_int: int

Bit-packed 32-bit integer (LinkageType with TAG_EDGE=1 or ResidueType with TAG_EDGE=0).

component_label property

component_label: str

Human-readable label of the target glycan component (e.g. '*D-Galp', 'a1-3', 'OAcp').

format_short

format_short() -> str

Compact summary token (e.g. 'Init[g0]->*D-Galp', 'AcT[g4]*->OAcp', or 'OxR->L-Rhap').

Source code in src/kbank/biosynthesis/align.py
def format_short(self) -> str:
    """Compact summary token (e.g. `'Init[g0]->*D-Galp'`, `'AcT[g4]*->OAcp'`, or `'OxR->L-Rhap'`)."""
    role_str = self.role.abbreviation
    gene_tag = f"[g{self.gene_index}]" if self.gene_index >= 0 else ""
    comp_mark = "*" if self.evidence == AssignmentEvidence.LOCUS_COMPLETED else ""
    return f"{role_str}{gene_tag}{comp_mark}->{self.component_label}"

GeneGlycanAssignments dataclass

Bases: RaggedArrayContainer[Any, 'GeneGlycanAssignments']

Ragged Structure-of-Arrays (SoA) container for cross-domain gene-to-glycan component assignments.

Stores variable-length bipartite assignment edges per GlycanRecord in flat contiguous NumPy arrays indexed via offsets and lengths (zero nesting, zero pickle).

Methods:

Name Description
empty

Create an empty GeneGlycanAssignments container.

from_list

Compile a sequence of per-record GeneGlycanAssignment tuples into a flat SoA container.

concat

Concatenate multiple GeneGlycanAssignments containers into a single batch.

Attributes:

Name Type Description
total_assignments int

Return the total number of assignment edges across all records in the batch.

Source code in src/kbank/biosynthesis/align.py
@dataclass(slots=True, frozen=True)
class GeneGlycanAssignments(RaggedArrayContainer[Any, "GeneGlycanAssignments"]):
    """Ragged Structure-of-Arrays (SoA) container for cross-domain gene-to-glycan component assignments.

    Stores variable-length bipartite assignment edges per `GlycanRecord` in flat contiguous
    NumPy arrays indexed via `offsets` and `lengths` (zero nesting, zero pickle).
    """

    gene_indices: np.ndarray[tuple[int], np.dtype[np.int16]]
    roles: np.ndarray[tuple[int], np.dtype[np.int8]]
    bipartite_indices: np.ndarray[tuple[int], np.dtype[np.int16]]
    target_types: np.ndarray[tuple[int], np.dtype[np.int8]]
    components: np.ndarray[tuple[int], np.dtype[np.uint32]]
    edge_indices: np.ndarray[tuple[int], np.dtype[np.int16]]
    evidences: np.ndarray[tuple[int], np.dtype[np.int8]]
    scores: np.ndarray[tuple[int], np.dtype[np.float32]]
    offsets: np.ndarray[tuple[int], np.dtype[np.int32]]
    lengths: np.ndarray[tuple[int], np.dtype[np.int32]]

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

    @property
    def total_assignments(self) -> int:
        """Return the total number of assignment edges across all records in the batch."""
        return len(self.roles)

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

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

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

    def __getitem__(
        self,
        item: int | np.integer[Any] | slice | np.ndarray[tuple[int], Any] | list[Any],
    ) -> tuple[GeneGlycanAssignment, ...] | GeneGlycanAssignments:
        """Access a single record's assignments tuple by int index, or slice/mask the batch container."""
        if isinstance(item, (int, np.integer)):
            idx = int(item)
            if idx < 0:
                idx += len(self)
            if idx < 0 or idx >= len(self):
                raise IndexError("GeneGlycanAssignments index out of range")
            off = int(self.offsets[idx])
            ln = int(self.lengths[idx])
            if ln == 0:
                return ()
            out: list[GeneGlycanAssignment] = []
            for k in range(off, off + ln):
                comp_val = int(self.components[k])
                is_link = bool(comp_val & TAG_EDGE)
                e_idx = int(self.edge_indices[k])
                out.append(
                    GeneGlycanAssignment(
                        gene_index=int(self.gene_indices[k]),
                        role=GeneRole(int(self.roles[k])),
                        bipartite_glycan_index=int(self.bipartite_indices[k]),
                        target_type=_TARGET_TYPES[int(self.target_types[k])],
                        residue=None if is_link else ResidueType(comp_val),
                        linkage=LinkageType(comp_val) if is_link else None,
                        edge_index=e_idx if e_idx >= 0 else None,
                        evidence=AssignmentEvidence(int(self.evidences[k])),
                        score=float(self.scores[k]),
                    )
                )
            return tuple(out)

        indices, new_offsets, new_lengths, sliced = self._slice_ragged(
            item,
            self.gene_indices,
            self.roles,
            self.bipartite_indices,
            self.target_types,
            self.components,
            self.edge_indices,
            self.evidences,
            self.scores,
        )
        if len(indices) == 0:
            return self.empty()
        return GeneGlycanAssignments(
            gene_indices=sliced[0],
            roles=sliced[1],
            bipartite_indices=sliced[2],
            target_types=sliced[3],
            components=sliced[4],
            edge_indices=sliced[5],
            evidences=sliced[6],
            scores=sliced[7],
            offsets=new_offsets,
            lengths=new_lengths,
        )

    @classmethod
    def empty(cls, length: int = 0) -> Self:
        """Create an empty `GeneGlycanAssignments` container."""
        return cls(
            gene_indices=np.empty(0, dtype=np.int16),
            roles=np.empty(0, dtype=np.int8),
            bipartite_indices=np.empty(0, dtype=np.int16),
            target_types=np.empty(0, dtype=np.int8),
            components=np.empty(0, dtype=np.uint32),
            edge_indices=np.empty(0, dtype=np.int16),
            evidences=np.empty(0, dtype=np.int8),
            scores=np.empty(0, dtype=np.float32),
            offsets=np.zeros(length, dtype=np.int32) if length > 0 else np.empty(0, dtype=np.int32),
            lengths=np.zeros(length, dtype=np.int32) if length > 0 else np.empty(0, dtype=np.int32),
        )

    @classmethod
    def from_list(cls, records_assignments: Sequence[Sequence[GeneGlycanAssignment]]) -> Self:
        """Compile a sequence of per-record `GeneGlycanAssignment` tuples into a flat SoA container."""
        n = len(records_assignments)
        if n == 0:
            return cls.empty(0)
        lengths = np.array([len(a) for a in records_assignments], dtype=np.int32)
        offsets = cls.lengths_to_offsets(lengths)
        tot = int(offsets[-1] + lengths[-1])
        if tot == 0:
            return cls.empty(n)

        gene_indices = np.empty(tot, dtype=np.int16)
        roles = np.empty(tot, dtype=np.int8)
        bipartite_indices = np.empty(tot, dtype=np.int16)
        target_types = np.empty(tot, dtype=np.int8)
        components = np.empty(tot, dtype=np.uint32)
        edge_indices = np.empty(tot, dtype=np.int16)
        evidences = np.empty(tot, dtype=np.int8)
        scores = np.empty(tot, dtype=np.float32)

        cursor = 0
        for rec_assigns in records_assignments:
            for item in rec_assigns:
                gene_indices[cursor] = item.gene_index
                roles[cursor] = int(item.role)
                bipartite_indices[cursor] = item.bipartite_glycan_index
                target_types[cursor] = _TARGET_TYPE_TO_INT.get(item.target_type, 0)
                components[cursor] = item.component_int
                edge_indices[cursor] = item.edge_index if item.edge_index is not None else -1
                evidences[cursor] = int(item.evidence)
                scores[cursor] = float(item.score)
                cursor += 1

        return cls(
            gene_indices=gene_indices,
            roles=roles,
            bipartite_indices=bipartite_indices,
            target_types=target_types,
            components=components,
            edge_indices=edge_indices,
            evidences=evidences,
            scores=scores,
            offsets=offsets,
            lengths=lengths,
        )

    @classmethod
    def concat(cls, batches: Iterable[Self]) -> Self:
        """Concatenate multiple `GeneGlycanAssignments` containers into a single batch."""
        batch_list = [b for b in batches if len(b) > 0]
        if not batch_list:
            return cls.empty(0)
        if len(batch_list) == 1:
            return batch_list[0]

        lengths = np.concatenate([b.lengths for b in batch_list])
        offsets = cls.lengths_to_offsets(lengths)
        non_empty = [b for b in batch_list if b.total_assignments > 0]
        if not non_empty:
            return cls.empty(len(lengths))

        return cls(
            gene_indices=np.concatenate([b.gene_indices for b in non_empty]),
            roles=np.concatenate([b.roles for b in non_empty]),
            bipartite_indices=np.concatenate([b.bipartite_indices for b in non_empty]),
            target_types=np.concatenate([b.target_types for b in non_empty]),
            components=np.concatenate([b.components for b in non_empty]),
            edge_indices=np.concatenate([b.edge_indices for b in non_empty]),
            evidences=np.concatenate([b.evidences for b in non_empty]),
            scores=np.concatenate([b.scores for b in non_empty]),
            offsets=offsets,
            lengths=lengths,
        )

total_assignments property

total_assignments: int

Return the total number of assignment edges across all records in the batch.

empty classmethod

empty(length: int = 0) -> Self

Create an empty GeneGlycanAssignments container.

Source code in src/kbank/biosynthesis/align.py
@classmethod
def empty(cls, length: int = 0) -> Self:
    """Create an empty `GeneGlycanAssignments` container."""
    return cls(
        gene_indices=np.empty(0, dtype=np.int16),
        roles=np.empty(0, dtype=np.int8),
        bipartite_indices=np.empty(0, dtype=np.int16),
        target_types=np.empty(0, dtype=np.int8),
        components=np.empty(0, dtype=np.uint32),
        edge_indices=np.empty(0, dtype=np.int16),
        evidences=np.empty(0, dtype=np.int8),
        scores=np.empty(0, dtype=np.float32),
        offsets=np.zeros(length, dtype=np.int32) if length > 0 else np.empty(0, dtype=np.int32),
        lengths=np.zeros(length, dtype=np.int32) if length > 0 else np.empty(0, dtype=np.int32),
    )

from_list classmethod

from_list(
    records_assignments: Sequence[
        Sequence[GeneGlycanAssignment]
    ],
) -> Self

Compile a sequence of per-record GeneGlycanAssignment tuples into a flat SoA container.

Source code in src/kbank/biosynthesis/align.py
@classmethod
def from_list(cls, records_assignments: Sequence[Sequence[GeneGlycanAssignment]]) -> Self:
    """Compile a sequence of per-record `GeneGlycanAssignment` tuples into a flat SoA container."""
    n = len(records_assignments)
    if n == 0:
        return cls.empty(0)
    lengths = np.array([len(a) for a in records_assignments], dtype=np.int32)
    offsets = cls.lengths_to_offsets(lengths)
    tot = int(offsets[-1] + lengths[-1])
    if tot == 0:
        return cls.empty(n)

    gene_indices = np.empty(tot, dtype=np.int16)
    roles = np.empty(tot, dtype=np.int8)
    bipartite_indices = np.empty(tot, dtype=np.int16)
    target_types = np.empty(tot, dtype=np.int8)
    components = np.empty(tot, dtype=np.uint32)
    edge_indices = np.empty(tot, dtype=np.int16)
    evidences = np.empty(tot, dtype=np.int8)
    scores = np.empty(tot, dtype=np.float32)

    cursor = 0
    for rec_assigns in records_assignments:
        for item in rec_assigns:
            gene_indices[cursor] = item.gene_index
            roles[cursor] = int(item.role)
            bipartite_indices[cursor] = item.bipartite_glycan_index
            target_types[cursor] = _TARGET_TYPE_TO_INT.get(item.target_type, 0)
            components[cursor] = item.component_int
            edge_indices[cursor] = item.edge_index if item.edge_index is not None else -1
            evidences[cursor] = int(item.evidence)
            scores[cursor] = float(item.score)
            cursor += 1

    return cls(
        gene_indices=gene_indices,
        roles=roles,
        bipartite_indices=bipartite_indices,
        target_types=target_types,
        components=components,
        edge_indices=edge_indices,
        evidences=evidences,
        scores=scores,
        offsets=offsets,
        lengths=lengths,
    )

concat classmethod

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

Concatenate multiple GeneGlycanAssignments containers into a single batch.

Source code in src/kbank/biosynthesis/align.py
@classmethod
def concat(cls, batches: Iterable[Self]) -> Self:
    """Concatenate multiple `GeneGlycanAssignments` containers into a single batch."""
    batch_list = [b for b in batches if len(b) > 0]
    if not batch_list:
        return cls.empty(0)
    if len(batch_list) == 1:
        return batch_list[0]

    lengths = np.concatenate([b.lengths for b in batch_list])
    offsets = cls.lengths_to_offsets(lengths)
    non_empty = [b for b in batch_list if b.total_assignments > 0]
    if not non_empty:
        return cls.empty(len(lengths))

    return cls(
        gene_indices=np.concatenate([b.gene_indices for b in non_empty]),
        roles=np.concatenate([b.roles for b in non_empty]),
        bipartite_indices=np.concatenate([b.bipartite_indices for b in non_empty]),
        target_types=np.concatenate([b.target_types for b in non_empty]),
        components=np.concatenate([b.components for b in non_empty]),
        edge_indices=np.concatenate([b.edge_indices for b in non_empty]),
        evidences=np.concatenate([b.evidences for b in non_empty]),
        scores=np.concatenate([b.scores for b in non_empty]),
        offsets=offsets,
        lengths=lengths,
    )

LocusMatchResult dataclass

Result of matching and scoring a candidate Locus against a target Glycan.

Source code in src/kbank/biosynthesis/align.py
@dataclass(slots=True, frozen=True)
class LocusMatchResult:
    """Result of matching and scoring a candidate `Locus` against a target `Glycan`."""

    locus_index: int
    locus: Locus
    archetype: LocusArchetype
    alignment: Alignment
    combined_score: float
    bio_score: float
    assignments: tuple[GeneGlycanAssignment, ...]

BiosyntheticAssigner

Assigns candidate biosynthetic Loci to Glycan structures and maps individual genes to linkages/residues.

Methods:

Name Description
resolve_biological_reducing_end

Rotate glycan reducing end (*Sugar) to the true biological Und-PP initiating monosaccharide.

infer_expected_archetype

Infer expected ArchetypeCategory and optional specific LocusArchetype from an antigen hint string.

complete_glycan_modifiers

Artificially complete glycan with modifier nodes inferred from excess modifier genes in locus.

rescue_cassette_roles

Promote variable-cassette UNKNOWN or duplicate non-conserved transport/hydrolase ORFs to GT/POLYMERASE when locus has a linkage deficit.

assign_genes

Map active biosynthetic genes in locus to their aligned residues or linkages in glycan.

infer_rule_assignments

Infer stoichiometric GeneRole -> glycan component assignments via BiosyntheticRuleRegistry.

assign_record

Resolve complete gene-to-glycan component assignments for a record (graph-aligned + rule fallback).

assign_gt_linkages

Map each glycosyltransferase / polymerase / synthase in locus to its Glycan linkage.

score_locus

Score a single candidate Locus against glycan by combining archetype, completeness, and alignment.

rank_loci

Score and rank all candidate Loci against glycan, prioritizing matching archetypes.

match_locus

Select the highest-scoring candidate Locus for glycan from loci.

Source code in src/kbank/biosynthesis/align.py
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
1971
1972
1973
1974
1975
1976
1977
1978
1979
1980
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
2047
2048
2049
2050
2051
2052
2053
2054
2055
2056
2057
2058
2059
2060
2061
2062
2063
2064
2065
2066
2067
2068
2069
2070
2071
2072
2073
2074
2075
2076
2077
2078
2079
2080
2081
2082
2083
2084
2085
2086
2087
2088
2089
2090
2091
2092
2093
2094
2095
2096
2097
2098
2099
2100
2101
2102
2103
2104
2105
2106
2107
2108
2109
2110
2111
2112
2113
2114
2115
2116
2117
2118
2119
2120
2121
2122
2123
2124
2125
2126
2127
2128
2129
2130
2131
2132
2133
2134
2135
2136
2137
2138
2139
2140
2141
2142
2143
2144
2145
2146
2147
2148
2149
2150
2151
2152
2153
2154
2155
2156
2157
2158
2159
2160
2161
2162
2163
2164
2165
2166
2167
2168
2169
2170
2171
2172
2173
2174
2175
2176
2177
2178
2179
2180
2181
2182
2183
2184
2185
2186
2187
2188
2189
2190
2191
2192
2193
2194
2195
2196
2197
2198
2199
2200
2201
2202
2203
2204
2205
2206
2207
2208
2209
2210
2211
2212
2213
2214
2215
2216
2217
2218
2219
2220
2221
2222
2223
2224
2225
2226
2227
2228
2229
2230
2231
2232
2233
2234
2235
2236
2237
2238
2239
2240
2241
2242
2243
2244
2245
2246
2247
2248
2249
2250
2251
2252
2253
2254
2255
2256
2257
2258
2259
2260
2261
2262
2263
2264
2265
2266
2267
2268
2269
2270
2271
2272
2273
2274
2275
2276
2277
2278
2279
2280
2281
2282
2283
2284
2285
2286
2287
2288
2289
2290
2291
2292
2293
2294
2295
2296
2297
2298
2299
2300
2301
2302
2303
2304
2305
2306
2307
2308
2309
2310
2311
2312
2313
2314
2315
2316
2317
2318
2319
2320
2321
2322
2323
2324
2325
2326
2327
2328
2329
2330
2331
2332
2333
2334
2335
2336
2337
2338
2339
2340
2341
2342
2343
2344
2345
2346
2347
2348
2349
2350
2351
2352
2353
2354
2355
2356
2357
2358
2359
2360
2361
2362
2363
2364
2365
2366
2367
2368
2369
2370
2371
2372
2373
2374
2375
2376
2377
2378
2379
2380
2381
2382
2383
2384
2385
2386
2387
2388
2389
2390
2391
2392
2393
2394
2395
2396
2397
2398
2399
2400
2401
2402
2403
2404
2405
2406
2407
2408
2409
2410
2411
2412
2413
2414
2415
2416
2417
2418
2419
2420
2421
2422
2423
2424
2425
2426
2427
2428
2429
2430
2431
2432
2433
2434
2435
2436
2437
2438
2439
2440
2441
2442
2443
2444
2445
2446
2447
2448
2449
2450
2451
2452
2453
2454
2455
2456
2457
2458
2459
2460
2461
2462
2463
2464
2465
2466
2467
2468
2469
2470
2471
2472
2473
2474
2475
2476
2477
2478
2479
2480
2481
2482
2483
2484
2485
2486
2487
2488
2489
2490
2491
2492
2493
2494
2495
2496
2497
2498
2499
2500
2501
2502
2503
2504
2505
2506
2507
2508
2509
2510
2511
2512
2513
2514
2515
2516
2517
2518
2519
2520
2521
2522
2523
2524
2525
2526
2527
2528
2529
2530
2531
2532
2533
2534
2535
2536
2537
2538
2539
2540
2541
2542
2543
2544
2545
2546
2547
2548
2549
2550
2551
2552
2553
2554
2555
2556
2557
2558
2559
2560
2561
2562
2563
2564
2565
2566
2567
2568
2569
2570
2571
2572
2573
2574
2575
2576
2577
2578
2579
2580
2581
2582
2583
2584
2585
2586
2587
2588
2589
2590
2591
2592
2593
2594
2595
2596
2597
2598
2599
2600
2601
2602
2603
2604
2605
2606
2607
2608
2609
2610
2611
2612
2613
2614
2615
2616
2617
2618
2619
2620
2621
2622
2623
2624
2625
2626
2627
2628
2629
2630
2631
2632
2633
2634
2635
2636
2637
2638
2639
2640
2641
2642
2643
2644
2645
2646
2647
2648
2649
2650
2651
2652
2653
2654
2655
2656
2657
2658
2659
2660
2661
2662
2663
2664
2665
2666
2667
2668
2669
2670
2671
2672
2673
2674
2675
2676
2677
2678
2679
2680
2681
2682
2683
2684
2685
2686
2687
2688
2689
2690
2691
2692
2693
2694
2695
2696
2697
2698
2699
2700
2701
2702
2703
2704
2705
2706
2707
2708
2709
2710
2711
2712
2713
2714
2715
2716
2717
2718
2719
2720
2721
2722
2723
2724
2725
2726
2727
2728
2729
2730
2731
2732
2733
2734
2735
2736
2737
2738
2739
2740
2741
2742
2743
2744
2745
2746
2747
2748
2749
2750
2751
2752
2753
2754
2755
2756
2757
2758
2759
2760
2761
2762
2763
2764
2765
2766
2767
2768
2769
2770
2771
2772
2773
2774
2775
2776
2777
2778
2779
2780
2781
2782
2783
2784
2785
2786
2787
2788
2789
2790
2791
2792
2793
2794
2795
2796
2797
2798
2799
2800
2801
2802
2803
2804
2805
2806
2807
2808
2809
2810
2811
2812
2813
2814
2815
2816
2817
2818
2819
2820
2821
2822
2823
2824
2825
2826
2827
2828
2829
2830
2831
2832
2833
2834
2835
2836
2837
2838
2839
2840
2841
2842
2843
2844
2845
2846
2847
2848
2849
2850
2851
2852
2853
2854
2855
2856
2857
2858
2859
2860
2861
2862
2863
2864
2865
2866
2867
2868
2869
2870
2871
2872
2873
2874
2875
2876
2877
2878
2879
2880
2881
2882
2883
2884
2885
2886
2887
2888
2889
2890
2891
2892
2893
2894
2895
2896
2897
2898
2899
2900
2901
2902
2903
2904
2905
2906
2907
2908
2909
2910
2911
2912
2913
2914
2915
2916
2917
2918
2919
2920
2921
2922
2923
2924
2925
2926
2927
2928
2929
2930
2931
2932
2933
2934
2935
2936
2937
2938
2939
2940
2941
2942
2943
2944
2945
2946
2947
2948
2949
2950
2951
2952
2953
2954
2955
2956
2957
2958
2959
2960
2961
2962
2963
2964
2965
2966
2967
2968
2969
2970
2971
2972
2973
2974
2975
2976
2977
2978
2979
2980
2981
2982
2983
2984
2985
2986
2987
2988
2989
2990
2991
2992
2993
2994
2995
2996
2997
2998
2999
3000
3001
3002
3003
3004
3005
3006
3007
3008
3009
3010
3011
3012
3013
3014
3015
3016
3017
3018
3019
3020
3021
3022
3023
3024
3025
3026
3027
3028
3029
3030
3031
3032
3033
3034
3035
3036
3037
3038
3039
3040
3041
3042
3043
3044
3045
3046
3047
3048
3049
3050
3051
3052
3053
3054
3055
3056
3057
3058
3059
3060
3061
3062
3063
3064
3065
3066
3067
3068
3069
3070
3071
3072
3073
3074
3075
3076
3077
3078
3079
3080
3081
3082
3083
3084
3085
3086
3087
3088
3089
3090
3091
3092
3093
3094
3095
3096
3097
3098
3099
3100
3101
3102
3103
3104
3105
3106
3107
3108
3109
3110
3111
3112
3113
3114
3115
3116
3117
3118
3119
3120
3121
3122
3123
3124
3125
3126
3127
3128
3129
3130
3131
3132
3133
3134
3135
3136
3137
3138
3139
3140
3141
3142
3143
3144
3145
3146
3147
3148
3149
3150
3151
3152
3153
3154
3155
3156
3157
3158
3159
3160
3161
3162
3163
3164
3165
3166
3167
3168
3169
3170
3171
3172
3173
3174
3175
3176
3177
3178
3179
3180
3181
3182
3183
3184
3185
3186
3187
3188
3189
3190
3191
3192
3193
3194
3195
3196
3197
3198
3199
3200
3201
3202
3203
3204
3205
3206
3207
3208
3209
3210
3211
3212
3213
3214
3215
3216
3217
3218
3219
3220
3221
3222
3223
3224
3225
3226
3227
3228
3229
3230
3231
3232
3233
3234
3235
3236
3237
3238
3239
3240
3241
3242
3243
3244
3245
3246
3247
3248
3249
3250
3251
3252
3253
3254
3255
3256
3257
3258
3259
3260
3261
3262
3263
3264
3265
3266
3267
class BiosyntheticAssigner:
    """Assigns candidate biosynthetic `Loci` to `Glycan` structures and maps individual genes to linkages/residues."""

    def __init__(
        self,
        aligner: BiosyntheticAligner | None = None,
        sugars_db: type[Sugars] | Sugars = Sugars,
        pan_prior: PanGraphConsensusPrior | None = None,
        classifier_suite: Any | None = None,
    ) -> None:
        """Initialize `BiosyntheticAssigner` with an underlying `BiosyntheticAligner` and optional `PanGraphConsensusPrior`."""
        self.pan_prior = pan_prior
        self.classifier_suite = classifier_suite
        if self.pan_prior is not None and classifier_suite is not None:
            self.pan_prior.classifier_suite = classifier_suite
        self.aligner = aligner if aligner is not None else BiosyntheticAligner(sugars_db=sugars_db, pan_prior=pan_prior)
        self.registry = BiosyntheticRuleRegistry(sugars_db=sugars_db)

    _LPS_PRIMARY_INIT_SUGARS: frozenset[bytes] = frozenset(
        {
            b"GlcNAc",
            b"GalNAc",
            b"QuiNAc",
            b"FucNAc",
            b"FucN4N",
            b"QuiN4N",
            b"Bac",
            b"RhaNAc",
            b"ManNAc",
            b"6dAltNAc",
            b"6dTalNAc",
        }
    )

    _CPS_PRIMARY_INIT_SUGARS: frozenset[bytes] = frozenset(
        {
            b"Gal",
            b"Glc",
            b"GlcNAc",
            b"GalNAc",
            b"QuiNAc",
            b"FucNAc",
            b"FucN4N",
            b"QuiN4N",
            b"Bac",
        }
    )

    def resolve_biological_reducing_end(
        self,
        glycan: Glycan,
        locus: Locus | None = None,
        archetype: LocusArchetype = LocusArchetype.UNKNOWN,
        pan_prior: PanGraphConsensusPrior | None = None,
    ) -> Glycan:
        """Rotate `glycan` reducing end (`*Sugar`) to the true biological Und-PP initiating monosaccharide.

        Uses `INITIATOR` homology `cluster_id` consensus from `pan_prior` (`WbaP` -> `D-Galp`, `WcaJ` -> `D-Glcp`,
        `WecA` -> `D-GlcNAc`/`D-GalNAc`, `Itr`/`PglC` -> `D-QuiNAc`/`D-FucNAc`/`D-Gal`) together with
        archetype-specific Und-PP transferase biochemistry to resolve arbitrary literature repeat frames.
        """
        if len(glycan.nodes) <= 1 or not glycan.has_polymer_repeat:
            return glycan

        cycle = glycan.cycle_nodes()
        if not cycle:
            return glycan

        active_prior = pan_prior if pan_prior is not None else self.pan_prior
        curr_red = glycan.reducing_end_index
        cycle_sugars = {glycan.nodes[u].sugar for u in cycle}

        pref_init_sugar: int | None = None
        wzy_cluster_id: int = 0
        if locus is not None and len(locus.nodes) > 0 and active_prior is not None:
            for g_node in locus.nodes:
                if g_node.role == GeneRole.INITIATOR and g_node.cluster_id > 0 and pref_init_sugar is None:
                    pref_init_sugar = active_prior.preferred_init_sugar(int(g_node.cluster_id), cycle_sugars)
                elif g_node.role == GeneRole.POLYMERASE and g_node.cluster_id > 0 and wzy_cluster_id == 0:
                    wzy_cluster_id = int(g_node.cluster_id)

        eff_arch = locus.archetype if (locus is not None and archetype == LocusArchetype.UNKNOWN) else archetype
        init_compat = self.registry.get_rule(GeneRole.INITIATOR).compatible_sugars
        cycle_set = set(cycle)

        def _score_cycle_node(u: int) -> tuple[float, int]:
            s_idx = glycan.nodes[u].sugar
            s_name = Sugars.names[s_idx]
            sc = 0.0

            if pref_init_sugar is not None and s_idx == pref_init_sugar:
                sc += 8.0

            if eff_arch in (LocusArchetype.LPS_WZY, LocusArchetype.LPS_ABC):
                if s_name in self._LPS_PRIMARY_INIT_SUGARS:
                    sc += 4.5
                elif s_name in (b"Gal", b"Glc"):
                    sc += 3.0
                elif s_idx in init_compat:
                    sc += 1.0
            elif eff_arch == LocusArchetype.CPS_WZY:
                if s_name in self._CPS_PRIMARY_INIT_SUGARS:
                    sc += 4.5
                elif s_idx in init_compat:
                    sc += 1.0
            else:
                if s_name in self._LPS_PRIMARY_INIT_SUGARS:
                    sc += 4.2
                elif s_name in (b"Gal", b"Glc"):
                    sc += 3.8
                elif s_idx in init_compat:
                    sc += 1.0

            if u == curr_red:
                sc += 0.35

            if active_prior is not None and wzy_cluster_id > 0:
                for k_e, e_obj in enumerate(glycan.edges):
                    if e_obj.source == u and e_obj.target in cycle_set:
                        wzy_b, _ = active_prior.gt_edge_bonus(wzy_cluster_id, glycan, k_e)
                        sc += -2.0 * wzy_b
                        break

            return (sc, -u)

        best_u = max(cycle, key=_score_cycle_node)
        return glycan.with_reducing_end(best_u)

    @staticmethod
    def infer_expected_archetype(
        antigen_hint: bytes | str = b"",
    ) -> tuple[ArchetypeCategory | None, LocusArchetype | None]:
        """Infer expected `ArchetypeCategory` and optional specific `LocusArchetype` from an antigen hint string."""
        hint = antigen_hint.decode("utf-8", errors="replace") if isinstance(antigen_hint, bytes) else str(antigen_hint)
        h = hint.strip().lower()
        if not h:
            return None, None

        # Specific archetype matches first
        if "group 2" in h or "group 3" in h or "kps" in h or "cps_abc" in h:
            return ArchetypeCategory.CAPSULE, LocusArchetype.CPS_ABC
        if "group 1" in h or "group 4" in h or "cps_wzy" in h:
            return ArchetypeCategory.CAPSULE, LocusArchetype.CPS_WZY
        if "synthase" in h or "cps_synthase" in h or "hyaluron" in h:
            return ArchetypeCategory.CAPSULE, LocusArchetype.CPS_SYNTHASE
        if "lps_abc" in h or "wzm" in h:
            return ArchetypeCategory.O_ANTIGEN, LocusArchetype.LPS_ABC
        if "lps_wzy" in h:
            return ArchetypeCategory.O_ANTIGEN, LocusArchetype.LPS_WZY
        if "teichoic" in h or "wta" in h or "lta" in h:
            return ArchetypeCategory.OTHER, LocusArchetype.TEICHOIC_ACID
        if "alginate" in h:
            return ArchetypeCategory.EXOPOLYSACCHARIDE, LocusArchetype.EPS_ALGINATE
        if "cellulose" in h or "bcs" in h:
            return ArchetypeCategory.EXOPOLYSACCHARIDE, LocusArchetype.EPS_BCS
        if "pga" in h or "pnag" in h or "ica" in h:
            return ArchetypeCategory.EXOPOLYSACCHARIDE, LocusArchetype.EPS_PGA
        if "pel" in h:
            return ArchetypeCategory.EXOPOLYSACCHARIDE, LocusArchetype.EPS_PEL

        # High-level category matches
        if any(k in h for k in ("cps", "k-antigen", "k antigen", "capsul", "vi-antigen", "vi antigen")):
            return ArchetypeCategory.CAPSULE, None
        if any(
            k in h for k in ("o-antigen", "o antigen", "ops", "lps", "los", "lipopolysaccharide", "lipooligosaccharide")
        ):
            return ArchetypeCategory.O_ANTIGEN, None
        if any(k in h for k in ("eps", "exopolysaccharide", "biofilm")):
            return ArchetypeCategory.EXOPOLYSACCHARIDE, None

        return None, None

    _PATHWAY_NACETYL_SUGARS: frozenset[bytes] = frozenset(
        {
            b"QuiNAc",
            b"FucNAc",
            b"RhaNAc",
            b"6dAltNAc",
            b"6dTalNAc",
            b"Pse",
            b"4ePse",
            b"8ePse",
            b"Leg",
            b"4eLeg",
            b"8eLeg",
            b"Aci",
            b"8eAci",
            b"Bac",
            b"Neu5Ac",
        }
    )

    _COMPLETABLE_MODIFIER_ROLES: tuple[tuple[GeneRole, bytes], ...] = (
        (GeneRole.PYRUVYLTRANSFERASE, b"Pyr"),
        (GeneRole.METHYLTRANSFERASE, b"OMe"),
        (GeneRole.FORMYLTRANSFERASE, b"Fo"),
        (GeneRole.ALANINE_TRANSFERASE, b"DAla"),
        (GeneRole.PHOSPHOTRANSFERASE, b"P"),
        (GeneRole.ACETYLTRANSFERASE, b"OAc"),
    )

    @staticmethod
    def _select_modifier_acceptor(
        nodes: Sequence[ResidueType],
        edges: Sequence[LinkageEdge],
        role: GeneRole,
        mod_name: bytes,
    ) -> int:
        """Select the most biochemically compatible core monosaccharide index in `nodes` for `mod_name`."""
        core_indices = [idx for idx, n in enumerate(nodes) if not n.is_modifier]
        if not core_indices:
            return 0

        # Track incoming glycosidic linkages and existing modifier attachments per core residue
        occupied_carbons: dict[int, set[int]] = {idx: set() for idx in core_indices}
        mod_counts: dict[int, int] = {idx: 0 for idx in core_indices}
        same_mod_counts: dict[int, int] = {idx: 0 for idx in core_indices}
        out_non_mod: dict[int, int] = {idx: 0 for idx in core_indices}
        in_non_mod: dict[int, int] = {idx: 0 for idx in core_indices}
        mod_s_idx = Sugars.name_to_index.get(mod_name, -1)

        for e in edges:
            if e.target in occupied_carbons and e.edge_type.acceptor > 0:
                occupied_carbons[e.target].add(e.edge_type.acceptor)
            if 0 <= e.source < len(nodes) and 0 <= e.target < len(nodes):
                s_res = nodes[e.source]
                t_res = nodes[e.target]
                if s_res.is_modifier and e.target in mod_counts:
                    mod_counts[e.target] += 1
                    if s_res.sugar == mod_s_idx:
                        same_mod_counts[e.target] += 1
                elif not s_res.is_modifier and not t_res.is_modifier:
                    if e.source in out_non_mod and e.source != 0:
                        out_non_mod[e.source] += 1
                    if e.target in in_non_mod and e.source != 0:
                        in_non_mod[e.target] += 1

        def _score_candidate(u: int) -> tuple[int, int, int, int, int]:
            res = nodes[u]
            s_name = Sugars.names[res.sugar]
            s_cls = Sugars.classes[res.sugar]
            occ = occupied_carbons[u]
            free_c4_c6 = int(4 not in occ) + int(6 not in occ)
            is_branch_tip = int(in_non_mod[u] == 0 and not res.is_reducing_end)

            biochem_pref = 0
            if role == GeneRole.PYRUVYLTRANSFERASE:
                if s_name in (b"Gal", b"Glc", b"Man", b"GlcA", b"GalA", b"GalNAc", b"GlcNAc"):
                    biochem_pref = 3 if free_c4_c6 >= 2 else (2 if free_c4_c6 == 1 else 1)
                elif s_cls in (b"Hex", b"HexA", b"HexNAc"):
                    biochem_pref = 1
            elif role == GeneRole.FORMYLTRANSFERASE:
                if s_name in (b"QuiN4N", b"FucN4N", b"RhaN3N", b"QuiN", b"FucN", b"RhaN", b"GlcN", b"GalN"):
                    biochem_pref = 4
                elif b"N" in s_name or s_cls in (b"dHexN", b"HexN", b"dHexNAc", b"HexNAc"):
                    biochem_pref = 2
            elif role == GeneRole.ALANINE_TRANSFERASE:
                if s_name == b"Gro" or s_cls in (b"HexA", b"HexNA"):
                    biochem_pref = 3
                elif s_cls in (b"Hex", b"HexNAc"):
                    biochem_pref = 1
            elif role == GeneRole.PHOSPHOTRANSFERASE:
                if s_name in (b"Gro", b"Gal", b"Glc", b"Man", b"GlcNAc", b"GalNAc") and free_c4_c6 >= 1:
                    biochem_pref = 3
                elif s_cls in (b"Hex", b"HexNAc", b"Pen"):
                    biochem_pref = 1
            else:
                # OAc / OMe: prefer core hexoses, deoxyhexoses, and N-acetylhexosamines with free hydroxyls
                if s_cls in (b"Hex", b"dHex", b"HexNAc", b"dHexNAc", b"HexA"):
                    biochem_pref = 2

            return (
                -same_mod_counts[u],
                biochem_pref,
                -mod_counts[u],
                is_branch_tip,
                free_c4_c6 - len(occ),
            )

        return max(core_indices, key=_score_candidate)

    def complete_glycan_modifiers(
        self,
        glycan: Glycan,
        locus: Locus | None,
        pan_prior: PanGraphConsensusPrior | None = None,
    ) -> tuple[Glycan, tuple[bytes, ...], dict[int, int]]:
        """Artificially complete `glycan` with modifier nodes inferred from excess modifier genes in `locus`.

        Subtracts 1 `ACETYLTRANSFERASE` per specialized N-acetylated sugar pathway (`QuiNAc`, `FucNAc`,
        `RhaNAc`, `Pse`, `Leg`, `Aci`, `Bac`, `Neu5Ac`) before adding `OAc` modifier nodes, while
        housekeeping `GlcNAc`/`GalNAc`/`ManNAc` consume 0 locus acetyltransferases.

        Returns:
            tuple[Glycan, tuple[bytes, ...], dict[int, int]]:
                - Completed `Glycan` graph (or original `glycan` if no modifiers were added).
                - Tuple of added modifier byte names (e.g. `(b"OAc", b"Pyr")`).
                - Mapping `{new_modifier_node_index: locus_gene_index}` for `LOCUS_COMPLETED` assignments.
        """
        if locus is None or len(locus.nodes) == 0 or len(glycan.nodes) == 0:
            return glycan, (), {}

        core_indices = [idx for idx, n in enumerate(glycan.nodes) if not n.is_modifier]
        if not core_indices:
            return glycan, (), {}

        n_acetyl_mod_nodes, _ = self._find_n_acetyl_nodes(glycan)
        pathway_act_sugars = {
            Sugars.names[n.sugar]
            for n in glycan.nodes
            if not n.is_modifier and Sugars.names[n.sugar] in self._PATHWAY_NACETYL_SUGARS
        }
        n_pathway_act = len(pathway_act_sugars)

        new_nodes = list(glycan.nodes)
        new_edges = list(glycan.edges)
        completed_names: list[bytes] = []
        completed_node_to_gene: dict[int, int] = {}

        for role, mod_bytes in self._COMPLETABLE_MODIFIER_ROLES:
            if mod_bytes not in Sugars.name_to_index:
                continue
            rule = self.registry.get_rule(role)
            role_gene_indices = [g_i for g_i, g_node in enumerate(locus.nodes) if g_node.role == role]
            if not role_gene_indices:
                continue

            existing_mods = [
                j
                for j, res in enumerate(new_nodes)
                if res.is_modifier and j not in n_acetyl_mod_nodes and res.sugar in rule.target_modifiers
            ]
            accounted = len(existing_mods) + (n_pathway_act if role == GeneRole.ACETYLTRANSFERASE else 0)
            if len(role_gene_indices) <= accounted:
                continue

            excess_genes = role_gene_indices[accounted:]
            mod_sugar_idx = Sugars.name_to_index[mod_bytes]
            for g_idx in excess_genes:
                best_u = self._select_modifier_acceptor(new_nodes, new_edges, role, mod_bytes)
                mod_edge_type = LinkageType(Anomer.UNKNOWN, donor=0, acceptor=0)
                cid_mod = int(locus.nodes[g_idx].cluster_id)
                if pan_prior is not None and cid_mod > 0:
                    spec_map = pan_prior.modifier_cluster_spec.get(cid_mod) or pan_prior.modifier_cluster_spec.get(
                        pan_prior.fine_to_med.get(cid_mod, 0), {}
                    )
                    if spec_map:
                        occupied_on: dict[int, set[int]] = {u_i: set() for u_i in core_indices}
                        for e_ex in new_edges:
                            if e_ex.target in occupied_on and e_ex.edge_type.acceptor > 0:
                                occupied_on[e_ex.target].add(int(e_ex.edge_type.acceptor))
                        for (m_sug, acc_sug, lnk_int), _w_m in sorted(
                            spec_map.items(), key=lambda kv: kv[1], reverse=True
                        ):
                            if m_sug != mod_sugar_idx:
                                continue
                            cand_lt = LinkageType(int(lnk_int)).with_topology(is_backbone=False, is_polymer=False)
                            matching_u = [
                                u_i
                                for u_i in core_indices
                                if int(new_nodes[u_i].sugar) == acc_sug
                                and (cand_lt.acceptor == 0 or int(cand_lt.acceptor) not in occupied_on.get(u_i, set()))
                            ]
                            if not matching_u:
                                matching_u = [u_i for u_i in core_indices if int(new_nodes[u_i].sugar) == acc_sug]
                            if matching_u:
                                best_u = matching_u[0]
                                mod_edge_type = cand_lt
                                break
                new_mod_idx = len(new_nodes)
                new_nodes.append(ResidueType.from_sugar(mod_sugar_idx, Chirality.UNKNOWN, False))
                new_edges.append(
                    LinkageEdge(
                        source=new_mod_idx,
                        target=best_u,
                        edge_type=mod_edge_type,
                    )
                )
                completed_names.append(mod_bytes)
                completed_node_to_gene[new_mod_idx] = g_idx

        if not completed_names:
            return glycan, (), {}

        completed_glycan = Glycan(
            canonical=glycan.canonical,
            nodes=tuple(new_nodes),
            edges=tuple(new_edges),
        )
        return completed_glycan, tuple(completed_names), completed_node_to_gene

    def rescue_cassette_roles(
        self,
        locus: Locus,
        glycan: Glycan,
        archetype: LocusArchetype = LocusArchetype.UNKNOWN,
        is_repeating: bool = True,
        pan_cluster_counts: dict[int, int] | None = None,
    ) -> Locus:
        """Promote variable-cassette `UNKNOWN` or duplicate non-conserved transport/hydrolase ORFs to `GT`/`POLYMERASE` when `locus` has a linkage deficit."""
        if len(locus.nodes) == 0 or len(glycan.nodes) == 0:
            return locus

        core_edges = [
            k
            for k, e in enumerate(glycan.edges)
            if not glycan.nodes[e.source].is_modifier and not glycan.nodes[e.target].is_modifier
        ]
        n_core = len(core_edges)
        if n_core == 0:
            return locus

        eff_arch = locus.archetype if archetype == LocusArchetype.UNKNOWN else archetype
        is_synthase_arch = eff_arch in (
            LocusArchetype.CPS_SYNTHASE,
            LocusArchetype.EPS_ALGINATE,
            LocusArchetype.EPS_BCS,
            LocusArchetype.EPS_PGA,
            LocusArchetype.EPS_PEL,
        )
        if is_synthase_arch:
            return locus

        is_wzy_arch = (
            eff_arch in (LocusArchetype.LPS_WZY, LocusArchetype.CPS_WZY, LocusArchetype.UNKNOWN)
            and is_repeating
            and n_core >= 2
        )

        gt_indices = [i for i, nd in enumerate(locus.nodes) if nd.role == GeneRole.GLYCOSYLTRANSFERASE]
        poly_indices = [i for i, nd in enumerate(locus.nodes) if nd.role == GeneRole.POLYMERASE]
        synth_indices = [i for i, nd in enumerate(locus.nodes) if nd.role == GeneRole.TRANSMEMBRANE_SYNTHASE]
        n_link_genes = len(gt_indices) + len(poly_indices) + len(synth_indices)

        deficit = max(0, n_core - n_link_genes)
        if deficit == 0:
            return locus

        cassette_anchors = [
            i
            for i, nd in enumerate(locus.nodes)
            if nd.role
            in (
                GeneRole.GLYCOSYLTRANSFERASE,
                GeneRole.POLYMERASE,
                GeneRole.INITIATOR,
                GeneRole.FLIPPASE,
            )
        ]
        if not cassette_anchors:
            cassette_anchors = list(range(len(locus.nodes)))

        gene_lengths = locus.lengths
        counts_map = pan_cluster_counts or {}

        # Identify duplicate non-conserved FLIPPASE / CAPSULE_ANCHOR ORFs when conserved backbone hubs exist
        dup_rescue_set: set[int] = set()
        for dup_role in (GeneRole.FLIPPASE, GeneRole.CAPSULE_ANCHOR):
            role_idxs = [i for i, nd in enumerate(locus.nodes) if nd.role == dup_role]
            if len(role_idxs) >= 2:
                role_idxs.sort(
                    key=lambda idx_g: (
                        counts_map.get(int(locus.nodes[idx_g].cluster_id), 0),
                        int(gene_lengths[idx_g]) if idx_g < len(gene_lengths) else 1000,
                    ),
                    reverse=True,
                )
                for extra_i in role_idxs[1:]:
                    if counts_map.get(int(locus.nodes[extra_i].cluster_id), 0) <= 8:
                        dup_rescue_set.add(extra_i)

        candidates: list[tuple[int, int, int, int, int]] = []
        for i, nd in enumerate(locus.nodes):
            bp_len = int(gene_lengths[i]) if i < len(gene_lengths) else 1000
            if bp_len < 420:
                continue
            c_freq = counts_map.get(int(nd.cluster_id), 0)
            dist = min(abs(i - a_i) for a_i in cassette_anchors)
            if nd.role == GeneRole.UNKNOWN and c_freq <= 12:
                candidates.append((0, dist, c_freq, -bp_len, i))
            elif nd.role == GeneRole.GLYCOSYLHYDROLASE and c_freq <= 8:
                candidates.append((1, dist, c_freq, -bp_len, i))
            elif i in dup_rescue_set:
                candidates.append((2, dist, c_freq, -bp_len, i))

        if not candidates:
            return locus

        candidates.sort()
        selected = candidates[:deficit]
        new_nodes = list(locus.nodes)
        has_poly = len(poly_indices) >= 1

        for _, _, _, _, g_idx in selected:
            old_nd = new_nodes[g_idx]
            target_role = GeneRole.POLYMERASE if (is_wzy_arch and not has_poly) else GeneRole.GLYCOSYLTRANSFERASE
            if target_role == GeneRole.POLYMERASE:
                has_poly = True
            new_nodes[g_idx] = GeneNode(
                target_role,
                cluster_id=int(old_nd.cluster_id),
                is_frameshift=old_nd.is_frameshift,
            )

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

    @staticmethod
    def _find_locus_completed_mod_nodes(glycan: Glycan) -> set[int]:
        """Identify modifier node indices attached via locus-completed wildcard linkages (`donor=0, acceptor=0`)."""
        completed_nodes: set[int] = set()
        for e in glycan.edges:
            if (
                0 <= e.source < len(glycan.nodes)
                and glycan.nodes[e.source].is_modifier
                and e.edge_type.donor == 0
                and e.edge_type.acceptor == 0
                and e.edge_type.anomer == Anomer.UNKNOWN
            ):
                completed_nodes.add(e.source)
        return completed_nodes

    def assign_genes(
        self,
        locus: Locus,
        glycan: Glycan,
        alignment: Alignment | None = None,
        completed_modifier_genes: dict[int, int] | None = None,
        pan_prior: PanGraphConsensusPrior | None = None,
    ) -> tuple[GeneGlycanAssignment, ...]:
        """Map active biosynthetic genes in `locus` to their aligned residues or linkages in `glycan`."""
        if len(locus.nodes) == 0 or len(glycan.nodes) == 0:
            return ()

        active_prior = pan_prior if pan_prior is not None else self.pan_prior
        if alignment is None:
            if (
                active_prior is not None
                and isinstance(self.aligner.cost_strategy, BiosyntheticCostStrategy)
                and self.aligner.cost_strategy.pan_prior is not active_prior
            ):
                temp_aligner = BiosyntheticAligner(
                    config=self.aligner.config,
                    embedding_strategy=self.aligner.embedding_strategy,
                    pan_prior=active_prior,
                )
                alignment = temp_aligner.align(locus, glycan)
            else:
                alignment = self.aligner.align(locus, glycan)

        _, cluster_labels = locus.as_graph(as_bipartite=True)
        _, glycan_labels = glycan.as_graph(as_bipartite=True)
        n_res = len(glycan.nodes)
        n_genes = len(locus.nodes)

        cost_strat = BiosyntheticCostStrategy(rule_registry=self.registry, pan_prior=active_prior)
        c_mat = cost_strat.compute_cost(cluster_labels, glycan_labels, graph_a=locus, graph_b=glycan)

        completed_mod_nodes = self._find_locus_completed_mod_nodes(glycan)
        if completed_modifier_genes:
            completed_mod_nodes |= set(completed_modifier_genes.keys())
        gene_to_completed_mod: dict[int, int] = (
            {g_i: m_i for m_i, g_i in completed_modifier_genes.items()} if completed_modifier_genes else {}
        )

        out: list[GeneGlycanAssignment] = []
        matched_gene_indices: set[int] = set()
        matched_mod_nodes: set[int] = set()

        # 1. Explicit locus-completed modifier gene assignments first
        for g_idx, mod_idx in gene_to_completed_mod.items():
            if 0 <= g_idx < n_genes and 0 <= mod_idx < n_res:
                g_node = locus.nodes[g_idx]
                res = glycan.nodes[mod_idx]
                out.append(
                    GeneGlycanAssignment(
                        gene_index=int(g_idx),
                        role=g_node.role,
                        bipartite_glycan_index=int(mod_idx),
                        target_type=TargetType.MODIFIED_MONO,
                        residue=res,
                        linkage=None,
                        edge_index=None,
                        evidence=AssignmentEvidence.LOCUS_COMPLETED,
                        score=0.85,
                    )
                )
                matched_gene_indices.add(int(g_idx))
                matched_mod_nodes.add(int(mod_idx))

        # Pre-collect linkage matches and pan-graph support for calibrated Boltzmann swap marginals
        precursor_roles = {
            GeneRole.OXIDOREDUCTASE,
            GeneRole.EPIMERASE,
            GeneRole.AMINOTRANSFERASE,
            GeneRole.ALDOLASE,
            GeneRole.NUCLEOTIDASE,
            GeneRole.NUCLEOTIDYLTRANSFERASE,
        }
        gt_gene_indices = [
            idx_g
            for idx_g, nd in enumerate(locus.nodes)
            if nd.role in (GeneRole.GLYCOSYLTRANSFERASE, GeneRole.POLYMERASE)
        ]
        init_gene_indices = [idx_g for idx_g, nd in enumerate(locus.nodes) if nd.role == GeneRole.INITIATOR]
        prec_gene_indices = [idx_g for idx_g, nd in enumerate(locus.nodes) if nd.role in precursor_roles]
        core_sugars_in_glycan = {nd.sugar for nd in glycan.nodes if not nd.is_modifier}
        gt_prec_sugars: dict[int, set[int]] = {idx_g: set() for idx_g in gt_gene_indices}
        if active_prior is not None and gt_gene_indices and prec_gene_indices and core_sugars_in_glycan:
            sugar_to_prec_indices: dict[int, list[int]] = {}
            for p_i in prec_gene_indices:
                p_cid = int(locus.nodes[p_i].cluster_id)
                pref_s = active_prior.preferred_precursor_sugar(p_cid, core_sugars_in_glycan)
                if pref_s is not None:
                    sugar_to_prec_indices.setdefault(pref_s, []).append(p_i)
            for s_idx, p_list in sugar_to_prec_indices.items():
                dists = {g_i: min(abs(g_i - p_i) for p_i in p_list) for g_i in gt_gene_indices}
                min_d = min(dists.values())
                if min_d <= 3:
                    for g_i, d_val in dists.items():
                        if d_val == min_d:
                            gt_prec_sugars[g_i].add(s_idx)

        core_bipartite_cols = [
            n_res + k
            for k, e in enumerate(glycan.edges)
            if not glycan.nodes[e.source].is_modifier and not glycan.nodes[e.target].is_modifier
        ]
        linkage_matches: dict[int, int] = {}
        direct_pan_link_genes: set[int] = set()
        loc_translations = locus.genes.translations if len(locus.genes) > 0 else None

        for g_idx, gly_idx in alignment.matches.items():
            if (
                g_idx in matched_gene_indices
                or g_idx >= n_genes
                or g_idx >= len(cluster_labels)
                or gly_idx >= len(glycan_labels)
            ):
                continue
            g_val = int(cluster_labels[g_idx])
            gly_val = int(glycan_labels[gly_idx])
            if not (g_val & TAG_EDGE) and (gly_val & TAG_EDGE):
                linkage_matches[int(g_idx)] = int(gly_idx)
                edge_idx = int(gly_idx) - n_res
                if active_prior is not None and 0 <= edge_idx < len(glycan.edges):
                    gn_obj = GeneNode(g_val)
                    c_id = int(gn_obj.cluster_id)
                    aa_seq_g: bytes | None = (
                        bytes(loc_translations[g_idx].seq)
                        if (loc_translations is not None and g_idx < len(loc_translations))
                        else None
                    )
                    adj_clusters: list[tuple[int, int]] = []
                    for other_i in gt_gene_indices:
                        if other_i < g_idx and (g_idx - other_i) <= 4:
                            adj_clusters.append((int(locus.nodes[other_i].cluster_id), -1))
                        elif other_i > g_idx and (other_i - g_idx) <= 4:
                            adj_clusters.append((int(locus.nodes[other_i].cluster_id), +1))
                    adj_init = [
                        int(locus.nodes[init_i].cluster_id)
                        for init_i in init_gene_indices
                        if abs(g_idx - init_i) <= 2 and locus.nodes[init_i].cluster_id > 0
                    ]
                    adj_prec = [
                        int(locus.nodes[prec_i].cluster_id)
                        for prec_i in prec_gene_indices
                        if abs(g_idx - prec_i) <= 3 and locus.nodes[prec_i].cluster_id > 0
                    ]
                    _, has_pan = active_prior.gt_edge_bonus(
                        c_id,
                        glycan,
                        edge_idx,
                        adj_clusters=adj_clusters,
                        adj_init_clusters=adj_init,
                        adj_precursor_clusters=adj_prec,
                        adj_precursor_sugars=gt_prec_sugars.get(int(g_idx), set()),
                        aa_seq=aa_seq_g,
                    )
                    if has_pan:
                        direct_pan_link_genes.add(int(g_idx))

        # Stoichiometric elimination: if all remaining unresolved intra-unit GTs map to edges with a single
        # distinct (donor, linkage) signature (and Wzy locks the polymer edge), they are uniquely resolved
        wzy_edge_k, _, _ = glycan.polymer_edge_and_assembly_depths()
        wzy_bip_col = (n_res + wzy_edge_k) if wzy_edge_k is not None else -1
        has_wzy_locked = any(
            locus.nodes[g_i].role == GeneRole.POLYMERASE and b_i == wzy_bip_col for g_i, b_i in linkage_matches.items()
        )
        for g_i, b_i in linkage_matches.items():
            if locus.nodes[g_i].role == GeneRole.POLYMERASE and b_i == wzy_bip_col:
                direct_pan_link_genes.add(g_i)

        unresolved_gt_genes = [
            g_i
            for g_i, b_i in linkage_matches.items()
            if g_i not in direct_pan_link_genes
            and not (locus.nodes[g_i].role == GeneRole.POLYMERASE and b_i == wzy_bip_col)
        ]
        unmatched_core_cols = [col for col in core_bipartite_cols if col not in linkage_matches.values()]
        matched_dl_sigs = {
            _edge_signatures(glycan, b_i - n_res)[1]
            for b_i in linkage_matches.values()
            if 0 <= (b_i - n_res) < len(glycan.edges)
        }
        unmatched_distinct_cols = [
            col
            for col in unmatched_core_cols
            if 0 <= (col - n_res) < len(glycan.edges)
            and _edge_signatures(glycan, col - n_res)[1] not in matched_dl_sigs
        ]
        unresolved_dl_sigs = {
            _edge_signatures(glycan, linkage_matches[g_i] - n_res)[1]
            for g_i in unresolved_gt_genes
            if 0 <= (linkage_matches[g_i] - n_res) < len(glycan.edges)
        }
        if len(gt_gene_indices) == 1 or (
            len(unresolved_gt_genes) >= 1
            and len(unresolved_dl_sigs) == 1
            and len(unmatched_distinct_cols) == 0
            and (len(direct_pan_link_genes) >= 1 or has_wzy_locked)
        ):
            direct_pan_link_genes.update(unresolved_gt_genes)

        for g_idx, gly_idx in alignment.matches.items():
            if (
                g_idx in matched_gene_indices
                or g_idx >= n_genes
                or g_idx >= len(cluster_labels)
                or gly_idx >= len(glycan_labels)
            ):
                continue
            g_val = int(cluster_labels[g_idx])
            gly_val = int(glycan_labels[gly_idx])
            if g_val & TAG_EDGE:
                continue

            g_node = GeneNode(g_val)
            role = g_node.role
            if role == GeneRole.UNKNOWN:
                continue
            rule = self.registry.get_rule(role)
            if rule.target_type == TargetType.NONE:
                continue

            raw_cost = alignment.costs.get(g_idx, 0.20)
            base_quality = (
                float(np.clip(np.exp(-float(raw_cost)), 0.0, 1.0))
                if (raw_cost is not None and np.isfinite(raw_cost) and raw_cost < 1e4)
                else 0.5
            )

            if gly_val & TAG_EDGE:
                edge_idx = int(gly_idx) - n_res
                c_ii = (
                    float(c_mat[g_idx, gly_idx])
                    if np.isfinite(c_mat[g_idx, gly_idx]) and c_mat[g_idx, gly_idx] < 1e4
                    else 0.25
                )
                sig_i = _edge_signatures(glycan, edge_idx)[1] if 0 <= edge_idx < len(glycan.edges) else ()

                alt_weight_sum = 0.0
                tau = 0.085
                for g_k, b_k in linkage_matches.items():
                    if g_k == g_idx:
                        continue
                    edge_k = b_k - n_res
                    sig_k = _edge_signatures(glycan, edge_k)[1] if 0 <= edge_k < len(glycan.edges) else ()
                    if sig_i and sig_i == sig_k:
                        continue
                    c_kk = float(c_mat[g_k, b_k]) if np.isfinite(c_mat[g_k, b_k]) else 0.25
                    c_ik = float(c_mat[g_idx, b_k]) if np.isfinite(c_mat[g_idx, b_k]) else 2.0
                    c_ki = float(c_mat[g_k, gly_idx]) if np.isfinite(c_mat[g_k, gly_idx]) else 2.0
                    delta_swap = max(0.0, (c_ik + c_ki) - (c_ii + c_kk))
                    alt_weight_sum += float(np.exp(-min(delta_swap / tau, 30.0)))

                for b_u in unmatched_core_cols:
                    edge_u = b_u - n_res
                    sig_u = _edge_signatures(glycan, edge_u)[1] if 0 <= edge_u < len(glycan.edges) else ()
                    if sig_i and sig_i == sig_u:
                        continue
                    c_iu = float(c_mat[g_idx, b_u]) if np.isfinite(c_mat[g_idx, b_u]) else 2.0
                    delta_move = max(0.0, c_iu - c_ii)
                    alt_weight_sum += float(np.exp(-min(delta_move / tau, 30.0)))

                marginal_prob = 1.0 / (1.0 + alt_weight_sum)
                is_pan = int(g_idx) in direct_pan_link_genes
                if is_pan:
                    marginal_prob = max(marginal_prob, 0.85)
                match_score = float(np.clip(0.30 * np.exp(-c_ii) + 0.70 * marginal_prob, 0.10, 0.99))
                ev_link = AssignmentEvidence.PAN_GRAPH_CONSENSUS if is_pan else AssignmentEvidence.GRAPH_ALIGNED

                out.append(
                    GeneGlycanAssignment(
                        gene_index=int(g_idx),
                        role=role,
                        bipartite_glycan_index=int(gly_idx),
                        target_type=TargetType.LINKAGE,
                        residue=None,
                        linkage=LinkageType(gly_val),
                        edge_index=edge_idx if 0 <= edge_idx < len(glycan.edges) else None,
                        evidence=ev_link,
                        score=match_score,
                    )
                )
                matched_gene_indices.add(int(g_idx))
            else:
                if int(gly_idx) in matched_mod_nodes:
                    continue
                res = ResidueType(gly_val)
                t_type = (
                    TargetType.MODIFIED_MONO
                    if res.is_modifier
                    else (TargetType.REDUCING_END if res.is_reducing_end else TargetType.MONOSACCHARIDE)
                )
                if int(gly_idx) in completed_mod_nodes:
                    ev = AssignmentEvidence.LOCUS_COMPLETED
                    node_score = 0.85
                elif (
                    t_type == TargetType.REDUCING_END
                    and active_prior is not None
                    and active_prior.has_init_consensus(int(g_node.cluster_id), res.sugar)
                ):
                    ev = AssignmentEvidence.PAN_GRAPH_CONSENSUS
                    node_score = max(base_quality, 0.96)
                else:
                    ev = AssignmentEvidence.GRAPH_ALIGNED
                    node_score = base_quality
                out.append(
                    GeneGlycanAssignment(
                        gene_index=int(g_idx),
                        role=role,
                        bipartite_glycan_index=int(gly_idx),
                        target_type=t_type,
                        residue=res,
                        linkage=None,
                        edge_index=None,
                        evidence=ev,
                        score=node_score,
                    )
                )
                matched_gene_indices.add(int(g_idx))
                if res.is_modifier:
                    matched_mod_nodes.add(int(gly_idx))

        # Processive / bifunctional GT & transmembrane synthase multi-linkage completion for remaining core edges
        if unmatched_core_cols:
            cand_multi_genes = [
                idx_g
                for idx_g, nd in enumerate(locus.nodes)
                if nd.role in (GeneRole.GLYCOSYLTRANSFERASE, GeneRole.TRANSMEMBRANE_SYNTHASE)
            ]
            if cand_multi_genes:
                gene_lengths = locus.lengths
                term_leaves = set(glycan.terminal_leaf_indices())
                for b_u in unmatched_core_cols:
                    edge_u = b_u - n_res
                    if not (0 <= edge_u < len(glycan.edges)):
                        continue
                    if b_u == wzy_bip_col and has_wzy_locked:
                        continue
                    e_u = glycan.edges[edge_u]
                    _, sig_dl_u, sig_da_u, _ = _edge_signatures(glycan, edge_u)

                    def _score_multi_gt(
                        g_c: int,
                        _b_u: int = b_u,
                        _e_u: Any = e_u,
                        _sig_dl_u: Any = sig_dl_u,
                        _sig_da_u: Any = sig_da_u,
                    ) -> tuple[int, int, int, float, int, int]:
                        unmatched_g = int(g_c not in linkage_matches)
                        b_p = linkage_matches.get(g_c, -1)
                        edge_p = b_p - n_res
                        has_p = 0 <= edge_p < len(glycan.edges)
                        same_dl = int(has_p and _edge_signatures(glycan, edge_p)[1] == _sig_dl_u)
                        same_da = int(has_p and _edge_signatures(glycan, edge_p)[2] == _sig_da_u)
                        is_adj = int(
                            has_p
                            and (
                                glycan.edges[edge_p].target == _e_u.source or glycan.edges[edge_p].source == _e_u.target
                            )
                        )
                        c_val = (
                            float(c_mat[g_c, _b_u])
                            if np.isfinite(c_mat[g_c, _b_u])
                            else (float(c_mat[g_c, b_p]) if (has_p and np.isfinite(c_mat[g_c, b_p])) else 1.0)
                        )
                        bp_len = int(gene_lengths[g_c]) if g_c < len(gene_lengths) else 1000
                        return (unmatched_g, same_dl, same_da, -round(c_val, 3), is_adj, bp_len)

                    best_g = max(cand_multi_genes, key=_score_multi_gt)
                    b_p = linkage_matches.get(best_g, -1)
                    edge_p = b_p - n_res
                    same_dl = 0 <= edge_p < len(glycan.edges) and _edge_signatures(glycan, edge_p)[1] == sig_dl_u
                    same_da_count = sum(
                        1
                        for g_o in cand_multi_genes
                        if g_o in linkage_matches
                        and 0 <= (linkage_matches[g_o] - n_res) < len(glycan.edges)
                        and _edge_signatures(glycan, linkage_matches[g_o] - n_res)[2] == sig_da_u
                    )
                    has_pan_u = False
                    if active_prior is not None and locus.nodes[best_g].cluster_id > 0:
                        _, has_pan_u = active_prior.gt_edge_bonus(
                            int(locus.nodes[best_g].cluster_id),
                            glycan,
                            edge_u,
                        )

                    # Extra-locus GtrABC-style terminal side-chain alpha-Glc/Gal branch when all locus GTs are saturated
                    s_u_node = glycan.nodes[e_u.source]
                    s_u_name = Sugars.names[s_u_node.sugar]
                    if (
                        best_g in linkage_matches
                        and len(cand_multi_genes) >= 1
                        and locus.nodes[best_g].role != GeneRole.TRANSMEMBRANE_SYNTHASE
                        and not same_dl
                        and not has_pan_u
                        and e_u.edge_type.is_branch
                        and e_u.source in term_leaves
                        and s_u_name in (b"Glc", b"Gal")
                        and e_u.edge_type.anomer == Anomer.ALPHA
                        and (0 <= edge_p < len(glycan.edges) and glycan.edges[edge_p].edge_type.is_backbone)
                    ):
                        out.append(
                            GeneGlycanAssignment(
                                gene_index=-1,
                                role=GeneRole.GLYCOSYLTRANSFERASE,
                                bipartite_glycan_index=int(b_u),
                                target_type=TargetType.LINKAGE,
                                residue=None,
                                linkage=e_u.edge_type,
                                edge_index=edge_u,
                                evidence=AssignmentEvidence.RULE_INFERRED,
                                score=0.75,
                            )
                        )
                        continue
                    is_pan_u = (
                        has_pan_u
                        or len(cand_multi_genes) == 1
                        or (same_dl and (best_g in direct_pan_link_genes or same_da_count == 1))
                    )
                    c_u = float(c_mat[best_g, b_u]) if np.isfinite(c_mat[best_g, b_u]) else 0.20
                    score_u = (
                        float(np.clip(0.30 * np.exp(-c_u) + 0.62, 0.82, 0.96))
                        if is_pan_u
                        else float(np.clip(0.80 * np.exp(-c_u), 0.55, 0.84))
                    )
                    out.append(
                        GeneGlycanAssignment(
                            gene_index=int(best_g),
                            role=locus.nodes[best_g].role,
                            bipartite_glycan_index=int(b_u),
                            target_type=TargetType.LINKAGE,
                            residue=None,
                            linkage=e_u.edge_type,
                            edge_index=edge_u,
                            evidence=(
                                AssignmentEvidence.PAN_GRAPH_CONSENSUS if is_pan_u else AssignmentEvidence.GRAPH_ALIGNED
                            ),
                            score=score_u,
                        )
                    )
                    matched_gene_indices.add(int(best_g))
                    if best_g not in linkage_matches:
                        linkage_matches[int(best_g)] = int(b_u)

        # Identify N-linked OAc modifier nodes (e.g. Ac(1-2) on GlcN -> GlcNAc) vs true O-acetylation
        n_acetyl_mod_nodes, n_acetylated_sugar_nodes = self._find_n_acetyl_nodes(glycan)

        # Many-to-many pathway completion: link additional precursor synthesis / modifier genes in the locus
        # (e.g., rmlABCD or Neu5Ac/Pse/Leg pathway genes) to their compatible monosaccharide / modifier nodes
        pathway_roles = (
            GeneRole.OXIDOREDUCTASE,
            GeneRole.EPIMERASE,
            GeneRole.AMINOTRANSFERASE,
            GeneRole.ALDOLASE,
            GeneRole.NUCLEOTIDASE,
            GeneRole.DEACETYLASE,
            GeneRole.PYRUVYLTRANSFERASE,
            GeneRole.METHYLTRANSFERASE,
            GeneRole.ACETYLTRANSFERASE,
            GeneRole.PHOSPHOTRANSFERASE,
            GeneRole.FORMYLTRANSFERASE,
            GeneRole.ALANINE_TRANSFERASE,
        )
        for g_idx in range(n_genes):
            if g_idx in matched_gene_indices:
                continue
            g_node = locus.nodes[g_idx]
            role = g_node.role
            if role not in pathway_roles:
                continue
            rule = self.registry.get_rule(role)
            # Prefer unmatched modifier nodes first before sharing an already-matched modifier node
            candidate_node_order = sorted(
                range(len(glycan.nodes)),
                key=lambda j: (j in matched_mod_nodes, j not in completed_mod_nodes, j),
            )
            for j in candidate_node_order:
                res = glycan.nodes[j]
                if res.is_modifier:
                    if j in n_acetyl_mod_nodes or (j in matched_mod_nodes and j in completed_mod_nodes):
                        continue
                    if res.sugar in rule.target_modifiers:
                        ev = (
                            AssignmentEvidence.LOCUS_COMPLETED
                            if j in completed_mod_nodes
                            else AssignmentEvidence.GRAPH_ALIGNED
                        )
                        out.append(
                            GeneGlycanAssignment(
                                gene_index=g_idx,
                                role=role,
                                bipartite_glycan_index=j,
                                target_type=TargetType.MODIFIED_MONO,
                                residue=res,
                                linkage=None,
                                edge_index=None,
                                evidence=ev,
                                score=0.85 if ev == AssignmentEvidence.LOCUS_COMPLETED else 0.90,
                            )
                        )
                        matched_mod_nodes.add(j)
                        break
                else:
                    if role == GeneRole.DEACETYLASE and j in n_acetylated_sugar_nodes:
                        continue
                    if res.sugar in rule.compatible_sugars or res.sugar in rule.target_modifiers:
                        out.append(
                            GeneGlycanAssignment(
                                gene_index=g_idx,
                                role=role,
                                bipartite_glycan_index=j,
                                target_type=(
                                    TargetType.MODIFIED_MONO
                                    if role == GeneRole.DEACETYLASE
                                    else TargetType.MONOSACCHARIDE
                                ),
                                residue=res,
                                linkage=None,
                                edge_index=None,
                                evidence=AssignmentEvidence.GRAPH_ALIGNED,
                                score=0.88,
                            )
                        )
                        break

        out.sort(key=lambda a: (a.gene_index if a.gene_index >= 0 else 9999, a.bipartite_glycan_index))
        return tuple(out)

    @staticmethod
    def _find_n_acetyl_nodes(glycan: Glycan) -> tuple[set[int], set[int]]:
        """Identify N-linked `OAc` modifier node indices (e.g. `Ac(1-2)` on `GlcN`) and N-acetylated sugar node indices."""
        oac_idx = Sugars.name_to_index.get(b"OAc", -1)
        n_acetyl_mods: set[int] = set()
        n_acetylated_sugars: set[int] = set()
        if oac_idx < 0:
            return n_acetyl_mods, n_acetylated_sugars

        for edge in glycan.edges:
            s_node = glycan.nodes[edge.source]
            t_node = glycan.nodes[edge.target]
            acc = edge.edge_type.acceptor
            if s_node.sugar == oac_idx and not t_node.is_modifier and not (edge.edge_type.donor == 0 and acc == 0):
                t_name = Sugars.names[t_node.sugar]
                t_cls = Sugars.classes[t_node.sugar]
                is_free_amino = (b"N" in t_name and b"NAc" not in t_name) or (t_cls in (b"HexN", b"dHexN", b"HexNA"))
                if is_free_amino and acc in (0, 2, 4, 5, 7):
                    n_acetyl_mods.add(edge.source)
                    if acc in (0, 2):
                        n_acetylated_sugars.add(edge.target)
        return n_acetyl_mods, n_acetylated_sugars

    def infer_rule_assignments(
        self,
        glycan: Glycan,
        archetype: LocusArchetype = LocusArchetype.UNKNOWN,
        is_repeating: bool = True,
    ) -> tuple[GeneGlycanAssignment, ...]:
        """Infer stoichiometric `GeneRole` -> glycan component assignments via `BiosyntheticRuleRegistry`."""
        n_res = len(glycan.nodes)
        if n_res == 0:
            return ()

        n_acetyl_mod_nodes, n_acetylated_sugar_nodes = self._find_n_acetyl_nodes(glycan)

        red_indices = {j for j, r in enumerate(glycan.nodes) if r.is_reducing_end and not r.is_modifier}
        if not red_indices:
            for j, r in enumerate(glycan.nodes):
                if not r.is_modifier:
                    red_indices.add(j)
                    break

        is_synthase_arch = archetype in (
            LocusArchetype.CPS_SYNTHASE,
            LocusArchetype.EPS_ALGINATE,
            LocusArchetype.EPS_BCS,
            LocusArchetype.EPS_PGA,
            LocusArchetype.EPS_PEL,
        )
        is_wzy_arch = archetype in (
            LocusArchetype.LPS_WZY,
            LocusArchetype.CPS_WZY,
            LocusArchetype.UNKNOWN,
        )

        mod_roles = (
            GeneRole.PYRUVYLTRANSFERASE,
            GeneRole.METHYLTRANSFERASE,
            GeneRole.ACETYLTRANSFERASE,
            GeneRole.PHOSPHOTRANSFERASE,
            GeneRole.FORMYLTRANSFERASE,
            GeneRole.ALANINE_TRANSFERASE,
        )
        oxr_rule = self.registry.get_rule(GeneRole.OXIDOREDUCTASE)
        atn_rule = self.registry.get_rule(GeneRole.AMINOTRANSFERASE)
        ald_rule = self.registry.get_rule(GeneRole.ALDOLASE)
        deac_rule = self.registry.get_rule(GeneRole.DEACETYLASE)

        out: list[GeneGlycanAssignment] = []

        # 1. Residue and Modifier Nodes
        for j, res in enumerate(glycan.nodes):
            s_idx = res.sugar
            if res.is_modifier:
                if j in n_acetyl_mod_nodes:
                    continue
                matched_mod_role = GeneRole.ACETYLTRANSFERASE
                for m_role in mod_roles:
                    if s_idx in self.registry.get_rule(m_role).target_modifiers:
                        matched_mod_role = m_role
                        break
                out.append(
                    GeneGlycanAssignment(
                        gene_index=-1,
                        role=matched_mod_role,
                        bipartite_glycan_index=j,
                        target_type=TargetType.MODIFIED_MONO,
                        residue=res,
                        linkage=None,
                        edge_index=None,
                        evidence=AssignmentEvidence.RULE_INFERRED,
                        score=0.90,
                    )
                )
            else:
                if j in red_indices:
                    init_role = GeneRole.TRANSMEMBRANE_SYNTHASE if is_synthase_arch else GeneRole.INITIATOR
                    out.append(
                        GeneGlycanAssignment(
                            gene_index=-1,
                            role=init_role,
                            bipartite_glycan_index=j,
                            target_type=TargetType.REDUCING_END,
                            residue=res,
                            linkage=None,
                            edge_index=None,
                            evidence=AssignmentEvidence.RULE_INFERRED,
                            score=0.90,
                        )
                    )

                # Specialized monosaccharide precursor pathways & deacetylation
                s_name = Sugars.names[s_idx]
                if s_idx in deac_rule.target_modifiers and j not in n_acetylated_sugar_nodes:
                    out.append(
                        GeneGlycanAssignment(
                            gene_index=-1,
                            role=GeneRole.DEACETYLASE,
                            bipartite_glycan_index=j,
                            target_type=TargetType.MODIFIED_MONO,
                            residue=res,
                            linkage=None,
                            edge_index=None,
                            evidence=AssignmentEvidence.RULE_INFERRED,
                            score=0.85,
                        )
                    )
                if s_idx in oxr_rule.compatible_sugars:
                    out.append(
                        GeneGlycanAssignment(
                            gene_index=-1,
                            role=GeneRole.OXIDOREDUCTASE,
                            bipartite_glycan_index=j,
                            target_type=TargetType.MONOSACCHARIDE,
                            residue=res,
                            linkage=None,
                            edge_index=None,
                            evidence=AssignmentEvidence.RULE_INFERRED,
                            score=0.85,
                        )
                    )
                    out.append(
                        GeneGlycanAssignment(
                            gene_index=-1,
                            role=GeneRole.EPIMERASE,
                            bipartite_glycan_index=j,
                            target_type=TargetType.MONOSACCHARIDE,
                            residue=res,
                            linkage=None,
                            edge_index=None,
                            evidence=AssignmentEvidence.RULE_INFERRED,
                            score=0.80,
                        )
                    )
                if s_idx in atn_rule.compatible_sugars and s_name not in (b"GlcN", b"GalN", b"ManN"):
                    out.append(
                        GeneGlycanAssignment(
                            gene_index=-1,
                            role=GeneRole.AMINOTRANSFERASE,
                            bipartite_glycan_index=j,
                            target_type=TargetType.MONOSACCHARIDE,
                            residue=res,
                            linkage=None,
                            edge_index=None,
                            evidence=AssignmentEvidence.RULE_INFERRED,
                            score=0.85,
                        )
                    )
                if s_idx in ald_rule.compatible_sugars:
                    out.append(
                        GeneGlycanAssignment(
                            gene_index=-1,
                            role=GeneRole.ALDOLASE,
                            bipartite_glycan_index=j,
                            target_type=TargetType.MONOSACCHARIDE,
                            residue=res,
                            linkage=None,
                            edge_index=None,
                            evidence=AssignmentEvidence.RULE_INFERRED,
                            score=0.85,
                        )
                    )
                    out.append(
                        GeneGlycanAssignment(
                            gene_index=-1,
                            role=GeneRole.NUCLEOTIDASE,
                            bipartite_glycan_index=j,
                            target_type=TargetType.MONOSACCHARIDE,
                            residue=res,
                            linkage=None,
                            edge_index=None,
                            evidence=AssignmentEvidence.RULE_INFERRED,
                            score=0.80,
                        )
                    )

        # 2. Glycosidic Linkage Edges
        core_edge_indices = [
            k
            for k, edge in enumerate(glycan.edges)
            if not glycan.nodes[edge.source].is_modifier and not glycan.nodes[edge.target].is_modifier
        ]
        wzy_edge_idx: int | None = None
        if is_repeating and is_wzy_arch and len(core_edge_indices) >= 2:
            red_idx = min(red_indices) if red_indices else 0
            wzy_edge_idx, _, _ = glycan.polymer_edge_and_assembly_depths(red_idx)
            if wzy_edge_idx is None:
                wzy_edge_idx = core_edge_indices[0]

        for k in core_edge_indices:
            edge = glycan.edges[k]
            if is_synthase_arch:
                edge_role = GeneRole.TRANSMEMBRANE_SYNTHASE
            elif k == wzy_edge_idx:
                edge_role = GeneRole.POLYMERASE
            else:
                edge_role = GeneRole.GLYCOSYLTRANSFERASE
            out.append(
                GeneGlycanAssignment(
                    gene_index=-1,
                    role=edge_role,
                    bipartite_glycan_index=n_res + k,
                    target_type=TargetType.LINKAGE,
                    residue=None,
                    linkage=edge.edge_type,
                    edge_index=k,
                    evidence=AssignmentEvidence.RULE_INFERRED,
                    score=0.85,
                )
            )

        return tuple(out)

    def assign_record(
        self,
        glycan: Glycan | None,
        locus: Locus | None = None,
        archetype: LocusArchetype = LocusArchetype.UNKNOWN,
        is_repeating: bool = True,
        alignment: Alignment | None = None,
        completed_modifier_genes: dict[int, int] | None = None,
        pan_prior: PanGraphConsensusPrior | None = None,
    ) -> tuple[GeneGlycanAssignment, ...]:
        """Resolve complete gene-to-glycan component assignments for a record (graph-aligned + rule fallback)."""
        if glycan is None or len(glycan.nodes) == 0:
            return ()

        rule_assigns = self.infer_rule_assignments(glycan, archetype=archetype, is_repeating=is_repeating)
        if locus is None or len(locus.nodes) == 0:
            return rule_assigns

        aligned = self.assign_genes(
            locus,
            glycan,
            alignment=alignment,
            completed_modifier_genes=completed_modifier_genes,
            pan_prior=pan_prior,
        )
        if not aligned:
            return rule_assigns

        covered_bipartite_types = {(a.bipartite_glycan_index, a.target_type) for a in aligned}
        covered_bipartite_roles = {(a.bipartite_glycan_index, a.role) for a in aligned}

        merged = list(aligned)
        for ra in rule_assigns:
            if ra.target_type in (TargetType.LINKAGE, TargetType.REDUCING_END):
                if (ra.bipartite_glycan_index, ra.target_type) in covered_bipartite_types:
                    continue
            elif (ra.bipartite_glycan_index, ra.role) in covered_bipartite_roles or (
                ra.bipartite_glycan_index,
                ra.target_type,
            ) in covered_bipartite_types:
                continue
            merged.append(ra)

        return tuple(merged)

    def assign_gt_linkages(
        self,
        locus: Locus,
        glycan: Glycan,
        alignment: Alignment | None = None,
    ) -> dict[int, tuple[int, LinkageType]]:
        """Map each glycosyltransferase / polymerase / synthase in `locus` to its `Glycan` linkage."""
        assignments: dict[int, tuple[int, LinkageType]] = {}
        for item in self.assign_genes(locus, glycan, alignment=alignment):
            if item.linkage is not None and item.role in (
                GeneRole.GLYCOSYLTRANSFERASE,
                GeneRole.POLYMERASE,
                GeneRole.TRANSMEMBRANE_SYNTHASE,
            ):
                assignments[item.gene_index] = (item.bipartite_glycan_index, item.linkage)
        return assignments

    def score_locus(
        self,
        locus: Locus,
        glycan: Glycan,
        locus_index: int = 0,
        expected_category: ArchetypeCategory | None = None,
        expected_archetype: LocusArchetype | None = None,
    ) -> LocusMatchResult:
        """Score a single candidate `Locus` against `glycan` by combining archetype, completeness, and alignment."""
        locus = locus.canonicalize_strand()
        if len(locus.nodes) == 0 or len(glycan.nodes) == 0:
            empty_align = Alignment(
                matches={},
                costs={},
                total_cost=float("inf"),
                graph_a=locus,
                graph_b=glycan,
                unmatched_a=tuple(range(len(locus.nodes))),
                unmatched_b=tuple(range(len(glycan.nodes))),
            )
            return LocusMatchResult(
                locus_index=locus_index,
                locus=locus,
                archetype=locus.archetype,
                alignment=empty_align,
                combined_score=0.0,
                bio_score=0.0,
                assignments=(),
            )

        alignment = self.aligner.align(locus, glycan)
        assignments = self.assign_genes(locus, glycan, alignment=alignment)

        # Count non-modifier glycosidic linkages vs GT/polymerase/synthase count
        n_core_linkages = sum(
            1 for e in glycan.edges if not glycan.nodes[e.source].is_modifier and not glycan.nodes[e.target].is_modifier
        )
        roles = locus.roles
        n_gt_like = int(
            np.sum(
                (roles == int(GeneRole.GLYCOSYLTRANSFERASE))
                | (roles == int(GeneRole.POLYMERASE))
                | (roles == int(GeneRole.TRANSMEMBRANE_SYNTHASE))
            )
        )
        gt_ratio_score = (
            1.0 - min(abs(n_gt_like - n_core_linkages) / max(n_core_linkages, n_gt_like, 1), 1.0)
            if (n_core_linkages > 0 or n_gt_like > 0)
            else 0.5
        )

        # Evaluate biochemical match costs across assigned gene nodes
        matched_costs: list[float] = []
        for a in assignments:
            c_val = alignment.costs.get(a.gene_index)
            if c_val is not None and np.isfinite(c_val) and c_val < 1e4:
                matched_costs.append(float(c_val))

        if matched_costs:
            mean_match_quality = float(np.mean(np.exp(-np.asarray(matched_costs, dtype=np.float64))))
        else:
            mean_match_quality = 0.0

        bio_score = float(np.clip(0.55 * mean_match_quality + 0.45 * gt_ratio_score, 0.0, 1.0))

        # Archetype compatibility score
        arch = locus.archetype
        if expected_archetype is not None and arch == expected_archetype:
            arch_bonus = 1.0
        elif expected_category is not None and arch.category == expected_category:
            arch_bonus = 0.85
        elif expected_category is None and arch != LocusArchetype.UNKNOWN:
            arch_bonus = 0.65
        elif arch == LocusArchetype.UNKNOWN:
            arch_bonus = 0.25
        else:
            arch_bonus = 0.0

        comp = float(np.clip(locus.completeness, 0.0, 1.0))
        seg_score = float(np.tanh(max(locus.score, 0.0) / 12.0))
        intact_factor = float(
            np.clip(1.0 - 0.35 * locus.frameshift_fraction - (0.15 if locus.is_fragmented else 0.0), 0.3, 1.0)
        )
        combined = float((0.40 * arch_bonus + 0.35 * bio_score + 0.15 * comp + 0.10 * seg_score) * intact_factor)

        return LocusMatchResult(
            locus_index=locus_index,
            locus=locus,
            archetype=arch,
            alignment=alignment,
            combined_score=combined,
            bio_score=bio_score,
            assignments=assignments,
        )

    def rank_loci(
        self,
        glycan: Glycan,
        loci: Loci | Sequence[Locus],
        *,
        antigen_hint: bytes | str = b"",
        expected_category: ArchetypeCategory | None = None,
        expected_archetype: LocusArchetype | None = None,
    ) -> list[LocusMatchResult]:
        """Score and rank all candidate `Loci` against `glycan`, prioritizing matching archetypes."""
        if expected_category is None and expected_archetype is None and antigen_hint:
            expected_category, expected_archetype = self.infer_expected_archetype(antigen_hint)

        candidates = [loci[i] for i in range(len(loci))] if isinstance(loci, Loci) else list(loci)
        if not candidates:
            return []

        results = [
            self.score_locus(
                loc,
                glycan,
                locus_index=idx,
                expected_category=expected_category,
                expected_archetype=expected_archetype,
            )
            for idx, loc in enumerate(candidates)
        ]

        # If any candidate matches the expected archetype or category, filter/prioritize those first
        if expected_archetype is not None:
            exact = [r for r in results if r.archetype == expected_archetype]
            if exact:
                exact.sort(key=lambda r: r.combined_score, reverse=True)
                others = [r for r in results if r.archetype != expected_archetype]
                others.sort(key=lambda r: r.combined_score, reverse=True)
                return exact + others

        if expected_category is not None:
            cat_matches = [r for r in results if r.archetype.category == expected_category]
            if cat_matches:
                cat_matches.sort(key=lambda r: r.combined_score, reverse=True)
                others = [r for r in results if r.archetype.category != expected_category]
                others.sort(key=lambda r: r.combined_score, reverse=True)
                return cat_matches + others

        results.sort(key=lambda r: r.combined_score, reverse=True)
        return results

    def match_locus(
        self,
        glycan: Glycan,
        loci: Loci | Sequence[Locus],
        *,
        antigen_hint: bytes | str = b"",
        expected_category: ArchetypeCategory | None = None,
        expected_archetype: LocusArchetype | None = None,
        require_category_match: bool = False,
    ) -> LocusMatchResult | None:
        """Select the highest-scoring candidate `Locus` for `glycan` from `loci`."""
        if expected_category is None and expected_archetype is None and antigen_hint:
            expected_category, expected_archetype = self.infer_expected_archetype(antigen_hint)

        ranked = self.rank_loci(
            glycan,
            loci,
            antigen_hint=antigen_hint,
            expected_category=expected_category,
            expected_archetype=expected_archetype,
        )
        if not ranked:
            return None

        best = ranked[0]
        if require_category_match and expected_category is not None:
            if best.archetype.category != expected_category:
                return None
        return best

resolve_biological_reducing_end

resolve_biological_reducing_end(
    glycan: Glycan,
    locus: Locus | None = None,
    archetype: LocusArchetype = UNKNOWN,
    pan_prior: PanGraphConsensusPrior | None = None,
) -> Glycan

Rotate glycan reducing end (*Sugar) to the true biological Und-PP initiating monosaccharide.

Uses INITIATOR homology cluster_id consensus from pan_prior (WbaP -> D-Galp, WcaJ -> D-Glcp, WecA -> D-GlcNAc/D-GalNAc, Itr/PglC -> D-QuiNAc/D-FucNAc/D-Gal) together with archetype-specific Und-PP transferase biochemistry to resolve arbitrary literature repeat frames.

Source code in src/kbank/biosynthesis/align.py
def resolve_biological_reducing_end(
    self,
    glycan: Glycan,
    locus: Locus | None = None,
    archetype: LocusArchetype = LocusArchetype.UNKNOWN,
    pan_prior: PanGraphConsensusPrior | None = None,
) -> Glycan:
    """Rotate `glycan` reducing end (`*Sugar`) to the true biological Und-PP initiating monosaccharide.

    Uses `INITIATOR` homology `cluster_id` consensus from `pan_prior` (`WbaP` -> `D-Galp`, `WcaJ` -> `D-Glcp`,
    `WecA` -> `D-GlcNAc`/`D-GalNAc`, `Itr`/`PglC` -> `D-QuiNAc`/`D-FucNAc`/`D-Gal`) together with
    archetype-specific Und-PP transferase biochemistry to resolve arbitrary literature repeat frames.
    """
    if len(glycan.nodes) <= 1 or not glycan.has_polymer_repeat:
        return glycan

    cycle = glycan.cycle_nodes()
    if not cycle:
        return glycan

    active_prior = pan_prior if pan_prior is not None else self.pan_prior
    curr_red = glycan.reducing_end_index
    cycle_sugars = {glycan.nodes[u].sugar for u in cycle}

    pref_init_sugar: int | None = None
    wzy_cluster_id: int = 0
    if locus is not None and len(locus.nodes) > 0 and active_prior is not None:
        for g_node in locus.nodes:
            if g_node.role == GeneRole.INITIATOR and g_node.cluster_id > 0 and pref_init_sugar is None:
                pref_init_sugar = active_prior.preferred_init_sugar(int(g_node.cluster_id), cycle_sugars)
            elif g_node.role == GeneRole.POLYMERASE and g_node.cluster_id > 0 and wzy_cluster_id == 0:
                wzy_cluster_id = int(g_node.cluster_id)

    eff_arch = locus.archetype if (locus is not None and archetype == LocusArchetype.UNKNOWN) else archetype
    init_compat = self.registry.get_rule(GeneRole.INITIATOR).compatible_sugars
    cycle_set = set(cycle)

    def _score_cycle_node(u: int) -> tuple[float, int]:
        s_idx = glycan.nodes[u].sugar
        s_name = Sugars.names[s_idx]
        sc = 0.0

        if pref_init_sugar is not None and s_idx == pref_init_sugar:
            sc += 8.0

        if eff_arch in (LocusArchetype.LPS_WZY, LocusArchetype.LPS_ABC):
            if s_name in self._LPS_PRIMARY_INIT_SUGARS:
                sc += 4.5
            elif s_name in (b"Gal", b"Glc"):
                sc += 3.0
            elif s_idx in init_compat:
                sc += 1.0
        elif eff_arch == LocusArchetype.CPS_WZY:
            if s_name in self._CPS_PRIMARY_INIT_SUGARS:
                sc += 4.5
            elif s_idx in init_compat:
                sc += 1.0
        else:
            if s_name in self._LPS_PRIMARY_INIT_SUGARS:
                sc += 4.2
            elif s_name in (b"Gal", b"Glc"):
                sc += 3.8
            elif s_idx in init_compat:
                sc += 1.0

        if u == curr_red:
            sc += 0.35

        if active_prior is not None and wzy_cluster_id > 0:
            for k_e, e_obj in enumerate(glycan.edges):
                if e_obj.source == u and e_obj.target in cycle_set:
                    wzy_b, _ = active_prior.gt_edge_bonus(wzy_cluster_id, glycan, k_e)
                    sc += -2.0 * wzy_b
                    break

        return (sc, -u)

    best_u = max(cycle, key=_score_cycle_node)
    return glycan.with_reducing_end(best_u)

infer_expected_archetype staticmethod

infer_expected_archetype(
    antigen_hint: bytes | str = b"",
) -> tuple[ArchetypeCategory | None, LocusArchetype | None]

Infer expected ArchetypeCategory and optional specific LocusArchetype from an antigen hint string.

Source code in src/kbank/biosynthesis/align.py
@staticmethod
def infer_expected_archetype(
    antigen_hint: bytes | str = b"",
) -> tuple[ArchetypeCategory | None, LocusArchetype | None]:
    """Infer expected `ArchetypeCategory` and optional specific `LocusArchetype` from an antigen hint string."""
    hint = antigen_hint.decode("utf-8", errors="replace") if isinstance(antigen_hint, bytes) else str(antigen_hint)
    h = hint.strip().lower()
    if not h:
        return None, None

    # Specific archetype matches first
    if "group 2" in h or "group 3" in h or "kps" in h or "cps_abc" in h:
        return ArchetypeCategory.CAPSULE, LocusArchetype.CPS_ABC
    if "group 1" in h or "group 4" in h or "cps_wzy" in h:
        return ArchetypeCategory.CAPSULE, LocusArchetype.CPS_WZY
    if "synthase" in h or "cps_synthase" in h or "hyaluron" in h:
        return ArchetypeCategory.CAPSULE, LocusArchetype.CPS_SYNTHASE
    if "lps_abc" in h or "wzm" in h:
        return ArchetypeCategory.O_ANTIGEN, LocusArchetype.LPS_ABC
    if "lps_wzy" in h:
        return ArchetypeCategory.O_ANTIGEN, LocusArchetype.LPS_WZY
    if "teichoic" in h or "wta" in h or "lta" in h:
        return ArchetypeCategory.OTHER, LocusArchetype.TEICHOIC_ACID
    if "alginate" in h:
        return ArchetypeCategory.EXOPOLYSACCHARIDE, LocusArchetype.EPS_ALGINATE
    if "cellulose" in h or "bcs" in h:
        return ArchetypeCategory.EXOPOLYSACCHARIDE, LocusArchetype.EPS_BCS
    if "pga" in h or "pnag" in h or "ica" in h:
        return ArchetypeCategory.EXOPOLYSACCHARIDE, LocusArchetype.EPS_PGA
    if "pel" in h:
        return ArchetypeCategory.EXOPOLYSACCHARIDE, LocusArchetype.EPS_PEL

    # High-level category matches
    if any(k in h for k in ("cps", "k-antigen", "k antigen", "capsul", "vi-antigen", "vi antigen")):
        return ArchetypeCategory.CAPSULE, None
    if any(
        k in h for k in ("o-antigen", "o antigen", "ops", "lps", "los", "lipopolysaccharide", "lipooligosaccharide")
    ):
        return ArchetypeCategory.O_ANTIGEN, None
    if any(k in h for k in ("eps", "exopolysaccharide", "biofilm")):
        return ArchetypeCategory.EXOPOLYSACCHARIDE, None

    return None, None

complete_glycan_modifiers

complete_glycan_modifiers(
    glycan: Glycan,
    locus: Locus | None,
    pan_prior: PanGraphConsensusPrior | None = None,
) -> tuple[Glycan, tuple[bytes, ...], dict[int, int]]

Artificially complete glycan with modifier nodes inferred from excess modifier genes in locus.

Subtracts 1 ACETYLTRANSFERASE per specialized N-acetylated sugar pathway (QuiNAc, FucNAc, RhaNAc, Pse, Leg, Aci, Bac, Neu5Ac) before adding OAc modifier nodes, while housekeeping GlcNAc/GalNAc/ManNAc consume 0 locus acetyltransferases.

Returns:

Type Description
tuple[Glycan, tuple[bytes, ...], dict[int, int]]

tuple[Glycan, tuple[bytes, ...], dict[int, int]]: - Completed Glycan graph (or original glycan if no modifiers were added). - Tuple of added modifier byte names (e.g. (b"OAc", b"Pyr")). - Mapping {new_modifier_node_index: locus_gene_index} for LOCUS_COMPLETED assignments.

Source code in src/kbank/biosynthesis/align.py
def complete_glycan_modifiers(
    self,
    glycan: Glycan,
    locus: Locus | None,
    pan_prior: PanGraphConsensusPrior | None = None,
) -> tuple[Glycan, tuple[bytes, ...], dict[int, int]]:
    """Artificially complete `glycan` with modifier nodes inferred from excess modifier genes in `locus`.

    Subtracts 1 `ACETYLTRANSFERASE` per specialized N-acetylated sugar pathway (`QuiNAc`, `FucNAc`,
    `RhaNAc`, `Pse`, `Leg`, `Aci`, `Bac`, `Neu5Ac`) before adding `OAc` modifier nodes, while
    housekeeping `GlcNAc`/`GalNAc`/`ManNAc` consume 0 locus acetyltransferases.

    Returns:
        tuple[Glycan, tuple[bytes, ...], dict[int, int]]:
            - Completed `Glycan` graph (or original `glycan` if no modifiers were added).
            - Tuple of added modifier byte names (e.g. `(b"OAc", b"Pyr")`).
            - Mapping `{new_modifier_node_index: locus_gene_index}` for `LOCUS_COMPLETED` assignments.
    """
    if locus is None or len(locus.nodes) == 0 or len(glycan.nodes) == 0:
        return glycan, (), {}

    core_indices = [idx for idx, n in enumerate(glycan.nodes) if not n.is_modifier]
    if not core_indices:
        return glycan, (), {}

    n_acetyl_mod_nodes, _ = self._find_n_acetyl_nodes(glycan)
    pathway_act_sugars = {
        Sugars.names[n.sugar]
        for n in glycan.nodes
        if not n.is_modifier and Sugars.names[n.sugar] in self._PATHWAY_NACETYL_SUGARS
    }
    n_pathway_act = len(pathway_act_sugars)

    new_nodes = list(glycan.nodes)
    new_edges = list(glycan.edges)
    completed_names: list[bytes] = []
    completed_node_to_gene: dict[int, int] = {}

    for role, mod_bytes in self._COMPLETABLE_MODIFIER_ROLES:
        if mod_bytes not in Sugars.name_to_index:
            continue
        rule = self.registry.get_rule(role)
        role_gene_indices = [g_i for g_i, g_node in enumerate(locus.nodes) if g_node.role == role]
        if not role_gene_indices:
            continue

        existing_mods = [
            j
            for j, res in enumerate(new_nodes)
            if res.is_modifier and j not in n_acetyl_mod_nodes and res.sugar in rule.target_modifiers
        ]
        accounted = len(existing_mods) + (n_pathway_act if role == GeneRole.ACETYLTRANSFERASE else 0)
        if len(role_gene_indices) <= accounted:
            continue

        excess_genes = role_gene_indices[accounted:]
        mod_sugar_idx = Sugars.name_to_index[mod_bytes]
        for g_idx in excess_genes:
            best_u = self._select_modifier_acceptor(new_nodes, new_edges, role, mod_bytes)
            mod_edge_type = LinkageType(Anomer.UNKNOWN, donor=0, acceptor=0)
            cid_mod = int(locus.nodes[g_idx].cluster_id)
            if pan_prior is not None and cid_mod > 0:
                spec_map = pan_prior.modifier_cluster_spec.get(cid_mod) or pan_prior.modifier_cluster_spec.get(
                    pan_prior.fine_to_med.get(cid_mod, 0), {}
                )
                if spec_map:
                    occupied_on: dict[int, set[int]] = {u_i: set() for u_i in core_indices}
                    for e_ex in new_edges:
                        if e_ex.target in occupied_on and e_ex.edge_type.acceptor > 0:
                            occupied_on[e_ex.target].add(int(e_ex.edge_type.acceptor))
                    for (m_sug, acc_sug, lnk_int), _w_m in sorted(
                        spec_map.items(), key=lambda kv: kv[1], reverse=True
                    ):
                        if m_sug != mod_sugar_idx:
                            continue
                        cand_lt = LinkageType(int(lnk_int)).with_topology(is_backbone=False, is_polymer=False)
                        matching_u = [
                            u_i
                            for u_i in core_indices
                            if int(new_nodes[u_i].sugar) == acc_sug
                            and (cand_lt.acceptor == 0 or int(cand_lt.acceptor) not in occupied_on.get(u_i, set()))
                        ]
                        if not matching_u:
                            matching_u = [u_i for u_i in core_indices if int(new_nodes[u_i].sugar) == acc_sug]
                        if matching_u:
                            best_u = matching_u[0]
                            mod_edge_type = cand_lt
                            break
            new_mod_idx = len(new_nodes)
            new_nodes.append(ResidueType.from_sugar(mod_sugar_idx, Chirality.UNKNOWN, False))
            new_edges.append(
                LinkageEdge(
                    source=new_mod_idx,
                    target=best_u,
                    edge_type=mod_edge_type,
                )
            )
            completed_names.append(mod_bytes)
            completed_node_to_gene[new_mod_idx] = g_idx

    if not completed_names:
        return glycan, (), {}

    completed_glycan = Glycan(
        canonical=glycan.canonical,
        nodes=tuple(new_nodes),
        edges=tuple(new_edges),
    )
    return completed_glycan, tuple(completed_names), completed_node_to_gene

rescue_cassette_roles

rescue_cassette_roles(
    locus: Locus,
    glycan: Glycan,
    archetype: LocusArchetype = UNKNOWN,
    is_repeating: bool = True,
    pan_cluster_counts: dict[int, int] | None = None,
) -> Locus

Promote variable-cassette UNKNOWN or duplicate non-conserved transport/hydrolase ORFs to GT/POLYMERASE when locus has a linkage deficit.

Source code in src/kbank/biosynthesis/align.py
def rescue_cassette_roles(
    self,
    locus: Locus,
    glycan: Glycan,
    archetype: LocusArchetype = LocusArchetype.UNKNOWN,
    is_repeating: bool = True,
    pan_cluster_counts: dict[int, int] | None = None,
) -> Locus:
    """Promote variable-cassette `UNKNOWN` or duplicate non-conserved transport/hydrolase ORFs to `GT`/`POLYMERASE` when `locus` has a linkage deficit."""
    if len(locus.nodes) == 0 or len(glycan.nodes) == 0:
        return locus

    core_edges = [
        k
        for k, e in enumerate(glycan.edges)
        if not glycan.nodes[e.source].is_modifier and not glycan.nodes[e.target].is_modifier
    ]
    n_core = len(core_edges)
    if n_core == 0:
        return locus

    eff_arch = locus.archetype if archetype == LocusArchetype.UNKNOWN else archetype
    is_synthase_arch = eff_arch in (
        LocusArchetype.CPS_SYNTHASE,
        LocusArchetype.EPS_ALGINATE,
        LocusArchetype.EPS_BCS,
        LocusArchetype.EPS_PGA,
        LocusArchetype.EPS_PEL,
    )
    if is_synthase_arch:
        return locus

    is_wzy_arch = (
        eff_arch in (LocusArchetype.LPS_WZY, LocusArchetype.CPS_WZY, LocusArchetype.UNKNOWN)
        and is_repeating
        and n_core >= 2
    )

    gt_indices = [i for i, nd in enumerate(locus.nodes) if nd.role == GeneRole.GLYCOSYLTRANSFERASE]
    poly_indices = [i for i, nd in enumerate(locus.nodes) if nd.role == GeneRole.POLYMERASE]
    synth_indices = [i for i, nd in enumerate(locus.nodes) if nd.role == GeneRole.TRANSMEMBRANE_SYNTHASE]
    n_link_genes = len(gt_indices) + len(poly_indices) + len(synth_indices)

    deficit = max(0, n_core - n_link_genes)
    if deficit == 0:
        return locus

    cassette_anchors = [
        i
        for i, nd in enumerate(locus.nodes)
        if nd.role
        in (
            GeneRole.GLYCOSYLTRANSFERASE,
            GeneRole.POLYMERASE,
            GeneRole.INITIATOR,
            GeneRole.FLIPPASE,
        )
    ]
    if not cassette_anchors:
        cassette_anchors = list(range(len(locus.nodes)))

    gene_lengths = locus.lengths
    counts_map = pan_cluster_counts or {}

    # Identify duplicate non-conserved FLIPPASE / CAPSULE_ANCHOR ORFs when conserved backbone hubs exist
    dup_rescue_set: set[int] = set()
    for dup_role in (GeneRole.FLIPPASE, GeneRole.CAPSULE_ANCHOR):
        role_idxs = [i for i, nd in enumerate(locus.nodes) if nd.role == dup_role]
        if len(role_idxs) >= 2:
            role_idxs.sort(
                key=lambda idx_g: (
                    counts_map.get(int(locus.nodes[idx_g].cluster_id), 0),
                    int(gene_lengths[idx_g]) if idx_g < len(gene_lengths) else 1000,
                ),
                reverse=True,
            )
            for extra_i in role_idxs[1:]:
                if counts_map.get(int(locus.nodes[extra_i].cluster_id), 0) <= 8:
                    dup_rescue_set.add(extra_i)

    candidates: list[tuple[int, int, int, int, int]] = []
    for i, nd in enumerate(locus.nodes):
        bp_len = int(gene_lengths[i]) if i < len(gene_lengths) else 1000
        if bp_len < 420:
            continue
        c_freq = counts_map.get(int(nd.cluster_id), 0)
        dist = min(abs(i - a_i) for a_i in cassette_anchors)
        if nd.role == GeneRole.UNKNOWN and c_freq <= 12:
            candidates.append((0, dist, c_freq, -bp_len, i))
        elif nd.role == GeneRole.GLYCOSYLHYDROLASE and c_freq <= 8:
            candidates.append((1, dist, c_freq, -bp_len, i))
        elif i in dup_rescue_set:
            candidates.append((2, dist, c_freq, -bp_len, i))

    if not candidates:
        return locus

    candidates.sort()
    selected = candidates[:deficit]
    new_nodes = list(locus.nodes)
    has_poly = len(poly_indices) >= 1

    for _, _, _, _, g_idx in selected:
        old_nd = new_nodes[g_idx]
        target_role = GeneRole.POLYMERASE if (is_wzy_arch and not has_poly) else GeneRole.GLYCOSYLTRANSFERASE
        if target_role == GeneRole.POLYMERASE:
            has_poly = True
        new_nodes[g_idx] = GeneNode(
            target_role,
            cluster_id=int(old_nd.cluster_id),
            is_frameshift=old_nd.is_frameshift,
        )

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

assign_genes

assign_genes(
    locus: Locus,
    glycan: Glycan,
    alignment: Alignment | None = None,
    completed_modifier_genes: dict[int, int] | None = None,
    pan_prior: PanGraphConsensusPrior | None = None,
) -> tuple[GeneGlycanAssignment, ...]

Map active biosynthetic genes in locus to their aligned residues or linkages in glycan.

Source code in src/kbank/biosynthesis/align.py
2269
2270
2271
2272
2273
2274
2275
2276
2277
2278
2279
2280
2281
2282
2283
2284
2285
2286
2287
2288
2289
2290
2291
2292
2293
2294
2295
2296
2297
2298
2299
2300
2301
2302
2303
2304
2305
2306
2307
2308
2309
2310
2311
2312
2313
2314
2315
2316
2317
2318
2319
2320
2321
2322
2323
2324
2325
2326
2327
2328
2329
2330
2331
2332
2333
2334
2335
2336
2337
2338
2339
2340
2341
2342
2343
2344
2345
2346
2347
2348
2349
2350
2351
2352
2353
2354
2355
2356
2357
2358
2359
2360
2361
2362
2363
2364
2365
2366
2367
2368
2369
2370
2371
2372
2373
2374
2375
2376
2377
2378
2379
2380
2381
2382
2383
2384
2385
2386
2387
2388
2389
2390
2391
2392
2393
2394
2395
2396
2397
2398
2399
2400
2401
2402
2403
2404
2405
2406
2407
2408
2409
2410
2411
2412
2413
2414
2415
2416
2417
2418
2419
2420
2421
2422
2423
2424
2425
2426
2427
2428
2429
2430
2431
2432
2433
2434
2435
2436
2437
2438
2439
2440
2441
2442
2443
2444
2445
2446
2447
2448
2449
2450
2451
2452
2453
2454
2455
2456
2457
2458
2459
2460
2461
2462
2463
2464
2465
2466
2467
2468
2469
2470
2471
2472
2473
2474
2475
2476
2477
2478
2479
2480
2481
2482
2483
2484
2485
2486
2487
2488
2489
2490
2491
2492
2493
2494
2495
2496
2497
2498
2499
2500
2501
2502
2503
2504
2505
2506
2507
2508
2509
2510
2511
2512
2513
2514
2515
2516
2517
2518
2519
2520
2521
2522
2523
2524
2525
2526
2527
2528
2529
2530
2531
2532
2533
2534
2535
2536
2537
2538
2539
2540
2541
2542
2543
2544
2545
2546
2547
2548
2549
2550
2551
2552
2553
2554
2555
2556
2557
2558
2559
2560
2561
2562
2563
2564
2565
2566
2567
2568
2569
2570
2571
2572
2573
2574
2575
2576
2577
2578
2579
2580
2581
2582
2583
2584
2585
2586
2587
2588
2589
2590
2591
2592
2593
2594
2595
2596
2597
2598
2599
2600
2601
2602
2603
2604
2605
2606
2607
2608
2609
2610
2611
2612
2613
2614
2615
2616
2617
2618
2619
2620
2621
2622
2623
2624
2625
2626
2627
2628
2629
2630
2631
2632
2633
2634
2635
2636
2637
2638
2639
2640
2641
2642
2643
2644
2645
2646
2647
2648
2649
2650
2651
2652
2653
2654
2655
2656
2657
2658
2659
2660
2661
2662
2663
2664
2665
2666
2667
2668
2669
2670
2671
2672
2673
2674
2675
2676
2677
2678
2679
2680
2681
2682
2683
2684
2685
2686
2687
2688
2689
2690
2691
2692
2693
2694
2695
2696
2697
2698
2699
2700
2701
2702
2703
2704
2705
2706
2707
2708
2709
2710
2711
2712
2713
2714
2715
2716
2717
2718
2719
2720
2721
2722
2723
2724
2725
2726
2727
2728
2729
2730
2731
2732
2733
2734
2735
2736
2737
2738
2739
2740
2741
2742
2743
2744
2745
2746
2747
2748
2749
2750
2751
2752
2753
2754
2755
2756
2757
2758
2759
2760
2761
2762
2763
2764
2765
2766
2767
2768
2769
2770
2771
2772
2773
2774
2775
2776
2777
2778
2779
2780
2781
2782
2783
2784
2785
2786
2787
2788
2789
2790
2791
2792
2793
2794
2795
2796
2797
2798
def assign_genes(
    self,
    locus: Locus,
    glycan: Glycan,
    alignment: Alignment | None = None,
    completed_modifier_genes: dict[int, int] | None = None,
    pan_prior: PanGraphConsensusPrior | None = None,
) -> tuple[GeneGlycanAssignment, ...]:
    """Map active biosynthetic genes in `locus` to their aligned residues or linkages in `glycan`."""
    if len(locus.nodes) == 0 or len(glycan.nodes) == 0:
        return ()

    active_prior = pan_prior if pan_prior is not None else self.pan_prior
    if alignment is None:
        if (
            active_prior is not None
            and isinstance(self.aligner.cost_strategy, BiosyntheticCostStrategy)
            and self.aligner.cost_strategy.pan_prior is not active_prior
        ):
            temp_aligner = BiosyntheticAligner(
                config=self.aligner.config,
                embedding_strategy=self.aligner.embedding_strategy,
                pan_prior=active_prior,
            )
            alignment = temp_aligner.align(locus, glycan)
        else:
            alignment = self.aligner.align(locus, glycan)

    _, cluster_labels = locus.as_graph(as_bipartite=True)
    _, glycan_labels = glycan.as_graph(as_bipartite=True)
    n_res = len(glycan.nodes)
    n_genes = len(locus.nodes)

    cost_strat = BiosyntheticCostStrategy(rule_registry=self.registry, pan_prior=active_prior)
    c_mat = cost_strat.compute_cost(cluster_labels, glycan_labels, graph_a=locus, graph_b=glycan)

    completed_mod_nodes = self._find_locus_completed_mod_nodes(glycan)
    if completed_modifier_genes:
        completed_mod_nodes |= set(completed_modifier_genes.keys())
    gene_to_completed_mod: dict[int, int] = (
        {g_i: m_i for m_i, g_i in completed_modifier_genes.items()} if completed_modifier_genes else {}
    )

    out: list[GeneGlycanAssignment] = []
    matched_gene_indices: set[int] = set()
    matched_mod_nodes: set[int] = set()

    # 1. Explicit locus-completed modifier gene assignments first
    for g_idx, mod_idx in gene_to_completed_mod.items():
        if 0 <= g_idx < n_genes and 0 <= mod_idx < n_res:
            g_node = locus.nodes[g_idx]
            res = glycan.nodes[mod_idx]
            out.append(
                GeneGlycanAssignment(
                    gene_index=int(g_idx),
                    role=g_node.role,
                    bipartite_glycan_index=int(mod_idx),
                    target_type=TargetType.MODIFIED_MONO,
                    residue=res,
                    linkage=None,
                    edge_index=None,
                    evidence=AssignmentEvidence.LOCUS_COMPLETED,
                    score=0.85,
                )
            )
            matched_gene_indices.add(int(g_idx))
            matched_mod_nodes.add(int(mod_idx))

    # Pre-collect linkage matches and pan-graph support for calibrated Boltzmann swap marginals
    precursor_roles = {
        GeneRole.OXIDOREDUCTASE,
        GeneRole.EPIMERASE,
        GeneRole.AMINOTRANSFERASE,
        GeneRole.ALDOLASE,
        GeneRole.NUCLEOTIDASE,
        GeneRole.NUCLEOTIDYLTRANSFERASE,
    }
    gt_gene_indices = [
        idx_g
        for idx_g, nd in enumerate(locus.nodes)
        if nd.role in (GeneRole.GLYCOSYLTRANSFERASE, GeneRole.POLYMERASE)
    ]
    init_gene_indices = [idx_g for idx_g, nd in enumerate(locus.nodes) if nd.role == GeneRole.INITIATOR]
    prec_gene_indices = [idx_g for idx_g, nd in enumerate(locus.nodes) if nd.role in precursor_roles]
    core_sugars_in_glycan = {nd.sugar for nd in glycan.nodes if not nd.is_modifier}
    gt_prec_sugars: dict[int, set[int]] = {idx_g: set() for idx_g in gt_gene_indices}
    if active_prior is not None and gt_gene_indices and prec_gene_indices and core_sugars_in_glycan:
        sugar_to_prec_indices: dict[int, list[int]] = {}
        for p_i in prec_gene_indices:
            p_cid = int(locus.nodes[p_i].cluster_id)
            pref_s = active_prior.preferred_precursor_sugar(p_cid, core_sugars_in_glycan)
            if pref_s is not None:
                sugar_to_prec_indices.setdefault(pref_s, []).append(p_i)
        for s_idx, p_list in sugar_to_prec_indices.items():
            dists = {g_i: min(abs(g_i - p_i) for p_i in p_list) for g_i in gt_gene_indices}
            min_d = min(dists.values())
            if min_d <= 3:
                for g_i, d_val in dists.items():
                    if d_val == min_d:
                        gt_prec_sugars[g_i].add(s_idx)

    core_bipartite_cols = [
        n_res + k
        for k, e in enumerate(glycan.edges)
        if not glycan.nodes[e.source].is_modifier and not glycan.nodes[e.target].is_modifier
    ]
    linkage_matches: dict[int, int] = {}
    direct_pan_link_genes: set[int] = set()
    loc_translations = locus.genes.translations if len(locus.genes) > 0 else None

    for g_idx, gly_idx in alignment.matches.items():
        if (
            g_idx in matched_gene_indices
            or g_idx >= n_genes
            or g_idx >= len(cluster_labels)
            or gly_idx >= len(glycan_labels)
        ):
            continue
        g_val = int(cluster_labels[g_idx])
        gly_val = int(glycan_labels[gly_idx])
        if not (g_val & TAG_EDGE) and (gly_val & TAG_EDGE):
            linkage_matches[int(g_idx)] = int(gly_idx)
            edge_idx = int(gly_idx) - n_res
            if active_prior is not None and 0 <= edge_idx < len(glycan.edges):
                gn_obj = GeneNode(g_val)
                c_id = int(gn_obj.cluster_id)
                aa_seq_g: bytes | None = (
                    bytes(loc_translations[g_idx].seq)
                    if (loc_translations is not None and g_idx < len(loc_translations))
                    else None
                )
                adj_clusters: list[tuple[int, int]] = []
                for other_i in gt_gene_indices:
                    if other_i < g_idx and (g_idx - other_i) <= 4:
                        adj_clusters.append((int(locus.nodes[other_i].cluster_id), -1))
                    elif other_i > g_idx and (other_i - g_idx) <= 4:
                        adj_clusters.append((int(locus.nodes[other_i].cluster_id), +1))
                adj_init = [
                    int(locus.nodes[init_i].cluster_id)
                    for init_i in init_gene_indices
                    if abs(g_idx - init_i) <= 2 and locus.nodes[init_i].cluster_id > 0
                ]
                adj_prec = [
                    int(locus.nodes[prec_i].cluster_id)
                    for prec_i in prec_gene_indices
                    if abs(g_idx - prec_i) <= 3 and locus.nodes[prec_i].cluster_id > 0
                ]
                _, has_pan = active_prior.gt_edge_bonus(
                    c_id,
                    glycan,
                    edge_idx,
                    adj_clusters=adj_clusters,
                    adj_init_clusters=adj_init,
                    adj_precursor_clusters=adj_prec,
                    adj_precursor_sugars=gt_prec_sugars.get(int(g_idx), set()),
                    aa_seq=aa_seq_g,
                )
                if has_pan:
                    direct_pan_link_genes.add(int(g_idx))

    # Stoichiometric elimination: if all remaining unresolved intra-unit GTs map to edges with a single
    # distinct (donor, linkage) signature (and Wzy locks the polymer edge), they are uniquely resolved
    wzy_edge_k, _, _ = glycan.polymer_edge_and_assembly_depths()
    wzy_bip_col = (n_res + wzy_edge_k) if wzy_edge_k is not None else -1
    has_wzy_locked = any(
        locus.nodes[g_i].role == GeneRole.POLYMERASE and b_i == wzy_bip_col for g_i, b_i in linkage_matches.items()
    )
    for g_i, b_i in linkage_matches.items():
        if locus.nodes[g_i].role == GeneRole.POLYMERASE and b_i == wzy_bip_col:
            direct_pan_link_genes.add(g_i)

    unresolved_gt_genes = [
        g_i
        for g_i, b_i in linkage_matches.items()
        if g_i not in direct_pan_link_genes
        and not (locus.nodes[g_i].role == GeneRole.POLYMERASE and b_i == wzy_bip_col)
    ]
    unmatched_core_cols = [col for col in core_bipartite_cols if col not in linkage_matches.values()]
    matched_dl_sigs = {
        _edge_signatures(glycan, b_i - n_res)[1]
        for b_i in linkage_matches.values()
        if 0 <= (b_i - n_res) < len(glycan.edges)
    }
    unmatched_distinct_cols = [
        col
        for col in unmatched_core_cols
        if 0 <= (col - n_res) < len(glycan.edges)
        and _edge_signatures(glycan, col - n_res)[1] not in matched_dl_sigs
    ]
    unresolved_dl_sigs = {
        _edge_signatures(glycan, linkage_matches[g_i] - n_res)[1]
        for g_i in unresolved_gt_genes
        if 0 <= (linkage_matches[g_i] - n_res) < len(glycan.edges)
    }
    if len(gt_gene_indices) == 1 or (
        len(unresolved_gt_genes) >= 1
        and len(unresolved_dl_sigs) == 1
        and len(unmatched_distinct_cols) == 0
        and (len(direct_pan_link_genes) >= 1 or has_wzy_locked)
    ):
        direct_pan_link_genes.update(unresolved_gt_genes)

    for g_idx, gly_idx in alignment.matches.items():
        if (
            g_idx in matched_gene_indices
            or g_idx >= n_genes
            or g_idx >= len(cluster_labels)
            or gly_idx >= len(glycan_labels)
        ):
            continue
        g_val = int(cluster_labels[g_idx])
        gly_val = int(glycan_labels[gly_idx])
        if g_val & TAG_EDGE:
            continue

        g_node = GeneNode(g_val)
        role = g_node.role
        if role == GeneRole.UNKNOWN:
            continue
        rule = self.registry.get_rule(role)
        if rule.target_type == TargetType.NONE:
            continue

        raw_cost = alignment.costs.get(g_idx, 0.20)
        base_quality = (
            float(np.clip(np.exp(-float(raw_cost)), 0.0, 1.0))
            if (raw_cost is not None and np.isfinite(raw_cost) and raw_cost < 1e4)
            else 0.5
        )

        if gly_val & TAG_EDGE:
            edge_idx = int(gly_idx) - n_res
            c_ii = (
                float(c_mat[g_idx, gly_idx])
                if np.isfinite(c_mat[g_idx, gly_idx]) and c_mat[g_idx, gly_idx] < 1e4
                else 0.25
            )
            sig_i = _edge_signatures(glycan, edge_idx)[1] if 0 <= edge_idx < len(glycan.edges) else ()

            alt_weight_sum = 0.0
            tau = 0.085
            for g_k, b_k in linkage_matches.items():
                if g_k == g_idx:
                    continue
                edge_k = b_k - n_res
                sig_k = _edge_signatures(glycan, edge_k)[1] if 0 <= edge_k < len(glycan.edges) else ()
                if sig_i and sig_i == sig_k:
                    continue
                c_kk = float(c_mat[g_k, b_k]) if np.isfinite(c_mat[g_k, b_k]) else 0.25
                c_ik = float(c_mat[g_idx, b_k]) if np.isfinite(c_mat[g_idx, b_k]) else 2.0
                c_ki = float(c_mat[g_k, gly_idx]) if np.isfinite(c_mat[g_k, gly_idx]) else 2.0
                delta_swap = max(0.0, (c_ik + c_ki) - (c_ii + c_kk))
                alt_weight_sum += float(np.exp(-min(delta_swap / tau, 30.0)))

            for b_u in unmatched_core_cols:
                edge_u = b_u - n_res
                sig_u = _edge_signatures(glycan, edge_u)[1] if 0 <= edge_u < len(glycan.edges) else ()
                if sig_i and sig_i == sig_u:
                    continue
                c_iu = float(c_mat[g_idx, b_u]) if np.isfinite(c_mat[g_idx, b_u]) else 2.0
                delta_move = max(0.0, c_iu - c_ii)
                alt_weight_sum += float(np.exp(-min(delta_move / tau, 30.0)))

            marginal_prob = 1.0 / (1.0 + alt_weight_sum)
            is_pan = int(g_idx) in direct_pan_link_genes
            if is_pan:
                marginal_prob = max(marginal_prob, 0.85)
            match_score = float(np.clip(0.30 * np.exp(-c_ii) + 0.70 * marginal_prob, 0.10, 0.99))
            ev_link = AssignmentEvidence.PAN_GRAPH_CONSENSUS if is_pan else AssignmentEvidence.GRAPH_ALIGNED

            out.append(
                GeneGlycanAssignment(
                    gene_index=int(g_idx),
                    role=role,
                    bipartite_glycan_index=int(gly_idx),
                    target_type=TargetType.LINKAGE,
                    residue=None,
                    linkage=LinkageType(gly_val),
                    edge_index=edge_idx if 0 <= edge_idx < len(glycan.edges) else None,
                    evidence=ev_link,
                    score=match_score,
                )
            )
            matched_gene_indices.add(int(g_idx))
        else:
            if int(gly_idx) in matched_mod_nodes:
                continue
            res = ResidueType(gly_val)
            t_type = (
                TargetType.MODIFIED_MONO
                if res.is_modifier
                else (TargetType.REDUCING_END if res.is_reducing_end else TargetType.MONOSACCHARIDE)
            )
            if int(gly_idx) in completed_mod_nodes:
                ev = AssignmentEvidence.LOCUS_COMPLETED
                node_score = 0.85
            elif (
                t_type == TargetType.REDUCING_END
                and active_prior is not None
                and active_prior.has_init_consensus(int(g_node.cluster_id), res.sugar)
            ):
                ev = AssignmentEvidence.PAN_GRAPH_CONSENSUS
                node_score = max(base_quality, 0.96)
            else:
                ev = AssignmentEvidence.GRAPH_ALIGNED
                node_score = base_quality
            out.append(
                GeneGlycanAssignment(
                    gene_index=int(g_idx),
                    role=role,
                    bipartite_glycan_index=int(gly_idx),
                    target_type=t_type,
                    residue=res,
                    linkage=None,
                    edge_index=None,
                    evidence=ev,
                    score=node_score,
                )
            )
            matched_gene_indices.add(int(g_idx))
            if res.is_modifier:
                matched_mod_nodes.add(int(gly_idx))

    # Processive / bifunctional GT & transmembrane synthase multi-linkage completion for remaining core edges
    if unmatched_core_cols:
        cand_multi_genes = [
            idx_g
            for idx_g, nd in enumerate(locus.nodes)
            if nd.role in (GeneRole.GLYCOSYLTRANSFERASE, GeneRole.TRANSMEMBRANE_SYNTHASE)
        ]
        if cand_multi_genes:
            gene_lengths = locus.lengths
            term_leaves = set(glycan.terminal_leaf_indices())
            for b_u in unmatched_core_cols:
                edge_u = b_u - n_res
                if not (0 <= edge_u < len(glycan.edges)):
                    continue
                if b_u == wzy_bip_col and has_wzy_locked:
                    continue
                e_u = glycan.edges[edge_u]
                _, sig_dl_u, sig_da_u, _ = _edge_signatures(glycan, edge_u)

                def _score_multi_gt(
                    g_c: int,
                    _b_u: int = b_u,
                    _e_u: Any = e_u,
                    _sig_dl_u: Any = sig_dl_u,
                    _sig_da_u: Any = sig_da_u,
                ) -> tuple[int, int, int, float, int, int]:
                    unmatched_g = int(g_c not in linkage_matches)
                    b_p = linkage_matches.get(g_c, -1)
                    edge_p = b_p - n_res
                    has_p = 0 <= edge_p < len(glycan.edges)
                    same_dl = int(has_p and _edge_signatures(glycan, edge_p)[1] == _sig_dl_u)
                    same_da = int(has_p and _edge_signatures(glycan, edge_p)[2] == _sig_da_u)
                    is_adj = int(
                        has_p
                        and (
                            glycan.edges[edge_p].target == _e_u.source or glycan.edges[edge_p].source == _e_u.target
                        )
                    )
                    c_val = (
                        float(c_mat[g_c, _b_u])
                        if np.isfinite(c_mat[g_c, _b_u])
                        else (float(c_mat[g_c, b_p]) if (has_p and np.isfinite(c_mat[g_c, b_p])) else 1.0)
                    )
                    bp_len = int(gene_lengths[g_c]) if g_c < len(gene_lengths) else 1000
                    return (unmatched_g, same_dl, same_da, -round(c_val, 3), is_adj, bp_len)

                best_g = max(cand_multi_genes, key=_score_multi_gt)
                b_p = linkage_matches.get(best_g, -1)
                edge_p = b_p - n_res
                same_dl = 0 <= edge_p < len(glycan.edges) and _edge_signatures(glycan, edge_p)[1] == sig_dl_u
                same_da_count = sum(
                    1
                    for g_o in cand_multi_genes
                    if g_o in linkage_matches
                    and 0 <= (linkage_matches[g_o] - n_res) < len(glycan.edges)
                    and _edge_signatures(glycan, linkage_matches[g_o] - n_res)[2] == sig_da_u
                )
                has_pan_u = False
                if active_prior is not None and locus.nodes[best_g].cluster_id > 0:
                    _, has_pan_u = active_prior.gt_edge_bonus(
                        int(locus.nodes[best_g].cluster_id),
                        glycan,
                        edge_u,
                    )

                # Extra-locus GtrABC-style terminal side-chain alpha-Glc/Gal branch when all locus GTs are saturated
                s_u_node = glycan.nodes[e_u.source]
                s_u_name = Sugars.names[s_u_node.sugar]
                if (
                    best_g in linkage_matches
                    and len(cand_multi_genes) >= 1
                    and locus.nodes[best_g].role != GeneRole.TRANSMEMBRANE_SYNTHASE
                    and not same_dl
                    and not has_pan_u
                    and e_u.edge_type.is_branch
                    and e_u.source in term_leaves
                    and s_u_name in (b"Glc", b"Gal")
                    and e_u.edge_type.anomer == Anomer.ALPHA
                    and (0 <= edge_p < len(glycan.edges) and glycan.edges[edge_p].edge_type.is_backbone)
                ):
                    out.append(
                        GeneGlycanAssignment(
                            gene_index=-1,
                            role=GeneRole.GLYCOSYLTRANSFERASE,
                            bipartite_glycan_index=int(b_u),
                            target_type=TargetType.LINKAGE,
                            residue=None,
                            linkage=e_u.edge_type,
                            edge_index=edge_u,
                            evidence=AssignmentEvidence.RULE_INFERRED,
                            score=0.75,
                        )
                    )
                    continue
                is_pan_u = (
                    has_pan_u
                    or len(cand_multi_genes) == 1
                    or (same_dl and (best_g in direct_pan_link_genes or same_da_count == 1))
                )
                c_u = float(c_mat[best_g, b_u]) if np.isfinite(c_mat[best_g, b_u]) else 0.20
                score_u = (
                    float(np.clip(0.30 * np.exp(-c_u) + 0.62, 0.82, 0.96))
                    if is_pan_u
                    else float(np.clip(0.80 * np.exp(-c_u), 0.55, 0.84))
                )
                out.append(
                    GeneGlycanAssignment(
                        gene_index=int(best_g),
                        role=locus.nodes[best_g].role,
                        bipartite_glycan_index=int(b_u),
                        target_type=TargetType.LINKAGE,
                        residue=None,
                        linkage=e_u.edge_type,
                        edge_index=edge_u,
                        evidence=(
                            AssignmentEvidence.PAN_GRAPH_CONSENSUS if is_pan_u else AssignmentEvidence.GRAPH_ALIGNED
                        ),
                        score=score_u,
                    )
                )
                matched_gene_indices.add(int(best_g))
                if best_g not in linkage_matches:
                    linkage_matches[int(best_g)] = int(b_u)

    # Identify N-linked OAc modifier nodes (e.g. Ac(1-2) on GlcN -> GlcNAc) vs true O-acetylation
    n_acetyl_mod_nodes, n_acetylated_sugar_nodes = self._find_n_acetyl_nodes(glycan)

    # Many-to-many pathway completion: link additional precursor synthesis / modifier genes in the locus
    # (e.g., rmlABCD or Neu5Ac/Pse/Leg pathway genes) to their compatible monosaccharide / modifier nodes
    pathway_roles = (
        GeneRole.OXIDOREDUCTASE,
        GeneRole.EPIMERASE,
        GeneRole.AMINOTRANSFERASE,
        GeneRole.ALDOLASE,
        GeneRole.NUCLEOTIDASE,
        GeneRole.DEACETYLASE,
        GeneRole.PYRUVYLTRANSFERASE,
        GeneRole.METHYLTRANSFERASE,
        GeneRole.ACETYLTRANSFERASE,
        GeneRole.PHOSPHOTRANSFERASE,
        GeneRole.FORMYLTRANSFERASE,
        GeneRole.ALANINE_TRANSFERASE,
    )
    for g_idx in range(n_genes):
        if g_idx in matched_gene_indices:
            continue
        g_node = locus.nodes[g_idx]
        role = g_node.role
        if role not in pathway_roles:
            continue
        rule = self.registry.get_rule(role)
        # Prefer unmatched modifier nodes first before sharing an already-matched modifier node
        candidate_node_order = sorted(
            range(len(glycan.nodes)),
            key=lambda j: (j in matched_mod_nodes, j not in completed_mod_nodes, j),
        )
        for j in candidate_node_order:
            res = glycan.nodes[j]
            if res.is_modifier:
                if j in n_acetyl_mod_nodes or (j in matched_mod_nodes and j in completed_mod_nodes):
                    continue
                if res.sugar in rule.target_modifiers:
                    ev = (
                        AssignmentEvidence.LOCUS_COMPLETED
                        if j in completed_mod_nodes
                        else AssignmentEvidence.GRAPH_ALIGNED
                    )
                    out.append(
                        GeneGlycanAssignment(
                            gene_index=g_idx,
                            role=role,
                            bipartite_glycan_index=j,
                            target_type=TargetType.MODIFIED_MONO,
                            residue=res,
                            linkage=None,
                            edge_index=None,
                            evidence=ev,
                            score=0.85 if ev == AssignmentEvidence.LOCUS_COMPLETED else 0.90,
                        )
                    )
                    matched_mod_nodes.add(j)
                    break
            else:
                if role == GeneRole.DEACETYLASE and j in n_acetylated_sugar_nodes:
                    continue
                if res.sugar in rule.compatible_sugars or res.sugar in rule.target_modifiers:
                    out.append(
                        GeneGlycanAssignment(
                            gene_index=g_idx,
                            role=role,
                            bipartite_glycan_index=j,
                            target_type=(
                                TargetType.MODIFIED_MONO
                                if role == GeneRole.DEACETYLASE
                                else TargetType.MONOSACCHARIDE
                            ),
                            residue=res,
                            linkage=None,
                            edge_index=None,
                            evidence=AssignmentEvidence.GRAPH_ALIGNED,
                            score=0.88,
                        )
                    )
                    break

    out.sort(key=lambda a: (a.gene_index if a.gene_index >= 0 else 9999, a.bipartite_glycan_index))
    return tuple(out)

infer_rule_assignments

infer_rule_assignments(
    glycan: Glycan,
    archetype: LocusArchetype = UNKNOWN,
    is_repeating: bool = True,
) -> tuple[GeneGlycanAssignment, ...]

Infer stoichiometric GeneRole -> glycan component assignments via BiosyntheticRuleRegistry.

Source code in src/kbank/biosynthesis/align.py
def infer_rule_assignments(
    self,
    glycan: Glycan,
    archetype: LocusArchetype = LocusArchetype.UNKNOWN,
    is_repeating: bool = True,
) -> tuple[GeneGlycanAssignment, ...]:
    """Infer stoichiometric `GeneRole` -> glycan component assignments via `BiosyntheticRuleRegistry`."""
    n_res = len(glycan.nodes)
    if n_res == 0:
        return ()

    n_acetyl_mod_nodes, n_acetylated_sugar_nodes = self._find_n_acetyl_nodes(glycan)

    red_indices = {j for j, r in enumerate(glycan.nodes) if r.is_reducing_end and not r.is_modifier}
    if not red_indices:
        for j, r in enumerate(glycan.nodes):
            if not r.is_modifier:
                red_indices.add(j)
                break

    is_synthase_arch = archetype in (
        LocusArchetype.CPS_SYNTHASE,
        LocusArchetype.EPS_ALGINATE,
        LocusArchetype.EPS_BCS,
        LocusArchetype.EPS_PGA,
        LocusArchetype.EPS_PEL,
    )
    is_wzy_arch = archetype in (
        LocusArchetype.LPS_WZY,
        LocusArchetype.CPS_WZY,
        LocusArchetype.UNKNOWN,
    )

    mod_roles = (
        GeneRole.PYRUVYLTRANSFERASE,
        GeneRole.METHYLTRANSFERASE,
        GeneRole.ACETYLTRANSFERASE,
        GeneRole.PHOSPHOTRANSFERASE,
        GeneRole.FORMYLTRANSFERASE,
        GeneRole.ALANINE_TRANSFERASE,
    )
    oxr_rule = self.registry.get_rule(GeneRole.OXIDOREDUCTASE)
    atn_rule = self.registry.get_rule(GeneRole.AMINOTRANSFERASE)
    ald_rule = self.registry.get_rule(GeneRole.ALDOLASE)
    deac_rule = self.registry.get_rule(GeneRole.DEACETYLASE)

    out: list[GeneGlycanAssignment] = []

    # 1. Residue and Modifier Nodes
    for j, res in enumerate(glycan.nodes):
        s_idx = res.sugar
        if res.is_modifier:
            if j in n_acetyl_mod_nodes:
                continue
            matched_mod_role = GeneRole.ACETYLTRANSFERASE
            for m_role in mod_roles:
                if s_idx in self.registry.get_rule(m_role).target_modifiers:
                    matched_mod_role = m_role
                    break
            out.append(
                GeneGlycanAssignment(
                    gene_index=-1,
                    role=matched_mod_role,
                    bipartite_glycan_index=j,
                    target_type=TargetType.MODIFIED_MONO,
                    residue=res,
                    linkage=None,
                    edge_index=None,
                    evidence=AssignmentEvidence.RULE_INFERRED,
                    score=0.90,
                )
            )
        else:
            if j in red_indices:
                init_role = GeneRole.TRANSMEMBRANE_SYNTHASE if is_synthase_arch else GeneRole.INITIATOR
                out.append(
                    GeneGlycanAssignment(
                        gene_index=-1,
                        role=init_role,
                        bipartite_glycan_index=j,
                        target_type=TargetType.REDUCING_END,
                        residue=res,
                        linkage=None,
                        edge_index=None,
                        evidence=AssignmentEvidence.RULE_INFERRED,
                        score=0.90,
                    )
                )

            # Specialized monosaccharide precursor pathways & deacetylation
            s_name = Sugars.names[s_idx]
            if s_idx in deac_rule.target_modifiers and j not in n_acetylated_sugar_nodes:
                out.append(
                    GeneGlycanAssignment(
                        gene_index=-1,
                        role=GeneRole.DEACETYLASE,
                        bipartite_glycan_index=j,
                        target_type=TargetType.MODIFIED_MONO,
                        residue=res,
                        linkage=None,
                        edge_index=None,
                        evidence=AssignmentEvidence.RULE_INFERRED,
                        score=0.85,
                    )
                )
            if s_idx in oxr_rule.compatible_sugars:
                out.append(
                    GeneGlycanAssignment(
                        gene_index=-1,
                        role=GeneRole.OXIDOREDUCTASE,
                        bipartite_glycan_index=j,
                        target_type=TargetType.MONOSACCHARIDE,
                        residue=res,
                        linkage=None,
                        edge_index=None,
                        evidence=AssignmentEvidence.RULE_INFERRED,
                        score=0.85,
                    )
                )
                out.append(
                    GeneGlycanAssignment(
                        gene_index=-1,
                        role=GeneRole.EPIMERASE,
                        bipartite_glycan_index=j,
                        target_type=TargetType.MONOSACCHARIDE,
                        residue=res,
                        linkage=None,
                        edge_index=None,
                        evidence=AssignmentEvidence.RULE_INFERRED,
                        score=0.80,
                    )
                )
            if s_idx in atn_rule.compatible_sugars and s_name not in (b"GlcN", b"GalN", b"ManN"):
                out.append(
                    GeneGlycanAssignment(
                        gene_index=-1,
                        role=GeneRole.AMINOTRANSFERASE,
                        bipartite_glycan_index=j,
                        target_type=TargetType.MONOSACCHARIDE,
                        residue=res,
                        linkage=None,
                        edge_index=None,
                        evidence=AssignmentEvidence.RULE_INFERRED,
                        score=0.85,
                    )
                )
            if s_idx in ald_rule.compatible_sugars:
                out.append(
                    GeneGlycanAssignment(
                        gene_index=-1,
                        role=GeneRole.ALDOLASE,
                        bipartite_glycan_index=j,
                        target_type=TargetType.MONOSACCHARIDE,
                        residue=res,
                        linkage=None,
                        edge_index=None,
                        evidence=AssignmentEvidence.RULE_INFERRED,
                        score=0.85,
                    )
                )
                out.append(
                    GeneGlycanAssignment(
                        gene_index=-1,
                        role=GeneRole.NUCLEOTIDASE,
                        bipartite_glycan_index=j,
                        target_type=TargetType.MONOSACCHARIDE,
                        residue=res,
                        linkage=None,
                        edge_index=None,
                        evidence=AssignmentEvidence.RULE_INFERRED,
                        score=0.80,
                    )
                )

    # 2. Glycosidic Linkage Edges
    core_edge_indices = [
        k
        for k, edge in enumerate(glycan.edges)
        if not glycan.nodes[edge.source].is_modifier and not glycan.nodes[edge.target].is_modifier
    ]
    wzy_edge_idx: int | None = None
    if is_repeating and is_wzy_arch and len(core_edge_indices) >= 2:
        red_idx = min(red_indices) if red_indices else 0
        wzy_edge_idx, _, _ = glycan.polymer_edge_and_assembly_depths(red_idx)
        if wzy_edge_idx is None:
            wzy_edge_idx = core_edge_indices[0]

    for k in core_edge_indices:
        edge = glycan.edges[k]
        if is_synthase_arch:
            edge_role = GeneRole.TRANSMEMBRANE_SYNTHASE
        elif k == wzy_edge_idx:
            edge_role = GeneRole.POLYMERASE
        else:
            edge_role = GeneRole.GLYCOSYLTRANSFERASE
        out.append(
            GeneGlycanAssignment(
                gene_index=-1,
                role=edge_role,
                bipartite_glycan_index=n_res + k,
                target_type=TargetType.LINKAGE,
                residue=None,
                linkage=edge.edge_type,
                edge_index=k,
                evidence=AssignmentEvidence.RULE_INFERRED,
                score=0.85,
            )
        )

    return tuple(out)

assign_record

assign_record(
    glycan: Glycan | None,
    locus: Locus | None = None,
    archetype: LocusArchetype = UNKNOWN,
    is_repeating: bool = True,
    alignment: Alignment | None = None,
    completed_modifier_genes: dict[int, int] | None = None,
    pan_prior: PanGraphConsensusPrior | None = None,
) -> tuple[GeneGlycanAssignment, ...]

Resolve complete gene-to-glycan component assignments for a record (graph-aligned + rule fallback).

Source code in src/kbank/biosynthesis/align.py
def assign_record(
    self,
    glycan: Glycan | None,
    locus: Locus | None = None,
    archetype: LocusArchetype = LocusArchetype.UNKNOWN,
    is_repeating: bool = True,
    alignment: Alignment | None = None,
    completed_modifier_genes: dict[int, int] | None = None,
    pan_prior: PanGraphConsensusPrior | None = None,
) -> tuple[GeneGlycanAssignment, ...]:
    """Resolve complete gene-to-glycan component assignments for a record (graph-aligned + rule fallback)."""
    if glycan is None or len(glycan.nodes) == 0:
        return ()

    rule_assigns = self.infer_rule_assignments(glycan, archetype=archetype, is_repeating=is_repeating)
    if locus is None or len(locus.nodes) == 0:
        return rule_assigns

    aligned = self.assign_genes(
        locus,
        glycan,
        alignment=alignment,
        completed_modifier_genes=completed_modifier_genes,
        pan_prior=pan_prior,
    )
    if not aligned:
        return rule_assigns

    covered_bipartite_types = {(a.bipartite_glycan_index, a.target_type) for a in aligned}
    covered_bipartite_roles = {(a.bipartite_glycan_index, a.role) for a in aligned}

    merged = list(aligned)
    for ra in rule_assigns:
        if ra.target_type in (TargetType.LINKAGE, TargetType.REDUCING_END):
            if (ra.bipartite_glycan_index, ra.target_type) in covered_bipartite_types:
                continue
        elif (ra.bipartite_glycan_index, ra.role) in covered_bipartite_roles or (
            ra.bipartite_glycan_index,
            ra.target_type,
        ) in covered_bipartite_types:
            continue
        merged.append(ra)

    return tuple(merged)

assign_gt_linkages

assign_gt_linkages(
    locus: Locus,
    glycan: Glycan,
    alignment: Alignment | None = None,
) -> dict[int, tuple[int, LinkageType]]

Map each glycosyltransferase / polymerase / synthase in locus to its Glycan linkage.

Source code in src/kbank/biosynthesis/align.py
def assign_gt_linkages(
    self,
    locus: Locus,
    glycan: Glycan,
    alignment: Alignment | None = None,
) -> dict[int, tuple[int, LinkageType]]:
    """Map each glycosyltransferase / polymerase / synthase in `locus` to its `Glycan` linkage."""
    assignments: dict[int, tuple[int, LinkageType]] = {}
    for item in self.assign_genes(locus, glycan, alignment=alignment):
        if item.linkage is not None and item.role in (
            GeneRole.GLYCOSYLTRANSFERASE,
            GeneRole.POLYMERASE,
            GeneRole.TRANSMEMBRANE_SYNTHASE,
        ):
            assignments[item.gene_index] = (item.bipartite_glycan_index, item.linkage)
    return assignments

score_locus

score_locus(
    locus: Locus,
    glycan: Glycan,
    locus_index: int = 0,
    expected_category: ArchetypeCategory | None = None,
    expected_archetype: LocusArchetype | None = None,
) -> LocusMatchResult

Score a single candidate Locus against glycan by combining archetype, completeness, and alignment.

Source code in src/kbank/biosynthesis/align.py
def score_locus(
    self,
    locus: Locus,
    glycan: Glycan,
    locus_index: int = 0,
    expected_category: ArchetypeCategory | None = None,
    expected_archetype: LocusArchetype | None = None,
) -> LocusMatchResult:
    """Score a single candidate `Locus` against `glycan` by combining archetype, completeness, and alignment."""
    locus = locus.canonicalize_strand()
    if len(locus.nodes) == 0 or len(glycan.nodes) == 0:
        empty_align = Alignment(
            matches={},
            costs={},
            total_cost=float("inf"),
            graph_a=locus,
            graph_b=glycan,
            unmatched_a=tuple(range(len(locus.nodes))),
            unmatched_b=tuple(range(len(glycan.nodes))),
        )
        return LocusMatchResult(
            locus_index=locus_index,
            locus=locus,
            archetype=locus.archetype,
            alignment=empty_align,
            combined_score=0.0,
            bio_score=0.0,
            assignments=(),
        )

    alignment = self.aligner.align(locus, glycan)
    assignments = self.assign_genes(locus, glycan, alignment=alignment)

    # Count non-modifier glycosidic linkages vs GT/polymerase/synthase count
    n_core_linkages = sum(
        1 for e in glycan.edges if not glycan.nodes[e.source].is_modifier and not glycan.nodes[e.target].is_modifier
    )
    roles = locus.roles
    n_gt_like = int(
        np.sum(
            (roles == int(GeneRole.GLYCOSYLTRANSFERASE))
            | (roles == int(GeneRole.POLYMERASE))
            | (roles == int(GeneRole.TRANSMEMBRANE_SYNTHASE))
        )
    )
    gt_ratio_score = (
        1.0 - min(abs(n_gt_like - n_core_linkages) / max(n_core_linkages, n_gt_like, 1), 1.0)
        if (n_core_linkages > 0 or n_gt_like > 0)
        else 0.5
    )

    # Evaluate biochemical match costs across assigned gene nodes
    matched_costs: list[float] = []
    for a in assignments:
        c_val = alignment.costs.get(a.gene_index)
        if c_val is not None and np.isfinite(c_val) and c_val < 1e4:
            matched_costs.append(float(c_val))

    if matched_costs:
        mean_match_quality = float(np.mean(np.exp(-np.asarray(matched_costs, dtype=np.float64))))
    else:
        mean_match_quality = 0.0

    bio_score = float(np.clip(0.55 * mean_match_quality + 0.45 * gt_ratio_score, 0.0, 1.0))

    # Archetype compatibility score
    arch = locus.archetype
    if expected_archetype is not None and arch == expected_archetype:
        arch_bonus = 1.0
    elif expected_category is not None and arch.category == expected_category:
        arch_bonus = 0.85
    elif expected_category is None and arch != LocusArchetype.UNKNOWN:
        arch_bonus = 0.65
    elif arch == LocusArchetype.UNKNOWN:
        arch_bonus = 0.25
    else:
        arch_bonus = 0.0

    comp = float(np.clip(locus.completeness, 0.0, 1.0))
    seg_score = float(np.tanh(max(locus.score, 0.0) / 12.0))
    intact_factor = float(
        np.clip(1.0 - 0.35 * locus.frameshift_fraction - (0.15 if locus.is_fragmented else 0.0), 0.3, 1.0)
    )
    combined = float((0.40 * arch_bonus + 0.35 * bio_score + 0.15 * comp + 0.10 * seg_score) * intact_factor)

    return LocusMatchResult(
        locus_index=locus_index,
        locus=locus,
        archetype=arch,
        alignment=alignment,
        combined_score=combined,
        bio_score=bio_score,
        assignments=assignments,
    )

rank_loci

rank_loci(
    glycan: Glycan,
    loci: Loci | Sequence[Locus],
    *,
    antigen_hint: bytes | str = b"",
    expected_category: ArchetypeCategory | None = None,
    expected_archetype: LocusArchetype | None = None,
) -> list[LocusMatchResult]

Score and rank all candidate Loci against glycan, prioritizing matching archetypes.

Source code in src/kbank/biosynthesis/align.py
def rank_loci(
    self,
    glycan: Glycan,
    loci: Loci | Sequence[Locus],
    *,
    antigen_hint: bytes | str = b"",
    expected_category: ArchetypeCategory | None = None,
    expected_archetype: LocusArchetype | None = None,
) -> list[LocusMatchResult]:
    """Score and rank all candidate `Loci` against `glycan`, prioritizing matching archetypes."""
    if expected_category is None and expected_archetype is None and antigen_hint:
        expected_category, expected_archetype = self.infer_expected_archetype(antigen_hint)

    candidates = [loci[i] for i in range(len(loci))] if isinstance(loci, Loci) else list(loci)
    if not candidates:
        return []

    results = [
        self.score_locus(
            loc,
            glycan,
            locus_index=idx,
            expected_category=expected_category,
            expected_archetype=expected_archetype,
        )
        for idx, loc in enumerate(candidates)
    ]

    # If any candidate matches the expected archetype or category, filter/prioritize those first
    if expected_archetype is not None:
        exact = [r for r in results if r.archetype == expected_archetype]
        if exact:
            exact.sort(key=lambda r: r.combined_score, reverse=True)
            others = [r for r in results if r.archetype != expected_archetype]
            others.sort(key=lambda r: r.combined_score, reverse=True)
            return exact + others

    if expected_category is not None:
        cat_matches = [r for r in results if r.archetype.category == expected_category]
        if cat_matches:
            cat_matches.sort(key=lambda r: r.combined_score, reverse=True)
            others = [r for r in results if r.archetype.category != expected_category]
            others.sort(key=lambda r: r.combined_score, reverse=True)
            return cat_matches + others

    results.sort(key=lambda r: r.combined_score, reverse=True)
    return results

match_locus

match_locus(
    glycan: Glycan,
    loci: Loci | Sequence[Locus],
    *,
    antigen_hint: bytes | str = b"",
    expected_category: ArchetypeCategory | None = None,
    expected_archetype: LocusArchetype | None = None,
    require_category_match: bool = False,
) -> LocusMatchResult | None

Select the highest-scoring candidate Locus for glycan from loci.

Source code in src/kbank/biosynthesis/align.py
def match_locus(
    self,
    glycan: Glycan,
    loci: Loci | Sequence[Locus],
    *,
    antigen_hint: bytes | str = b"",
    expected_category: ArchetypeCategory | None = None,
    expected_archetype: LocusArchetype | None = None,
    require_category_match: bool = False,
) -> LocusMatchResult | None:
    """Select the highest-scoring candidate `Locus` for `glycan` from `loci`."""
    if expected_category is None and expected_archetype is None and antigen_hint:
        expected_category, expected_archetype = self.infer_expected_archetype(antigen_hint)

    ranked = self.rank_loci(
        glycan,
        loci,
        antigen_hint=antigen_hint,
        expected_category=expected_category,
        expected_archetype=expected_archetype,
    )
    if not ranked:
        return None

    best = ranked[0]
    if require_category_match and expected_category is not None:
        if best.archetype.category != expected_category:
            return None
    return best

infer_gt_linkage_assignments

infer_gt_linkage_assignments(
    cluster: Locus,
    glycan: Glycan,
    alignment: Alignment | None = None,
) -> dict[int, tuple[int, LinkageType]]

Explicit helper mapping each glycosyltransferase in a Locus to its Glycan linkage.

Source code in src/kbank/biosynthesis/align.py
def infer_gt_linkage_assignments(
    cluster: Locus,
    glycan: Glycan,
    alignment: Alignment | None = None,
) -> dict[int, tuple[int, LinkageType]]:
    """Explicit helper mapping each glycosyltransferase in a `Locus` to its `Glycan` linkage."""
    return BiosyntheticAssigner().assign_gt_linkages(cluster, glycan, alignment=alignment)

10-Head 1D-CNN Enzyme Classifiers (kbank.biosynthesis.classifier)

classifier

Sequence-based 1D-CNN classifiers for glycosyltransferase and initiator specificity.

Provides ClusterClassifier (a subclass of serogen.genes.GeneRoleClassifier) and BiosyntheticClassifierSuite for predicting donor monosaccharide, glycosidic linkage, retaining/inverting catalytic mechanism, and initiating Und-PP sugar directly from amino acid sequences, with homology-cluster probability pooling (predict_cluster_proba).

Classes:

Name Description
ClusterClassifier

1D-CNN classifier predicting fine-grained glycan reaction specificity from ORF protein sequences.

BiosyntheticClassifierSuite

Factorized suite of 10 ClusterClassifier models for hybrid pan-graph / sequence inference.

Functions:

Name Description
encode_donor_residue_chem

Return topology-invariant integer code for a donor ResidueType (sugar | chirality | furanose).

encode_donor_linkage_label

Pack (donor_residue_chem, linkage_chem) into a single positive 32-bit integer class label.

decode_donor_linkage_label

Unpack a donor_linkage integer class label into (ResidueType, LinkageType).

encode_acceptor_motif_label

Pack (acceptor_residue_chem, acc_anomer, acc_role) into a positive integer class label.

decode_acceptor_motif_label

Unpack an acceptor_motif integer label into (ResidueType, Anomer, acc_role).

extract_acceptor_motif_for_edge

Compute the acceptor_motif integer code ({anomer}{sugar}@{root|bb|br}) for the target residue of edge_idx.

split_bifunctional_gt_domains

Split a multi-domain bifunctional GT (len(aa_seq) >= min_len) into (N_term_seq, C_term_seq).

encode_modifier_linkage_label

Pack (modifier_sugar_id, acceptor_carbon) into a positive integer class label.

decode_modifier_linkage_label

Unpack a modifier_linkage integer class label into (modifier_sugar_id, acceptor_carbon).

ClusterClassifier

Bases: GeneRoleClassifier

1D-CNN classifier predicting fine-grained glycan reaction specificity from ORF protein sequences.

Subclasses serogen.genes.GeneRoleClassifier to support target tasks: - "donor_linkage": Joint (donor_residue, anomer, donor_c, acceptor_c) glycosidic reaction. - "donor": Transferred donor monosaccharide (ResidueType chemical bits). - "linkage": Formed glycosidic bond (LinkageType chemical bits: anomer, donor_c, acceptor_c). - "acceptor_sugar": Acceptor monosaccharide (ResidueType chemical bits). - "acceptor_motif": Acceptor stereochemical and topological motif ({anomer}{sugar}@{root|bb|br}). - "is_backbone": Main-chain backbone (1) vs. side-chain branch (0) glycosidic linkage. - "mechanism": Retaining (1) vs. inverting (2) GT stereochemical mechanism. - "anomer": Alpha (1) vs. beta (2) anomeric configuration. - "initiator_sugar": Und-PP initiating monosaccharide index (Sugars index) for INITIATOR genes. - "wzy_linkage": Repeating-unit polymerase (Wzy) linkage specificity. - "modifier_linkage": Non-carbohydrate modifier (OAc, Pyr, OMe, etc.) and acceptor carbon. - "component_int": Raw bit-packed GeneGlycanAssignment.component_int.

Methods:

Name Description
format_target_label

Convert an integer class label for target into a human-readable biochemical string.

decode_label

Return a human-readable biochemical label for an integer class value.

predict_cluster_proba

Aggregate sequence class probabilities per homology cluster_id via geometric (log_proba) or mean pooling.

extract_training_data

Extract (sequences, y_labels, sample_weights, cluster_ids) from GlycanRecords.

fit_from_records

Extract training examples for self.target from records and fit the 1D-CNN.

Attributes:

Name Type Description
class_names tuple[str, ...]

Human-readable names corresponding to self.classes_.

Source code in src/kbank/biosynthesis/classifier.py
class ClusterClassifier(GeneRoleClassifier):
    """1D-CNN classifier predicting fine-grained glycan reaction specificity from ORF protein sequences.

    Subclasses `serogen.genes.GeneRoleClassifier` to support target tasks:
    - `"donor_linkage"`: Joint `(donor_residue, anomer, donor_c, acceptor_c)` glycosidic reaction.
    - `"donor"`: Transferred donor monosaccharide (`ResidueType` chemical bits).
    - `"linkage"`: Formed glycosidic bond (`LinkageType` chemical bits: `anomer`, `donor_c`, `acceptor_c`).
    - `"acceptor_sugar"`: Acceptor monosaccharide (`ResidueType` chemical bits).
    - `"acceptor_motif"`: Acceptor stereochemical and topological motif (`{anomer}{sugar}@{root|bb|br}`).
    - `"is_backbone"`: Main-chain backbone (`1`) vs. side-chain branch (`0`) glycosidic linkage.
    - `"mechanism"`: Retaining (`1`) vs. inverting (`2`) GT stereochemical mechanism.
    - `"anomer"`: Alpha (`1`) vs. beta (`2`) anomeric configuration.
    - `"initiator_sugar"`: Und-PP initiating monosaccharide index (`Sugars` index) for `INITIATOR` genes.
    - `"wzy_linkage"`: Repeating-unit polymerase (`Wzy`) linkage specificity.
    - `"modifier_linkage"`: Non-carbohydrate modifier (`OAc`, `Pyr`, `OMe`, etc.) and acceptor carbon.
    - `"component_int"`: Raw bit-packed `GeneGlycanAssignment.component_int`.
    """

    target: str

    def __init__(
        self,
        target: str = "donor_linkage",
        vocab_size: int = 23,
        embed_dim: int = 16,
        num_filters: int = 64,
        kernel_size: int | tuple[int, ...] = (5, 9, 13),
        learning_rate: float = 0.008,
        epochs: int = 25,
        batch_size: int = 32,
        weight_decay: float = 1e-4,
        random_state: int | None = 42,
        class_weight: str | None = None,
    ) -> None:
        """Initialize `ClusterClassifier` for a specific biosynthetic `target`."""
        if target not in VALID_TARGETS:
            raise ValueError(f"Unsupported target {target!r}. Expected one of {VALID_TARGETS}.")
        super().__init__(
            vocab_size=vocab_size,
            embed_dim=embed_dim,
            num_filters=num_filters,
            kernel_size=kernel_size,
            learning_rate=learning_rate,
            epochs=epochs,
            batch_size=batch_size,
            weight_decay=weight_decay,
            random_state=random_state,
            class_weight=class_weight,
        )
        self.target = target

    @staticmethod
    def format_target_label(target: str, label: int) -> str:
        """Convert an integer class label for `target` into a human-readable biochemical string."""
        val = int(label)
        if val < 0:
            return "UNKNOWN"
        if target == "donor_linkage":
            res, link = decode_donor_linkage_label(val)
            return f"{res!r}({link.name.decode('ascii', errors='replace')})"
        if target == "donor":
            return repr(ResidueType(val))
        if target == "acceptor_sugar":
            return repr(ResidueType(val))
        if target == "acceptor_motif":
            res, anom, role_id = decode_acceptor_motif_label(val)
            anom_s = "a" if anom == Anomer.ALPHA else ("b" if anom == Anomer.BETA else "")
            role_s = "root" if role_id == 1 else ("bb" if role_id == 2 else ("br" if role_id == 3 else "?"))
            return f"{anom_s}{res!r}@{role_s}"
        if target == "linkage" or target == "wzy_linkage":
            link = LinkageType(val | int(LinkageType.TAG_EDGE.mask))
            return link.name.decode("ascii", errors="replace")
        if target == "is_backbone":
            return "BACKBONE" if val == 1 else "BRANCH"
        if target == "modifier_linkage":
            mod_s, acc_c = decode_modifier_linkage_label(val)
            s_str = (
                Sugars.names[mod_s].decode("ascii", errors="replace")
                if 0 <= mod_s < len(Sugars.names)
                else f"Mod#{mod_s}"
            )
            c_str = str(acc_c) if acc_c > 0 else "?"
            return f"{s_str}(1-{c_str})"
        if target == "mechanism":
            return "RETAINING" if val == 1 else ("INVERTING" if val == 2 else "UNKNOWN")
        if target == "anomer":
            return "alpha" if val == int(Anomer.ALPHA) else ("beta" if val == int(Anomer.BETA) else "?")
        if target == "initiator_sugar":
            if 0 <= val < len(Sugars.names):
                return Sugars.names[val].decode("ascii", errors="replace")
            return f"Sugar#{val}"
        if target == "component_int":
            if val & int(LinkageType.TAG_EDGE.mask):
                return LinkageType(val).name.decode("ascii", errors="replace")
            return repr(ResidueType(val))
        return str(val)

    def decode_label(self, label: int) -> str:
        """Return a human-readable biochemical label for an integer class value."""
        return self.format_target_label(self.target, int(label))

    @property
    def class_names(self) -> tuple[str, ...]:
        """Human-readable names corresponding to `self.classes_`."""
        if not self.is_fitted:
            return ()
        return tuple(self.decode_label(int(c)) for c in self.classes_)

    def predict_cluster_proba(
        self,
        X: Sequences | Sequence[Any] | tuple[np.ndarray, np.ndarray],
        cluster_ids: Sequence[int] | np.ndarray,
        *,
        pool: str = "log_proba",
        ignore_non_positive: bool = True,
    ) -> tuple[np.ndarray[tuple[int], np.dtype[np.int64]], np.ndarray[tuple[int, int], np.dtype[np.float32]]]:
        """Aggregate sequence class probabilities per homology `cluster_id` via geometric (`log_proba`) or mean pooling."""
        probs = self.predict_proba(X)
        c_arr = np.asarray(cluster_ids, dtype=np.int64)
        if len(c_arr) != len(probs):
            raise ValueError(f"cluster_ids length ({len(c_arr)}) does not match X length ({len(probs)}).")
        mask = (c_arr > 0) if ignore_non_positive else np.ones(len(c_arr), dtype=bool)
        if not np.any(mask):
            return np.empty(0, dtype=np.int64), np.empty((0, probs.shape[1]), dtype=np.float32)

        c_valid = c_arr[mask]
        p_valid = probs[mask]
        uniq_cids, inv = np.unique(c_valid, return_inverse=True)
        n_c = len(uniq_cids)
        n_cls = probs.shape[1]
        counts = np.bincount(inv, minlength=n_c).astype(np.float32)[:, None]

        if pool == "log_proba":
            log_p = np.log(np.clip(p_valid, 1e-8, 1.0)).astype(np.float32)
            agg_log = np.zeros((n_c, n_cls), dtype=np.float32)
            np.add.at(agg_log, inv, log_p)
            agg_log /= np.maximum(counts, 1.0)
            agg_log -= np.max(agg_log, axis=1, keepdims=True)
            exp_p = np.exp(agg_log)
            out_probs = (exp_p / np.maximum(np.sum(exp_p, axis=1, keepdims=True), 1e-12)).astype(np.float32)
        else:
            agg_p = np.zeros((n_c, n_cls), dtype=np.float32)
            np.add.at(agg_p, inv, p_valid)
            agg_p /= np.maximum(counts, 1.0)
            out_probs = (agg_p / np.maximum(np.sum(agg_p, axis=1, keepdims=True), 1e-12)).astype(np.float32)

        return uniq_cids.astype(np.int64), out_probs

    @staticmethod
    def extract_training_data(
        records: GlycanRecords,
        *,
        target: str = "donor_linkage",
        min_score: float = 0.72,
        include_graph_aligned: bool = True,
        min_class_count: int = 2,
    ) -> tuple[list[bytes], np.ndarray, np.ndarray, np.ndarray]:
        """Extract `(sequences, y_labels, sample_weights, cluster_ids)` from `GlycanRecords`.

        Args:
            records: Curated `GlycanRecords` database with `.loci`, `.glycans`, and `.assignments`.
            target: Target task in `VALID_TARGETS`.
            min_score: Minimum assignment confidence score for `GRAPH_ALIGNED` examples.
            include_graph_aligned: Whether to include high-confidence `GRAPH_ALIGNED` assignments
                alongside `PAN_GRAPH_CONSENSUS` assignments.
            min_class_count: Minimum number of samples required for a class to be retained.

        Returns:
            tuple[list[bytes], np.ndarray, np.ndarray, np.ndarray]:
                `(sequences, y_labels, sample_weights, cluster_ids)`.
        """
        if target not in VALID_TARGETS:
            raise ValueError(f"Unsupported target {target!r}. Expected one of {VALID_TARGETS}.")

        seqs: list[bytes] = []
        labels: list[int] = []
        weights: list[float] = []
        cids: list[int] = []

        for i in range(len(records)):
            loc = records.loci[i]
            if len(loc.nodes) == 0 or len(loc.genes) == 0:
                continue
            gly = records.glycans[i]
            assigns = records.assignments[i]
            translations = loc.genes.translations

            # Identify bifunctional GTs assigned to 2+ distinct glycosidic edges so we can split N/C-terminal domains
            gene_edge_orders: dict[int, list[int]] = {}
            _, assembly_depths, _ = gly.polymer_edge_and_assembly_depths(gly.reducing_end_index)
            for a_pre in assigns:
                if (
                    a_pre.is_aligned
                    and a_pre.gene_index >= 0
                    and a_pre.target_type == TargetType.LINKAGE
                    and a_pre.edge_index is not None
                    and 0 <= a_pre.edge_index < len(gly.edges)
                ):
                    gene_edge_orders.setdefault(int(a_pre.gene_index), []).append(int(a_pre.edge_index))
            for g_k, e_list in gene_edge_orders.items():
                if len(e_list) >= 2:
                    gene_edge_orders[g_k] = sorted(
                        dict.fromkeys(e_list),
                        key=lambda ek: (assembly_depths.get(gly.edges[ek].source, 99), ek),
                    )

            for a in assigns:
                if not a.is_aligned or a.gene_index < 0 or a.gene_index >= len(loc.nodes):
                    continue
                if a.gene_index >= len(translations):
                    continue
                if a.evidence == AssignmentEvidence.PAN_GRAPH_CONSENSUS:
                    w_base = 1.50 if float(a.score) >= 0.93 else 1.25
                elif include_graph_aligned and a.evidence == AssignmentEvidence.GRAPH_ALIGNED and a.score >= min_score:
                    w_base = 0.85
                else:
                    continue

                aa_seq = bytes(translations[a.gene_index].seq)
                if len(aa_seq) < 80:
                    continue

                g_node = loc.nodes[a.gene_index]
                cid = int(g_node.cluster_id)

                # Split multi-domain bifunctional GTs (>= 550 aa) assigned to 2+ reactions into N/C catalytic domains
                e_ord = gene_edge_orders.get(int(a.gene_index), [])
                if len(e_ord) >= 2 and a.edge_index is not None and int(a.edge_index) in e_ord:
                    doms = split_bifunctional_gt_domains(aa_seq, min_len=550, overlap=25)
                    if doms is not None:
                        pos_dom = e_ord.index(int(a.edge_index))
                        aa_seq = doms[0] if pos_dom == 0 else doms[1]
                        if pos_dom > 0 and cid > 0:
                            cid = cid + 500000

                if target == "initiator_sugar":
                    if a.role != GeneRole.INITIATOR or a.residue is None or a.residue.is_modifier:
                        continue
                    y_val = int(a.residue.sugar)
                elif target == "modifier_linkage":
                    if a.role not in MODIFIER_ROLES:
                        continue
                    mod_sugar_id = -1
                    acc_c = 0
                    if a.residue is not None and a.residue.is_modifier:
                        mod_sugar_id = int(a.residue.sugar)
                        if a.bipartite_glycan_index >= 0:
                            for e_m in gly.edges:
                                if e_m.source == a.bipartite_glycan_index and not gly.nodes[e_m.target].is_modifier:
                                    acc_c = int(e_m.edge_type.acceptor)
                                    break
                    elif (
                        a.target_type == TargetType.LINKAGE
                        and a.edge_index is not None
                        and 0 <= a.edge_index < len(gly.edges)
                    ):
                        e_m = gly.edges[a.edge_index]
                        if gly.nodes[e_m.source].is_modifier:
                            mod_sugar_id = int(gly.nodes[e_m.source].sugar)
                            acc_c = int(e_m.edge_type.acceptor)
                    if mod_sugar_id < 0:
                        continue
                    y_val = encode_modifier_linkage_label(mod_sugar_id, acc_c)
                elif target == "wzy_linkage":
                    if a.role != GeneRole.POLYMERASE or a.target_type != TargetType.LINKAGE or a.edge_index is None:
                        continue
                    if not (0 <= a.edge_index < len(gly.edges)):
                        continue
                    w_link = gly.edges[a.edge_index].edge_type
                    if w_link.anomer == Anomer.UNKNOWN or w_link.acceptor == 0:
                        continue
                    y_val = int(w_link.chemical_int)
                elif target == "component_int":
                    y_val = int(a.component_int)
                    if y_val <= 0:
                        continue
                else:
                    if a.role not in (
                        GeneRole.GLYCOSYLTRANSFERASE,
                        GeneRole.POLYMERASE,
                        GeneRole.TRANSMEMBRANE_SYNTHASE,
                    ):
                        continue
                    if target == "is_backbone" and a.role == GeneRole.POLYMERASE:
                        continue
                    if a.target_type != TargetType.LINKAGE or a.edge_index is None:
                        continue
                    if not (0 <= a.edge_index < len(gly.edges)):
                        continue
                    edge = gly.edges[a.edge_index]
                    s_res = gly.nodes[edge.source]
                    t_res = gly.nodes[edge.target]
                    if s_res.is_modifier or t_res.is_modifier:
                        continue
                    link = edge.edge_type

                    if target == "donor":
                        y_val = encode_donor_residue_chem(s_res)
                    elif target == "acceptor_sugar":
                        y_val = encode_donor_residue_chem(t_res)
                    elif target == "acceptor_motif":
                        y_val = extract_acceptor_motif_for_edge(gly, a.edge_index)
                    elif target == "is_backbone":
                        y_val = 1 if link.is_backbone else 0
                    elif target == "linkage":
                        if link.anomer == Anomer.UNKNOWN or link.acceptor == 0:
                            continue
                        y_val = int(link.chemical_int)
                    elif target == "donor_linkage":
                        if link.anomer == Anomer.UNKNOWN or link.acceptor == 0:
                            continue
                        y_val = encode_donor_linkage_label(s_res, link)
                    elif target == "mechanism":
                        _, _, _, mech = _edge_signatures(gly, a.edge_index)
                        if mech not in (1, 2):
                            continue
                        y_val = int(mech)
                    elif target == "anomer":
                        if link.anomer not in (Anomer.ALPHA, Anomer.BETA):
                            continue
                        y_val = int(link.anomer)
                    else:
                        continue

                seqs.append(aa_seq)
                labels.append(y_val)
                weights.append(float(w_base * float(a.score)))
                cids.append(cid)

        if not seqs:
            return [], np.empty(0, dtype=np.int64), np.empty(0, dtype=np.float32), np.empty(0, dtype=np.int64)

        y_arr = np.asarray(labels, dtype=np.int64)
        w_arr = np.asarray(weights, dtype=np.float32)
        c_arr = np.asarray(cids, dtype=np.int64)

        if min_class_count > 1:
            uniq, counts = np.unique(y_arr, return_counts=True)
            keep_classes = set(uniq[counts >= min_class_count].tolist())
            keep_mask = np.array([int(y_v) in keep_classes for y_v in y_arr], dtype=bool)
            seqs = [s for s, m in zip(seqs, keep_mask, strict=False) if m]
            y_arr = y_arr[keep_mask]
            w_arr = w_arr[keep_mask]
            c_arr = c_arr[keep_mask]

        return seqs, y_arr, w_arr, c_arr

    def fit_from_records(
        self,
        records: GlycanRecords,
        *,
        min_score: float = 0.72,
        include_graph_aligned: bool = True,
        min_class_count: int = 2,
        init_from: GeneRoleClassifier | None = None,
        backbone_lr_scale: float = 1.0,
    ) -> Self:
        """Extract training examples for `self.target` from `records` and fit the 1D-CNN."""
        seqs, y_arr, w_arr, _ = self.extract_training_data(
            records,
            target=self.target,
            min_score=min_score,
            include_graph_aligned=include_graph_aligned,
            min_class_count=min_class_count,
        )
        if len(seqs) == 0 or len(np.unique(y_arr)) < 2:
            raise ValueError(f"Insufficient training data for target={self.target!r} (n={len(seqs)}).")
        return self.fit(
            seqs,
            y_arr,
            sample_weight=w_arr,
            init_from=init_from,
            backbone_lr_scale=backbone_lr_scale,
        )

class_names property

class_names: tuple[str, ...]

Human-readable names corresponding to self.classes_.

format_target_label staticmethod

format_target_label(target: str, label: int) -> str

Convert an integer class label for target into a human-readable biochemical string.

Source code in src/kbank/biosynthesis/classifier.py
@staticmethod
def format_target_label(target: str, label: int) -> str:
    """Convert an integer class label for `target` into a human-readable biochemical string."""
    val = int(label)
    if val < 0:
        return "UNKNOWN"
    if target == "donor_linkage":
        res, link = decode_donor_linkage_label(val)
        return f"{res!r}({link.name.decode('ascii', errors='replace')})"
    if target == "donor":
        return repr(ResidueType(val))
    if target == "acceptor_sugar":
        return repr(ResidueType(val))
    if target == "acceptor_motif":
        res, anom, role_id = decode_acceptor_motif_label(val)
        anom_s = "a" if anom == Anomer.ALPHA else ("b" if anom == Anomer.BETA else "")
        role_s = "root" if role_id == 1 else ("bb" if role_id == 2 else ("br" if role_id == 3 else "?"))
        return f"{anom_s}{res!r}@{role_s}"
    if target == "linkage" or target == "wzy_linkage":
        link = LinkageType(val | int(LinkageType.TAG_EDGE.mask))
        return link.name.decode("ascii", errors="replace")
    if target == "is_backbone":
        return "BACKBONE" if val == 1 else "BRANCH"
    if target == "modifier_linkage":
        mod_s, acc_c = decode_modifier_linkage_label(val)
        s_str = (
            Sugars.names[mod_s].decode("ascii", errors="replace")
            if 0 <= mod_s < len(Sugars.names)
            else f"Mod#{mod_s}"
        )
        c_str = str(acc_c) if acc_c > 0 else "?"
        return f"{s_str}(1-{c_str})"
    if target == "mechanism":
        return "RETAINING" if val == 1 else ("INVERTING" if val == 2 else "UNKNOWN")
    if target == "anomer":
        return "alpha" if val == int(Anomer.ALPHA) else ("beta" if val == int(Anomer.BETA) else "?")
    if target == "initiator_sugar":
        if 0 <= val < len(Sugars.names):
            return Sugars.names[val].decode("ascii", errors="replace")
        return f"Sugar#{val}"
    if target == "component_int":
        if val & int(LinkageType.TAG_EDGE.mask):
            return LinkageType(val).name.decode("ascii", errors="replace")
        return repr(ResidueType(val))
    return str(val)

decode_label

decode_label(label: int) -> str

Return a human-readable biochemical label for an integer class value.

Source code in src/kbank/biosynthesis/classifier.py
def decode_label(self, label: int) -> str:
    """Return a human-readable biochemical label for an integer class value."""
    return self.format_target_label(self.target, int(label))

predict_cluster_proba

predict_cluster_proba(
    X: Sequences | Sequence[Any] | tuple[ndarray, ndarray],
    cluster_ids: Sequence[int] | ndarray,
    *,
    pool: str = "log_proba",
    ignore_non_positive: bool = True,
) -> tuple[
    ndarray[tuple[int], dtype[int64]],
    ndarray[tuple[int, int], dtype[float32]],
]

Aggregate sequence class probabilities per homology cluster_id via geometric (log_proba) or mean pooling.

Source code in src/kbank/biosynthesis/classifier.py
def predict_cluster_proba(
    self,
    X: Sequences | Sequence[Any] | tuple[np.ndarray, np.ndarray],
    cluster_ids: Sequence[int] | np.ndarray,
    *,
    pool: str = "log_proba",
    ignore_non_positive: bool = True,
) -> tuple[np.ndarray[tuple[int], np.dtype[np.int64]], np.ndarray[tuple[int, int], np.dtype[np.float32]]]:
    """Aggregate sequence class probabilities per homology `cluster_id` via geometric (`log_proba`) or mean pooling."""
    probs = self.predict_proba(X)
    c_arr = np.asarray(cluster_ids, dtype=np.int64)
    if len(c_arr) != len(probs):
        raise ValueError(f"cluster_ids length ({len(c_arr)}) does not match X length ({len(probs)}).")
    mask = (c_arr > 0) if ignore_non_positive else np.ones(len(c_arr), dtype=bool)
    if not np.any(mask):
        return np.empty(0, dtype=np.int64), np.empty((0, probs.shape[1]), dtype=np.float32)

    c_valid = c_arr[mask]
    p_valid = probs[mask]
    uniq_cids, inv = np.unique(c_valid, return_inverse=True)
    n_c = len(uniq_cids)
    n_cls = probs.shape[1]
    counts = np.bincount(inv, minlength=n_c).astype(np.float32)[:, None]

    if pool == "log_proba":
        log_p = np.log(np.clip(p_valid, 1e-8, 1.0)).astype(np.float32)
        agg_log = np.zeros((n_c, n_cls), dtype=np.float32)
        np.add.at(agg_log, inv, log_p)
        agg_log /= np.maximum(counts, 1.0)
        agg_log -= np.max(agg_log, axis=1, keepdims=True)
        exp_p = np.exp(agg_log)
        out_probs = (exp_p / np.maximum(np.sum(exp_p, axis=1, keepdims=True), 1e-12)).astype(np.float32)
    else:
        agg_p = np.zeros((n_c, n_cls), dtype=np.float32)
        np.add.at(agg_p, inv, p_valid)
        agg_p /= np.maximum(counts, 1.0)
        out_probs = (agg_p / np.maximum(np.sum(agg_p, axis=1, keepdims=True), 1e-12)).astype(np.float32)

    return uniq_cids.astype(np.int64), out_probs

extract_training_data staticmethod

extract_training_data(
    records: GlycanRecords,
    *,
    target: str = "donor_linkage",
    min_score: float = 0.72,
    include_graph_aligned: bool = True,
    min_class_count: int = 2,
) -> tuple[list[bytes], ndarray, ndarray, ndarray]

Extract (sequences, y_labels, sample_weights, cluster_ids) from GlycanRecords.

Parameters:

Name Type Description Default
records GlycanRecords

Curated GlycanRecords database with .loci, .glycans, and .assignments.

required
target str

Target task in VALID_TARGETS.

'donor_linkage'
min_score float

Minimum assignment confidence score for GRAPH_ALIGNED examples.

0.72
include_graph_aligned bool

Whether to include high-confidence GRAPH_ALIGNED assignments alongside PAN_GRAPH_CONSENSUS assignments.

True
min_class_count int

Minimum number of samples required for a class to be retained.

2

Returns:

Type Description
tuple[list[bytes], ndarray, ndarray, ndarray]

tuple[list[bytes], np.ndarray, np.ndarray, np.ndarray]: (sequences, y_labels, sample_weights, cluster_ids).

Source code in src/kbank/biosynthesis/classifier.py
@staticmethod
def extract_training_data(
    records: GlycanRecords,
    *,
    target: str = "donor_linkage",
    min_score: float = 0.72,
    include_graph_aligned: bool = True,
    min_class_count: int = 2,
) -> tuple[list[bytes], np.ndarray, np.ndarray, np.ndarray]:
    """Extract `(sequences, y_labels, sample_weights, cluster_ids)` from `GlycanRecords`.

    Args:
        records: Curated `GlycanRecords` database with `.loci`, `.glycans`, and `.assignments`.
        target: Target task in `VALID_TARGETS`.
        min_score: Minimum assignment confidence score for `GRAPH_ALIGNED` examples.
        include_graph_aligned: Whether to include high-confidence `GRAPH_ALIGNED` assignments
            alongside `PAN_GRAPH_CONSENSUS` assignments.
        min_class_count: Minimum number of samples required for a class to be retained.

    Returns:
        tuple[list[bytes], np.ndarray, np.ndarray, np.ndarray]:
            `(sequences, y_labels, sample_weights, cluster_ids)`.
    """
    if target not in VALID_TARGETS:
        raise ValueError(f"Unsupported target {target!r}. Expected one of {VALID_TARGETS}.")

    seqs: list[bytes] = []
    labels: list[int] = []
    weights: list[float] = []
    cids: list[int] = []

    for i in range(len(records)):
        loc = records.loci[i]
        if len(loc.nodes) == 0 or len(loc.genes) == 0:
            continue
        gly = records.glycans[i]
        assigns = records.assignments[i]
        translations = loc.genes.translations

        # Identify bifunctional GTs assigned to 2+ distinct glycosidic edges so we can split N/C-terminal domains
        gene_edge_orders: dict[int, list[int]] = {}
        _, assembly_depths, _ = gly.polymer_edge_and_assembly_depths(gly.reducing_end_index)
        for a_pre in assigns:
            if (
                a_pre.is_aligned
                and a_pre.gene_index >= 0
                and a_pre.target_type == TargetType.LINKAGE
                and a_pre.edge_index is not None
                and 0 <= a_pre.edge_index < len(gly.edges)
            ):
                gene_edge_orders.setdefault(int(a_pre.gene_index), []).append(int(a_pre.edge_index))
        for g_k, e_list in gene_edge_orders.items():
            if len(e_list) >= 2:
                gene_edge_orders[g_k] = sorted(
                    dict.fromkeys(e_list),
                    key=lambda ek: (assembly_depths.get(gly.edges[ek].source, 99), ek),
                )

        for a in assigns:
            if not a.is_aligned or a.gene_index < 0 or a.gene_index >= len(loc.nodes):
                continue
            if a.gene_index >= len(translations):
                continue
            if a.evidence == AssignmentEvidence.PAN_GRAPH_CONSENSUS:
                w_base = 1.50 if float(a.score) >= 0.93 else 1.25
            elif include_graph_aligned and a.evidence == AssignmentEvidence.GRAPH_ALIGNED and a.score >= min_score:
                w_base = 0.85
            else:
                continue

            aa_seq = bytes(translations[a.gene_index].seq)
            if len(aa_seq) < 80:
                continue

            g_node = loc.nodes[a.gene_index]
            cid = int(g_node.cluster_id)

            # Split multi-domain bifunctional GTs (>= 550 aa) assigned to 2+ reactions into N/C catalytic domains
            e_ord = gene_edge_orders.get(int(a.gene_index), [])
            if len(e_ord) >= 2 and a.edge_index is not None and int(a.edge_index) in e_ord:
                doms = split_bifunctional_gt_domains(aa_seq, min_len=550, overlap=25)
                if doms is not None:
                    pos_dom = e_ord.index(int(a.edge_index))
                    aa_seq = doms[0] if pos_dom == 0 else doms[1]
                    if pos_dom > 0 and cid > 0:
                        cid = cid + 500000

            if target == "initiator_sugar":
                if a.role != GeneRole.INITIATOR or a.residue is None or a.residue.is_modifier:
                    continue
                y_val = int(a.residue.sugar)
            elif target == "modifier_linkage":
                if a.role not in MODIFIER_ROLES:
                    continue
                mod_sugar_id = -1
                acc_c = 0
                if a.residue is not None and a.residue.is_modifier:
                    mod_sugar_id = int(a.residue.sugar)
                    if a.bipartite_glycan_index >= 0:
                        for e_m in gly.edges:
                            if e_m.source == a.bipartite_glycan_index and not gly.nodes[e_m.target].is_modifier:
                                acc_c = int(e_m.edge_type.acceptor)
                                break
                elif (
                    a.target_type == TargetType.LINKAGE
                    and a.edge_index is not None
                    and 0 <= a.edge_index < len(gly.edges)
                ):
                    e_m = gly.edges[a.edge_index]
                    if gly.nodes[e_m.source].is_modifier:
                        mod_sugar_id = int(gly.nodes[e_m.source].sugar)
                        acc_c = int(e_m.edge_type.acceptor)
                if mod_sugar_id < 0:
                    continue
                y_val = encode_modifier_linkage_label(mod_sugar_id, acc_c)
            elif target == "wzy_linkage":
                if a.role != GeneRole.POLYMERASE or a.target_type != TargetType.LINKAGE or a.edge_index is None:
                    continue
                if not (0 <= a.edge_index < len(gly.edges)):
                    continue
                w_link = gly.edges[a.edge_index].edge_type
                if w_link.anomer == Anomer.UNKNOWN or w_link.acceptor == 0:
                    continue
                y_val = int(w_link.chemical_int)
            elif target == "component_int":
                y_val = int(a.component_int)
                if y_val <= 0:
                    continue
            else:
                if a.role not in (
                    GeneRole.GLYCOSYLTRANSFERASE,
                    GeneRole.POLYMERASE,
                    GeneRole.TRANSMEMBRANE_SYNTHASE,
                ):
                    continue
                if target == "is_backbone" and a.role == GeneRole.POLYMERASE:
                    continue
                if a.target_type != TargetType.LINKAGE or a.edge_index is None:
                    continue
                if not (0 <= a.edge_index < len(gly.edges)):
                    continue
                edge = gly.edges[a.edge_index]
                s_res = gly.nodes[edge.source]
                t_res = gly.nodes[edge.target]
                if s_res.is_modifier or t_res.is_modifier:
                    continue
                link = edge.edge_type

                if target == "donor":
                    y_val = encode_donor_residue_chem(s_res)
                elif target == "acceptor_sugar":
                    y_val = encode_donor_residue_chem(t_res)
                elif target == "acceptor_motif":
                    y_val = extract_acceptor_motif_for_edge(gly, a.edge_index)
                elif target == "is_backbone":
                    y_val = 1 if link.is_backbone else 0
                elif target == "linkage":
                    if link.anomer == Anomer.UNKNOWN or link.acceptor == 0:
                        continue
                    y_val = int(link.chemical_int)
                elif target == "donor_linkage":
                    if link.anomer == Anomer.UNKNOWN or link.acceptor == 0:
                        continue
                    y_val = encode_donor_linkage_label(s_res, link)
                elif target == "mechanism":
                    _, _, _, mech = _edge_signatures(gly, a.edge_index)
                    if mech not in (1, 2):
                        continue
                    y_val = int(mech)
                elif target == "anomer":
                    if link.anomer not in (Anomer.ALPHA, Anomer.BETA):
                        continue
                    y_val = int(link.anomer)
                else:
                    continue

            seqs.append(aa_seq)
            labels.append(y_val)
            weights.append(float(w_base * float(a.score)))
            cids.append(cid)

    if not seqs:
        return [], np.empty(0, dtype=np.int64), np.empty(0, dtype=np.float32), np.empty(0, dtype=np.int64)

    y_arr = np.asarray(labels, dtype=np.int64)
    w_arr = np.asarray(weights, dtype=np.float32)
    c_arr = np.asarray(cids, dtype=np.int64)

    if min_class_count > 1:
        uniq, counts = np.unique(y_arr, return_counts=True)
        keep_classes = set(uniq[counts >= min_class_count].tolist())
        keep_mask = np.array([int(y_v) in keep_classes for y_v in y_arr], dtype=bool)
        seqs = [s for s, m in zip(seqs, keep_mask, strict=False) if m]
        y_arr = y_arr[keep_mask]
        w_arr = w_arr[keep_mask]
        c_arr = c_arr[keep_mask]

    return seqs, y_arr, w_arr, c_arr

fit_from_records

fit_from_records(
    records: GlycanRecords,
    *,
    min_score: float = 0.72,
    include_graph_aligned: bool = True,
    min_class_count: int = 2,
    init_from: GeneRoleClassifier | None = None,
    backbone_lr_scale: float = 1.0,
) -> Self

Extract training examples for self.target from records and fit the 1D-CNN.

Source code in src/kbank/biosynthesis/classifier.py
def fit_from_records(
    self,
    records: GlycanRecords,
    *,
    min_score: float = 0.72,
    include_graph_aligned: bool = True,
    min_class_count: int = 2,
    init_from: GeneRoleClassifier | None = None,
    backbone_lr_scale: float = 1.0,
) -> Self:
    """Extract training examples for `self.target` from `records` and fit the 1D-CNN."""
    seqs, y_arr, w_arr, _ = self.extract_training_data(
        records,
        target=self.target,
        min_score=min_score,
        include_graph_aligned=include_graph_aligned,
        min_class_count=min_class_count,
    )
    if len(seqs) == 0 or len(np.unique(y_arr)) < 2:
        raise ValueError(f"Insufficient training data for target={self.target!r} (n={len(seqs)}).")
    return self.fit(
        seqs,
        y_arr,
        sample_weight=w_arr,
        init_from=init_from,
        backbone_lr_scale=backbone_lr_scale,
    )

BiosyntheticClassifierSuite

Factorized suite of 10 ClusterClassifier models for hybrid pan-graph / sequence inference.

Combines 10 specialized 1D-CNN heads across SUITE_TARGETS (donor_linkage, donor, linkage, acceptor_sugar, acceptor_motif, is_backbone, mechanism, initiator_sugar, wzy_linkage, and modifier_linkage).

Methods:

Name Description
fit_from_records

Train all factorized ClusterClassifier heads on records.

save

Save all fitted ClusterClassifier heads into a single compressed .npz archive (zero pickle).

load

Load a BiosyntheticClassifierSuite from a .npz archive (zero pickle).

pretrained

Load the packaged src/kbank/data/cluster_classifiers.npz suite if available, else None.

precompute_for_loci

Precompute cluster-pooled (cluster_id > 0) and single-ORF (cluster_id == 0) probabilities across loci.

preferred_init_sugar

Return (best_sugar_idx, probability) from initiator_sugar classifier among candidate_sugars.

gt_edge_score

Compute factorized 1D-CNN cost bonus in [-0.25, +0.06] and consensus flag for (cluster_id, edge_idx).

Attributes:

Name Type Description
is_fitted bool

True if at least the core GT classifiers (donor, linkage, mechanism) are fitted.

Source code in src/kbank/biosynthesis/classifier.py
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
class BiosyntheticClassifierSuite:
    """Factorized suite of 10 `ClusterClassifier` models for hybrid pan-graph / sequence inference.

    Combines 10 specialized 1D-CNN heads across `SUITE_TARGETS` (`donor_linkage`, `donor`,
    `linkage`, `acceptor_sugar`, `acceptor_motif`, `is_backbone`, `mechanism`,
    `initiator_sugar`, `wzy_linkage`, and `modifier_linkage`).
    """

    SUITE_TARGETS: tuple[str, ...] = (
        "donor_linkage",
        "donor",
        "linkage",
        "acceptor_sugar",
        "acceptor_motif",
        "is_backbone",
        "mechanism",
        "initiator_sugar",
        "wzy_linkage",
        "modifier_linkage",
    )

    def __init__(
        self,
        classifiers: dict[str, ClusterClassifier] | None = None,
    ) -> None:
        """Initialize `BiosyntheticClassifierSuite` with a dictionary of fitted `ClusterClassifier`s."""
        self.classifiers: dict[str, ClusterClassifier] = dict(classifiers) if classifiers else {}
        self._cluster_probs: dict[str, dict[int, dict[int, float]]] = {}
        self._gene_probs: dict[str, dict[tuple[int, int], dict[int, float]]] = {}
        self._seq_probs: dict[str, dict[bytes, dict[int, float]]] = {}

    @property
    def is_fitted(self) -> bool:
        """True if at least the core GT classifiers (`donor`, `linkage`, `mechanism`) are fitted."""
        return all(t in self.classifiers and self.classifiers[t].is_fitted for t in ("donor", "linkage", "mechanism"))

    def __getitem__(self, target: str) -> ClusterClassifier:
        """Return the `ClusterClassifier` for `target`."""
        return self.classifiers[target]

    @classmethod
    def fit_from_records(
        cls,
        records: GlycanRecords,
        *,
        embed_dim: int = 16,
        num_filters: int = 64,
        kernel_size: int | tuple[int, ...] = (5, 9, 13),
        learning_rate: float = 0.008,
        epochs: int = 25,
        batch_size: int = 32,
        random_state: int = 42,
        min_score: float = 0.72,
        warm_start_backbone: bool = True,
    ) -> Self:
        """Train all factorized `ClusterClassifier` heads on `records`."""
        base_clf: GeneRoleClassifier | None = None
        if warm_start_backbone:
            try:
                pretrained = GeneRoleClassifier.pretrained()
                if pretrained.embed_dim == embed_dim and pretrained.num_filters == num_filters:
                    base_clf = pretrained
            except Exception:
                base_clf = None

        suite: dict[str, ClusterClassifier] = {}
        for idx_t, target in enumerate(cls.SUITE_TARGETS):
            clf = ClusterClassifier(
                target=target,
                embed_dim=embed_dim,
                num_filters=num_filters,
                kernel_size=kernel_size,
                learning_rate=learning_rate,
                epochs=epochs,
                batch_size=batch_size,
                random_state=random_state + idx_t,
                class_weight=None,
            )
            min_cnt = (
                4
                if target in ("donor_linkage", "donor", "acceptor_sugar", "acceptor_motif")
                else (3 if target in ("linkage", "wzy_linkage") else 2)
            )
            try:
                clf.fit_from_records(
                    records,
                    min_score=min_score,
                    include_graph_aligned=True,
                    min_class_count=min_cnt,
                    init_from=base_clf,
                    backbone_lr_scale=0.5 if base_clf is not None else 1.0,
                )
                suite[target] = clf
            except ValueError:
                continue
        return cls(classifiers=suite)

    def save(self, path: str | Path) -> None:
        """Save all fitted `ClusterClassifier` heads into a single compressed `.npz` archive (zero pickle)."""
        out_path = Path(path)
        out_path.parent.mkdir(parents=True, exist_ok=True)
        arrays: dict[str, Any] = {}
        present_targets: list[str] = []
        for target, clf in self.classifiers.items():
            if not clf.is_fitted:
                continue
            present_targets.append(target)
            prefix = f"{target}__"
            arrays[f"{prefix}classes"] = clf.classes_
            arrays[f"{prefix}embedding"] = clf.embedding_
            arrays[f"{prefix}conv_w"] = clf.conv_w_
            arrays[f"{prefix}conv_b"] = clf.conv_b_
            arrays[f"{prefix}dense_w"] = clf.dense_w_
            arrays[f"{prefix}dense_b"] = clf.dense_b_
            arrays[f"{prefix}k_sizes"] = clf.k_sizes_
            arrays[f"{prefix}loss_history"] = np.asarray(getattr(clf, "loss_history_", []), dtype=np.float32)
            arrays[f"{prefix}hyperparams"] = np.array([clf.vocab_size, clf.embed_dim, clf.num_filters], dtype=np.int32)
        arrays["targets"] = np.array([t.encode("ascii") for t in present_targets], dtype="S32")
        import os

        tmp_path = out_path.with_name(f".{out_path.name}.{os.getpid()}.tmp.npz")
        np.savez_compressed(tmp_path, **arrays)
        os.replace(tmp_path, out_path)

    @classmethod
    def load(cls, path: str | Path | None = None) -> Self:
        """Load a `BiosyntheticClassifierSuite` from a `.npz` archive (zero pickle)."""
        from serogen.genes.cnn import _compute_lut

        resolved_path = (
            Path(path)
            if path is not None
            else (Path(__file__).resolve().parent.parent / "data" / "cluster_classifiers.npz")
        )
        with np.load(resolved_path, allow_pickle=False) as data:
            target_bytes = data["targets"]
            targets = [bytes(t).decode("ascii") for t in target_bytes]
            suite: dict[str, ClusterClassifier] = {}
            for target in targets:
                prefix = f"{target}__"
                hp = data[f"{prefix}hyperparams"]
                k_sizes = data[f"{prefix}k_sizes"].astype(np.int32)
                unique_ks = np.unique(k_sizes).astype(np.int32)
                clf = ClusterClassifier(
                    target=target,
                    vocab_size=int(hp[0]),
                    embed_dim=int(hp[1]),
                    num_filters=int(hp[2]),
                    kernel_size=tuple(int(k) for k in unique_ks),
                )
                clf.classes_ = data[f"{prefix}classes"]
                clf.embedding_ = data[f"{prefix}embedding"].astype(np.float32)
                clf.conv_w_ = data[f"{prefix}conv_w"].astype(np.float32)
                clf.conv_b_ = data[f"{prefix}conv_b"].astype(np.float32)
                clf.dense_w_ = data[f"{prefix}dense_w"].astype(np.float32)
                clf.dense_b_ = data[f"{prefix}dense_b"].astype(np.float32)
                clf.k_sizes_ = k_sizes
                clf.unique_ks_ = unique_ks
                clf.loss_history_ = (
                    data[f"{prefix}loss_history"].astype(np.float32).tolist() if f"{prefix}loss_history" in data else []
                )
                clf.lut_ = _compute_lut(clf.embedding_, clf.conv_w_, clf.k_sizes_)
                suite[target] = clf
        return cls(classifiers=suite)

    @classmethod
    @cache
    def pretrained(cls) -> Self | None:
        """Load the packaged `src/kbank/data/cluster_classifiers.npz` suite if available, else `None`."""
        try:
            res = files("kbank.data").joinpath("cluster_classifiers.npz")
            with as_file(res) as p:
                if p.exists():
                    return cls.load(p)
        except Exception:
            pass
        fallback_path = Path(__file__).resolve().parent.parent / "data" / "cluster_classifiers.npz"
        if fallback_path.exists():
            return cls.load(fallback_path)
        return None

    def precompute_for_loci(self, loci: Loci | Sequence[Locus]) -> None:
        """Precompute cluster-pooled (`cluster_id > 0`) and single-ORF (`cluster_id == 0`) probabilities across `loci`."""
        if not self.is_fitted:
            return

        gt_seqs: list[bytes] = []
        gt_cids: list[int] = []
        gt_keys: list[tuple[int, int]] = []

        wzy_seqs: list[bytes] = []
        wzy_cids: list[int] = []
        wzy_keys: list[tuple[int, int]] = []

        init_seqs: list[bytes] = []
        init_cids: list[int] = []
        init_keys: list[tuple[int, int]] = []

        mod_seqs: list[bytes] = []
        mod_cids: list[int] = []
        mod_keys: list[tuple[int, int]] = []

        n_loci = len(loci)
        for loc_i in range(n_loci):
            loc = loci[loc_i]
            if len(loc.nodes) == 0 or len(loc.genes) == 0:
                continue
            translations = loc.genes.translations
            n_tr = len(translations)
            for g_i, g_node in enumerate(loc.nodes):
                if g_i >= n_tr:
                    continue
                aa_seq = bytes(translations[g_i].seq)
                if len(aa_seq) < 60:
                    continue
                cid = int(g_node.cluster_id)
                if g_node.role in (
                    GeneRole.GLYCOSYLTRANSFERASE,
                    GeneRole.POLYMERASE,
                    GeneRole.TRANSMEMBRANE_SYNTHASE,
                ):
                    gt_seqs.append(aa_seq)
                    gt_cids.append(cid)
                    gt_keys.append((loc_i, g_i))
                    if len(aa_seq) >= 550:
                        doms = split_bifunctional_gt_domains(aa_seq, min_len=550, overlap=25)
                        if doms is not None:
                            gt_seqs.append(doms[0])
                            gt_cids.append((cid + 400000) if cid > 0 else 0)
                            gt_keys.append((loc_i, -(g_i + 1) * 2))
                            gt_seqs.append(doms[1])
                            gt_cids.append((cid + 500000) if cid > 0 else 0)
                            gt_keys.append((loc_i, -(g_i + 1) * 2 - 1))
                    if g_node.role == GeneRole.POLYMERASE:
                        wzy_seqs.append(aa_seq)
                        wzy_cids.append(cid)
                        wzy_keys.append((loc_i, g_i))
                elif g_node.role == GeneRole.INITIATOR:
                    init_seqs.append(aa_seq)
                    init_cids.append(cid)
                    init_keys.append((loc_i, g_i))
                elif g_node.role in MODIFIER_ROLES:
                    mod_seqs.append(aa_seq)
                    mod_cids.append(cid)
                    mod_keys.append((loc_i, g_i))

        self._cluster_probs.clear()
        self._gene_probs.clear()
        self._seq_probs.clear()

        def _populate_group(
            seqs_list: list[bytes],
            cids_list: list[int],
            keys_list: list[tuple[int, int]],
            targets: tuple[str, ...],
        ) -> None:
            if not seqs_list:
                return
            s_batch = Sequences.from_bytes(seqs_list)
            c_arr = np.asarray(cids_list, dtype=np.int64)
            for target in targets:
                clf = self.classifiers.get(target)
                if clf is None or not clf.is_fitted:
                    continue
                classes = [int(c) for c in clf.classes_]
                raw_probs = clf.predict_proba(s_batch)
                gene_map: dict[tuple[int, int], dict[int, float]] = {}
                seq_map = self._seq_probs.setdefault(target, {})
                for idx_s, key in enumerate(keys_list):
                    row = raw_probs[idx_s]
                    dist_s = {classes[c_i]: float(row[c_i]) for c_i in range(len(classes)) if row[c_i] >= 0.01}
                    gene_map[key] = dist_s
                    seq_map[seqs_list[idx_s]] = dist_s
                self._gene_probs[target] = gene_map

                uniq_cids, c_probs = clf.predict_cluster_proba(
                    s_batch,
                    c_arr,
                    pool="log_proba",
                    ignore_non_positive=True,
                )
                c_map: dict[int, dict[int, float]] = {}
                for idx_c, cid_val in enumerate(uniq_cids):
                    row = c_probs[idx_c]
                    c_map[int(cid_val)] = {
                        classes[c_i]: float(row[c_i]) for c_i in range(len(classes)) if row[c_i] >= 0.01
                    }
                self._cluster_probs[target] = c_map

        _populate_group(
            gt_seqs,
            gt_cids,
            gt_keys,
            ("donor_linkage", "donor", "linkage", "acceptor_sugar", "acceptor_motif", "is_backbone", "mechanism"),
        )
        _populate_group(wzy_seqs, wzy_cids, wzy_keys, ("wzy_linkage",))
        _populate_group(init_seqs, init_cids, init_keys, ("initiator_sugar",))
        _populate_group(mod_seqs, mod_cids, mod_keys, ("modifier_linkage",))

    def _get_target_dist(
        self,
        target: str,
        cluster_id: int,
        aa_seq: bytes | None = None,
    ) -> dict[int, float]:
        """Retrieve precomputed cluster distribution or cached single-sequence distribution from `aa_seq`."""
        if cluster_id > 0:
            c_dist = self._cluster_probs.get(target, {}).get(int(cluster_id))
            if c_dist is not None:
                return c_dist
        if aa_seq is not None and len(aa_seq) >= 60:
            s_map = self._seq_probs.setdefault(target, {})
            cached_s = s_map.get(aa_seq)
            if cached_s is not None:
                return cached_s
            clf = self.classifiers.get(target)
            if clf is not None and clf.is_fitted:
                probs = clf.predict_proba([aa_seq])[0]
                dist_val = {int(clf.classes_[i]): float(probs[i]) for i in range(len(clf.classes_)) if probs[i] >= 0.01}
                s_map[aa_seq] = dist_val
                return dist_val
        return {}

    def preferred_init_sugar(
        self,
        cluster_id: int,
        candidate_sugars: set[int],
        aa_seq: bytes | None = None,
    ) -> tuple[int | None, float]:
        """Return `(best_sugar_idx, probability)` from `initiator_sugar` classifier among `candidate_sugars`."""
        dist = self._get_target_dist("initiator_sugar", cluster_id, aa_seq=aa_seq)
        if not dist:
            return None, 0.0
        best_s: int | None = None
        best_p = 0.0
        for s_idx in candidate_sugars:
            p = dist.get(int(s_idx), 0.0)
            if p > best_p:
                best_p = p
                best_s = int(s_idx)
        return (best_s, best_p) if best_p >= 0.35 else (None, 0.0)

    def gt_edge_score(
        self,
        cluster_id: int,
        glycan: Glycan,
        edge_idx: int,
        aa_seq: bytes | None = None,
    ) -> tuple[float, bool]:
        """Compute factorized 1D-CNN cost bonus in `[-0.25, +0.06]` and consensus flag for `(cluster_id, edge_idx)`."""
        if not (0 <= edge_idx < len(glycan.edges)):
            return 0.0, False
        edge = glycan.edges[edge_idx]
        s_res = glycan.nodes[edge.source]
        t_res = glycan.nodes[edge.target]
        if s_res.is_modifier or t_res.is_modifier:
            return 0.0, False

        link = edge.edge_type
        donor_code = encode_donor_residue_chem(s_res)
        acc_code = encode_donor_residue_chem(t_res)
        acc_motif_code = extract_acceptor_motif_for_edge(glycan, edge_idx)
        link_code = int(link.chemical_int)
        dl_code = encode_donor_linkage_label(s_res, link)
        bb_code = 1 if link.is_backbone else 0
        _, _, _, mech = _edge_signatures(glycan, edge_idx)

        def _score_single_seq(seq_cand: bytes | None, cid_cand: int) -> tuple[float, bool]:
            dist_dl = self._get_target_dist("donor_linkage", cid_cand, aa_seq=seq_cand)
            dist_d = self._get_target_dist("donor", cid_cand, aa_seq=seq_cand)
            dist_l = self._get_target_dist("linkage", cid_cand, aa_seq=seq_cand)
            dist_acc = self._get_target_dist("acceptor_sugar", cid_cand, aa_seq=seq_cand)
            dist_am = self._get_target_dist("acceptor_motif", cid_cand, aa_seq=seq_cand)
            dist_bb = self._get_target_dist("is_backbone", cid_cand, aa_seq=seq_cand)
            dist_m = self._get_target_dist("mechanism", cid_cand, aa_seq=seq_cand)

            if not dist_d and not dist_l and not dist_dl:
                return 0.0, False

            p_dl = dist_dl.get(dl_code, 0.0)
            p_d = dist_d.get(donor_code, 0.0)
            p_l = dist_l.get(link_code, 0.0)
            p_acc = dist_acc.get(acc_code, 0.0)
            p_am = dist_am.get(acc_motif_code, 0.0)
            p_bb = dist_bb.get(bb_code, 0.5)
            p_m = dist_m.get(int(mech), 0.0) if mech in (1, 2) else 0.5

            bonus_val = -(
                0.10 * p_dl
                + 0.05 * p_d
                + 0.05 * p_l
                + 0.025 * p_acc
                + 0.025 * p_am
                + 0.02 * max(0.0, p_bb - 0.5)
                + 0.03 * max(0.0, p_m - 0.5)
            )
            if dist_m and mech in (1, 2) and p_m < 0.25:
                bonus_val += 0.04 * (1.0 - p_m)

            strong_flag = False
            if p_dl >= 0.42 or (p_d >= 0.50 and p_l >= 0.45 and p_m >= 0.65):
                competing = 0
                for k_o, e_o in enumerate(glycan.edges):
                    if k_o == edge_idx:
                        continue
                    so = glycan.nodes[e_o.source]
                    to = glycan.nodes[e_o.target]
                    if so.is_modifier or to.is_modifier:
                        continue
                    dl_o = encode_donor_linkage_label(so, e_o.edge_type)
                    am_o = extract_acceptor_motif_for_edge(glycan, k_o)
                    if dl_o == dl_code and am_o == acc_motif_code:
                        continue
                    p_dl_o = dist_dl.get(dl_o, 0.0)
                    p_d_o = dist_d.get(encode_donor_residue_chem(so), 0.0)
                    p_l_o = dist_l.get(int(e_o.edge_type.chemical_int), 0.0)
                    p_am_o = dist_am.get(am_o, 0.0)
                    if (
                        (p_dl_o >= 0.75 * max(p_dl, 1e-6))
                        and (p_d_o * p_l_o >= 0.75 * max(p_d * p_l, 1e-6))
                        and (p_am <= 0.15 or p_am_o >= 0.80 * p_am)
                    ):
                        competing += 1
                if competing == 0:
                    strong_flag = True
            return float(np.clip(bonus_val, -0.25, 0.06)), strong_flag

        best_bonus, has_strong_clf = _score_single_seq(aa_seq, cluster_id)
        if aa_seq is not None and len(aa_seq) >= 550:
            doms = split_bifunctional_gt_domains(aa_seq, min_len=550, overlap=25)
            if doms is not None:
                for d_idx, d_seq in enumerate(doms):
                    d_cid = (cluster_id + (400000 if d_idx == 0 else 500000)) if cluster_id > 0 else 0
                    b_d, s_d = _score_single_seq(d_seq, d_cid)
                    if b_d < best_bonus:
                        best_bonus = b_d
                    if s_d:
                        has_strong_clf = True

        return best_bonus, has_strong_clf

is_fitted property

is_fitted: bool

True if at least the core GT classifiers (donor, linkage, mechanism) are fitted.

fit_from_records classmethod

fit_from_records(
    records: GlycanRecords,
    *,
    embed_dim: int = 16,
    num_filters: int = 64,
    kernel_size: int | tuple[int, ...] = (5, 9, 13),
    learning_rate: float = 0.008,
    epochs: int = 25,
    batch_size: int = 32,
    random_state: int = 42,
    min_score: float = 0.72,
    warm_start_backbone: bool = True,
) -> Self

Train all factorized ClusterClassifier heads on records.

Source code in src/kbank/biosynthesis/classifier.py
@classmethod
def fit_from_records(
    cls,
    records: GlycanRecords,
    *,
    embed_dim: int = 16,
    num_filters: int = 64,
    kernel_size: int | tuple[int, ...] = (5, 9, 13),
    learning_rate: float = 0.008,
    epochs: int = 25,
    batch_size: int = 32,
    random_state: int = 42,
    min_score: float = 0.72,
    warm_start_backbone: bool = True,
) -> Self:
    """Train all factorized `ClusterClassifier` heads on `records`."""
    base_clf: GeneRoleClassifier | None = None
    if warm_start_backbone:
        try:
            pretrained = GeneRoleClassifier.pretrained()
            if pretrained.embed_dim == embed_dim and pretrained.num_filters == num_filters:
                base_clf = pretrained
        except Exception:
            base_clf = None

    suite: dict[str, ClusterClassifier] = {}
    for idx_t, target in enumerate(cls.SUITE_TARGETS):
        clf = ClusterClassifier(
            target=target,
            embed_dim=embed_dim,
            num_filters=num_filters,
            kernel_size=kernel_size,
            learning_rate=learning_rate,
            epochs=epochs,
            batch_size=batch_size,
            random_state=random_state + idx_t,
            class_weight=None,
        )
        min_cnt = (
            4
            if target in ("donor_linkage", "donor", "acceptor_sugar", "acceptor_motif")
            else (3 if target in ("linkage", "wzy_linkage") else 2)
        )
        try:
            clf.fit_from_records(
                records,
                min_score=min_score,
                include_graph_aligned=True,
                min_class_count=min_cnt,
                init_from=base_clf,
                backbone_lr_scale=0.5 if base_clf is not None else 1.0,
            )
            suite[target] = clf
        except ValueError:
            continue
    return cls(classifiers=suite)

save

save(path: str | Path) -> None

Save all fitted ClusterClassifier heads into a single compressed .npz archive (zero pickle).

Source code in src/kbank/biosynthesis/classifier.py
def save(self, path: str | Path) -> None:
    """Save all fitted `ClusterClassifier` heads into a single compressed `.npz` archive (zero pickle)."""
    out_path = Path(path)
    out_path.parent.mkdir(parents=True, exist_ok=True)
    arrays: dict[str, Any] = {}
    present_targets: list[str] = []
    for target, clf in self.classifiers.items():
        if not clf.is_fitted:
            continue
        present_targets.append(target)
        prefix = f"{target}__"
        arrays[f"{prefix}classes"] = clf.classes_
        arrays[f"{prefix}embedding"] = clf.embedding_
        arrays[f"{prefix}conv_w"] = clf.conv_w_
        arrays[f"{prefix}conv_b"] = clf.conv_b_
        arrays[f"{prefix}dense_w"] = clf.dense_w_
        arrays[f"{prefix}dense_b"] = clf.dense_b_
        arrays[f"{prefix}k_sizes"] = clf.k_sizes_
        arrays[f"{prefix}loss_history"] = np.asarray(getattr(clf, "loss_history_", []), dtype=np.float32)
        arrays[f"{prefix}hyperparams"] = np.array([clf.vocab_size, clf.embed_dim, clf.num_filters], dtype=np.int32)
    arrays["targets"] = np.array([t.encode("ascii") for t in present_targets], dtype="S32")
    import os

    tmp_path = out_path.with_name(f".{out_path.name}.{os.getpid()}.tmp.npz")
    np.savez_compressed(tmp_path, **arrays)
    os.replace(tmp_path, out_path)

load classmethod

load(path: str | Path | None = None) -> Self

Load a BiosyntheticClassifierSuite from a .npz archive (zero pickle).

Source code in src/kbank/biosynthesis/classifier.py
@classmethod
def load(cls, path: str | Path | None = None) -> Self:
    """Load a `BiosyntheticClassifierSuite` from a `.npz` archive (zero pickle)."""
    from serogen.genes.cnn import _compute_lut

    resolved_path = (
        Path(path)
        if path is not None
        else (Path(__file__).resolve().parent.parent / "data" / "cluster_classifiers.npz")
    )
    with np.load(resolved_path, allow_pickle=False) as data:
        target_bytes = data["targets"]
        targets = [bytes(t).decode("ascii") for t in target_bytes]
        suite: dict[str, ClusterClassifier] = {}
        for target in targets:
            prefix = f"{target}__"
            hp = data[f"{prefix}hyperparams"]
            k_sizes = data[f"{prefix}k_sizes"].astype(np.int32)
            unique_ks = np.unique(k_sizes).astype(np.int32)
            clf = ClusterClassifier(
                target=target,
                vocab_size=int(hp[0]),
                embed_dim=int(hp[1]),
                num_filters=int(hp[2]),
                kernel_size=tuple(int(k) for k in unique_ks),
            )
            clf.classes_ = data[f"{prefix}classes"]
            clf.embedding_ = data[f"{prefix}embedding"].astype(np.float32)
            clf.conv_w_ = data[f"{prefix}conv_w"].astype(np.float32)
            clf.conv_b_ = data[f"{prefix}conv_b"].astype(np.float32)
            clf.dense_w_ = data[f"{prefix}dense_w"].astype(np.float32)
            clf.dense_b_ = data[f"{prefix}dense_b"].astype(np.float32)
            clf.k_sizes_ = k_sizes
            clf.unique_ks_ = unique_ks
            clf.loss_history_ = (
                data[f"{prefix}loss_history"].astype(np.float32).tolist() if f"{prefix}loss_history" in data else []
            )
            clf.lut_ = _compute_lut(clf.embedding_, clf.conv_w_, clf.k_sizes_)
            suite[target] = clf
    return cls(classifiers=suite)

pretrained cached classmethod

pretrained() -> Self | None

Load the packaged src/kbank/data/cluster_classifiers.npz suite if available, else None.

Source code in src/kbank/biosynthesis/classifier.py
@classmethod
@cache
def pretrained(cls) -> Self | None:
    """Load the packaged `src/kbank/data/cluster_classifiers.npz` suite if available, else `None`."""
    try:
        res = files("kbank.data").joinpath("cluster_classifiers.npz")
        with as_file(res) as p:
            if p.exists():
                return cls.load(p)
    except Exception:
        pass
    fallback_path = Path(__file__).resolve().parent.parent / "data" / "cluster_classifiers.npz"
    if fallback_path.exists():
        return cls.load(fallback_path)
    return None

precompute_for_loci

precompute_for_loci(loci: Loci | Sequence[Locus]) -> None

Precompute cluster-pooled (cluster_id > 0) and single-ORF (cluster_id == 0) probabilities across loci.

Source code in src/kbank/biosynthesis/classifier.py
def precompute_for_loci(self, loci: Loci | Sequence[Locus]) -> None:
    """Precompute cluster-pooled (`cluster_id > 0`) and single-ORF (`cluster_id == 0`) probabilities across `loci`."""
    if not self.is_fitted:
        return

    gt_seqs: list[bytes] = []
    gt_cids: list[int] = []
    gt_keys: list[tuple[int, int]] = []

    wzy_seqs: list[bytes] = []
    wzy_cids: list[int] = []
    wzy_keys: list[tuple[int, int]] = []

    init_seqs: list[bytes] = []
    init_cids: list[int] = []
    init_keys: list[tuple[int, int]] = []

    mod_seqs: list[bytes] = []
    mod_cids: list[int] = []
    mod_keys: list[tuple[int, int]] = []

    n_loci = len(loci)
    for loc_i in range(n_loci):
        loc = loci[loc_i]
        if len(loc.nodes) == 0 or len(loc.genes) == 0:
            continue
        translations = loc.genes.translations
        n_tr = len(translations)
        for g_i, g_node in enumerate(loc.nodes):
            if g_i >= n_tr:
                continue
            aa_seq = bytes(translations[g_i].seq)
            if len(aa_seq) < 60:
                continue
            cid = int(g_node.cluster_id)
            if g_node.role in (
                GeneRole.GLYCOSYLTRANSFERASE,
                GeneRole.POLYMERASE,
                GeneRole.TRANSMEMBRANE_SYNTHASE,
            ):
                gt_seqs.append(aa_seq)
                gt_cids.append(cid)
                gt_keys.append((loc_i, g_i))
                if len(aa_seq) >= 550:
                    doms = split_bifunctional_gt_domains(aa_seq, min_len=550, overlap=25)
                    if doms is not None:
                        gt_seqs.append(doms[0])
                        gt_cids.append((cid + 400000) if cid > 0 else 0)
                        gt_keys.append((loc_i, -(g_i + 1) * 2))
                        gt_seqs.append(doms[1])
                        gt_cids.append((cid + 500000) if cid > 0 else 0)
                        gt_keys.append((loc_i, -(g_i + 1) * 2 - 1))
                if g_node.role == GeneRole.POLYMERASE:
                    wzy_seqs.append(aa_seq)
                    wzy_cids.append(cid)
                    wzy_keys.append((loc_i, g_i))
            elif g_node.role == GeneRole.INITIATOR:
                init_seqs.append(aa_seq)
                init_cids.append(cid)
                init_keys.append((loc_i, g_i))
            elif g_node.role in MODIFIER_ROLES:
                mod_seqs.append(aa_seq)
                mod_cids.append(cid)
                mod_keys.append((loc_i, g_i))

    self._cluster_probs.clear()
    self._gene_probs.clear()
    self._seq_probs.clear()

    def _populate_group(
        seqs_list: list[bytes],
        cids_list: list[int],
        keys_list: list[tuple[int, int]],
        targets: tuple[str, ...],
    ) -> None:
        if not seqs_list:
            return
        s_batch = Sequences.from_bytes(seqs_list)
        c_arr = np.asarray(cids_list, dtype=np.int64)
        for target in targets:
            clf = self.classifiers.get(target)
            if clf is None or not clf.is_fitted:
                continue
            classes = [int(c) for c in clf.classes_]
            raw_probs = clf.predict_proba(s_batch)
            gene_map: dict[tuple[int, int], dict[int, float]] = {}
            seq_map = self._seq_probs.setdefault(target, {})
            for idx_s, key in enumerate(keys_list):
                row = raw_probs[idx_s]
                dist_s = {classes[c_i]: float(row[c_i]) for c_i in range(len(classes)) if row[c_i] >= 0.01}
                gene_map[key] = dist_s
                seq_map[seqs_list[idx_s]] = dist_s
            self._gene_probs[target] = gene_map

            uniq_cids, c_probs = clf.predict_cluster_proba(
                s_batch,
                c_arr,
                pool="log_proba",
                ignore_non_positive=True,
            )
            c_map: dict[int, dict[int, float]] = {}
            for idx_c, cid_val in enumerate(uniq_cids):
                row = c_probs[idx_c]
                c_map[int(cid_val)] = {
                    classes[c_i]: float(row[c_i]) for c_i in range(len(classes)) if row[c_i] >= 0.01
                }
            self._cluster_probs[target] = c_map

    _populate_group(
        gt_seqs,
        gt_cids,
        gt_keys,
        ("donor_linkage", "donor", "linkage", "acceptor_sugar", "acceptor_motif", "is_backbone", "mechanism"),
    )
    _populate_group(wzy_seqs, wzy_cids, wzy_keys, ("wzy_linkage",))
    _populate_group(init_seqs, init_cids, init_keys, ("initiator_sugar",))
    _populate_group(mod_seqs, mod_cids, mod_keys, ("modifier_linkage",))

preferred_init_sugar

preferred_init_sugar(
    cluster_id: int,
    candidate_sugars: set[int],
    aa_seq: bytes | None = None,
) -> tuple[int | None, float]

Return (best_sugar_idx, probability) from initiator_sugar classifier among candidate_sugars.

Source code in src/kbank/biosynthesis/classifier.py
def preferred_init_sugar(
    self,
    cluster_id: int,
    candidate_sugars: set[int],
    aa_seq: bytes | None = None,
) -> tuple[int | None, float]:
    """Return `(best_sugar_idx, probability)` from `initiator_sugar` classifier among `candidate_sugars`."""
    dist = self._get_target_dist("initiator_sugar", cluster_id, aa_seq=aa_seq)
    if not dist:
        return None, 0.0
    best_s: int | None = None
    best_p = 0.0
    for s_idx in candidate_sugars:
        p = dist.get(int(s_idx), 0.0)
        if p > best_p:
            best_p = p
            best_s = int(s_idx)
    return (best_s, best_p) if best_p >= 0.35 else (None, 0.0)

gt_edge_score

gt_edge_score(
    cluster_id: int,
    glycan: Glycan,
    edge_idx: int,
    aa_seq: bytes | None = None,
) -> tuple[float, bool]

Compute factorized 1D-CNN cost bonus in [-0.25, +0.06] and consensus flag for (cluster_id, edge_idx).

Source code in src/kbank/biosynthesis/classifier.py
def gt_edge_score(
    self,
    cluster_id: int,
    glycan: Glycan,
    edge_idx: int,
    aa_seq: bytes | None = None,
) -> tuple[float, bool]:
    """Compute factorized 1D-CNN cost bonus in `[-0.25, +0.06]` and consensus flag for `(cluster_id, edge_idx)`."""
    if not (0 <= edge_idx < len(glycan.edges)):
        return 0.0, False
    edge = glycan.edges[edge_idx]
    s_res = glycan.nodes[edge.source]
    t_res = glycan.nodes[edge.target]
    if s_res.is_modifier or t_res.is_modifier:
        return 0.0, False

    link = edge.edge_type
    donor_code = encode_donor_residue_chem(s_res)
    acc_code = encode_donor_residue_chem(t_res)
    acc_motif_code = extract_acceptor_motif_for_edge(glycan, edge_idx)
    link_code = int(link.chemical_int)
    dl_code = encode_donor_linkage_label(s_res, link)
    bb_code = 1 if link.is_backbone else 0
    _, _, _, mech = _edge_signatures(glycan, edge_idx)

    def _score_single_seq(seq_cand: bytes | None, cid_cand: int) -> tuple[float, bool]:
        dist_dl = self._get_target_dist("donor_linkage", cid_cand, aa_seq=seq_cand)
        dist_d = self._get_target_dist("donor", cid_cand, aa_seq=seq_cand)
        dist_l = self._get_target_dist("linkage", cid_cand, aa_seq=seq_cand)
        dist_acc = self._get_target_dist("acceptor_sugar", cid_cand, aa_seq=seq_cand)
        dist_am = self._get_target_dist("acceptor_motif", cid_cand, aa_seq=seq_cand)
        dist_bb = self._get_target_dist("is_backbone", cid_cand, aa_seq=seq_cand)
        dist_m = self._get_target_dist("mechanism", cid_cand, aa_seq=seq_cand)

        if not dist_d and not dist_l and not dist_dl:
            return 0.0, False

        p_dl = dist_dl.get(dl_code, 0.0)
        p_d = dist_d.get(donor_code, 0.0)
        p_l = dist_l.get(link_code, 0.0)
        p_acc = dist_acc.get(acc_code, 0.0)
        p_am = dist_am.get(acc_motif_code, 0.0)
        p_bb = dist_bb.get(bb_code, 0.5)
        p_m = dist_m.get(int(mech), 0.0) if mech in (1, 2) else 0.5

        bonus_val = -(
            0.10 * p_dl
            + 0.05 * p_d
            + 0.05 * p_l
            + 0.025 * p_acc
            + 0.025 * p_am
            + 0.02 * max(0.0, p_bb - 0.5)
            + 0.03 * max(0.0, p_m - 0.5)
        )
        if dist_m and mech in (1, 2) and p_m < 0.25:
            bonus_val += 0.04 * (1.0 - p_m)

        strong_flag = False
        if p_dl >= 0.42 or (p_d >= 0.50 and p_l >= 0.45 and p_m >= 0.65):
            competing = 0
            for k_o, e_o in enumerate(glycan.edges):
                if k_o == edge_idx:
                    continue
                so = glycan.nodes[e_o.source]
                to = glycan.nodes[e_o.target]
                if so.is_modifier or to.is_modifier:
                    continue
                dl_o = encode_donor_linkage_label(so, e_o.edge_type)
                am_o = extract_acceptor_motif_for_edge(glycan, k_o)
                if dl_o == dl_code and am_o == acc_motif_code:
                    continue
                p_dl_o = dist_dl.get(dl_o, 0.0)
                p_d_o = dist_d.get(encode_donor_residue_chem(so), 0.0)
                p_l_o = dist_l.get(int(e_o.edge_type.chemical_int), 0.0)
                p_am_o = dist_am.get(am_o, 0.0)
                if (
                    (p_dl_o >= 0.75 * max(p_dl, 1e-6))
                    and (p_d_o * p_l_o >= 0.75 * max(p_d * p_l, 1e-6))
                    and (p_am <= 0.15 or p_am_o >= 0.80 * p_am)
                ):
                    competing += 1
            if competing == 0:
                strong_flag = True
        return float(np.clip(bonus_val, -0.25, 0.06)), strong_flag

    best_bonus, has_strong_clf = _score_single_seq(aa_seq, cluster_id)
    if aa_seq is not None and len(aa_seq) >= 550:
        doms = split_bifunctional_gt_domains(aa_seq, min_len=550, overlap=25)
        if doms is not None:
            for d_idx, d_seq in enumerate(doms):
                d_cid = (cluster_id + (400000 if d_idx == 0 else 500000)) if cluster_id > 0 else 0
                b_d, s_d = _score_single_seq(d_seq, d_cid)
                if b_d < best_bonus:
                    best_bonus = b_d
                if s_d:
                    has_strong_clf = True

    return best_bonus, has_strong_clf

encode_donor_residue_chem

encode_donor_residue_chem(res: ResidueType) -> int

Return topology-invariant integer code for a donor ResidueType (sugar | chirality | furanose).

Source code in src/kbank/biosynthesis/classifier.py
def encode_donor_residue_chem(res: ResidueType) -> int:
    """Return topology-invariant integer code for a donor `ResidueType` (`sugar | chirality | furanose`)."""
    return int(res) & ~ResidueType.TOPOLOGY_MASK

encode_donor_linkage_label

encode_donor_linkage_label(
    res: ResidueType, link: LinkageType
) -> int

Pack (donor_residue_chem, linkage_chem) into a single positive 32-bit integer class label.

Source code in src/kbank/biosynthesis/classifier.py
def encode_donor_linkage_label(res: ResidueType, link: LinkageType) -> int:
    """Pack `(donor_residue_chem, linkage_chem)` into a single positive 32-bit integer class label."""
    d_chem = encode_donor_residue_chem(res) & 0xFFF
    l_chem = link.chemical_int & 0x3FF
    return (d_chem << 10) | l_chem

decode_donor_linkage_label

decode_donor_linkage_label(
    label: int,
) -> tuple[ResidueType, LinkageType]

Unpack a donor_linkage integer class label into (ResidueType, LinkageType).

Source code in src/kbank/biosynthesis/classifier.py
def decode_donor_linkage_label(label: int) -> tuple[ResidueType, LinkageType]:
    """Unpack a `donor_linkage` integer class label into `(ResidueType, LinkageType)`."""
    d_chem = (int(label) >> 10) & 0xFFF
    l_chem = int(label) & 0x3FF
    return ResidueType(d_chem), LinkageType(l_chem | int(LinkageType.TAG_EDGE.mask))

encode_acceptor_motif_label

encode_acceptor_motif_label(
    res: ResidueType,
    acc_anomer: Anomer | int,
    acc_role: int,
) -> int

Pack (acceptor_residue_chem, acc_anomer, acc_role) into a positive integer class label.

acc_role: 1 = reducing-end root (@root), 2 = internal backbone (@bb), 3 = side-chain branch (@br).

Source code in src/kbank/biosynthesis/classifier.py
def encode_acceptor_motif_label(res: ResidueType, acc_anomer: Anomer | int, acc_role: int) -> int:
    """Pack `(acceptor_residue_chem, acc_anomer, acc_role)` into a positive integer class label.

    `acc_role`: `1` = reducing-end root (`@root`), `2` = internal backbone (`@bb`), `3` = side-chain branch (`@br`).
    """
    a_chem = encode_donor_residue_chem(res) & 0xFFF
    anom_bits = int(acc_anomer) & 0x3
    role_bits = int(acc_role) & 0x3
    return (a_chem << 4) | (anom_bits << 2) | role_bits

decode_acceptor_motif_label

decode_acceptor_motif_label(
    label: int,
) -> tuple[ResidueType, Anomer, int]

Unpack an acceptor_motif integer label into (ResidueType, Anomer, acc_role).

Source code in src/kbank/biosynthesis/classifier.py
def decode_acceptor_motif_label(label: int) -> tuple[ResidueType, Anomer, int]:
    """Unpack an `acceptor_motif` integer label into `(ResidueType, Anomer, acc_role)`."""
    val = int(label)
    a_chem = (val >> 4) & 0xFFF
    anom_bits = (val >> 2) & 0x3
    role_bits = val & 0x3
    anom = Anomer(anom_bits) if anom_bits in (0, 1, 2) else Anomer.UNKNOWN
    return ResidueType(a_chem), anom, role_bits

extract_acceptor_motif_for_edge

extract_acceptor_motif_for_edge(
    glycan: Glycan, edge_idx: int
) -> int

Compute the acceptor_motif integer code ({anomer}{sugar}@{root|bb|br}) for the target residue of edge_idx.

Source code in src/kbank/biosynthesis/classifier.py
def extract_acceptor_motif_for_edge(glycan: Glycan, edge_idx: int) -> int:
    """Compute the `acceptor_motif` integer code (`{anomer}{sugar}@{root|bb|br}`) for the target residue of `edge_idx`."""
    edge = glycan.edges[edge_idx]
    t_idx = int(edge.target)
    t_res = glycan.nodes[t_idx]
    red_idx = glycan.reducing_end_index
    acc_anomer = Anomer.UNKNOWN
    is_bb_node = False
    for e_o in glycan.edges:
        if int(e_o.source) == t_idx and not glycan.nodes[e_o.target].is_modifier:
            if e_o.edge_type.is_backbone:
                is_bb_node = True
                if e_o.edge_type.anomer in (Anomer.ALPHA, Anomer.BETA):
                    acc_anomer = e_o.edge_type.anomer
                    break
            elif acc_anomer == Anomer.UNKNOWN and e_o.edge_type.anomer in (Anomer.ALPHA, Anomer.BETA):
                acc_anomer = e_o.edge_type.anomer
    if t_idx == red_idx or t_res.is_reducing_end:
        acc_role = 1  # @root
    elif is_bb_node:
        acc_role = 2  # @bb
    else:
        acc_role = 3  # @br
    return encode_acceptor_motif_label(t_res, acc_anomer, acc_role)

split_bifunctional_gt_domains

split_bifunctional_gt_domains(
    aa_seq: bytes, *, min_len: int = 550, overlap: int = 25
) -> tuple[bytes, bytes] | None

Split a multi-domain bifunctional GT (len(aa_seq) >= min_len) into (N_term_seq, C_term_seq).

Source code in src/kbank/biosynthesis/classifier.py
def split_bifunctional_gt_domains(
    aa_seq: bytes,
    *,
    min_len: int = 550,
    overlap: int = 25,
) -> tuple[bytes, bytes] | None:
    """Split a multi-domain bifunctional GT (`len(aa_seq) >= min_len`) into `(N_term_seq, C_term_seq)`."""
    n = len(aa_seq)
    if n < min_len:
        return None
    mid = n // 2
    return aa_seq[: min(n, mid + overlap)], aa_seq[max(0, mid - overlap) :]

encode_modifier_linkage_label

encode_modifier_linkage_label(
    mod_sugar: int, acceptor_carbon: int
) -> int

Pack (modifier_sugar_id, acceptor_carbon) into a positive integer class label.

Source code in src/kbank/biosynthesis/classifier.py
def encode_modifier_linkage_label(mod_sugar: int, acceptor_carbon: int) -> int:
    """Pack `(modifier_sugar_id, acceptor_carbon)` into a positive integer class label."""
    return ((int(mod_sugar) & 0xFF) << 4) | (int(acceptor_carbon) & 0xF)

decode_modifier_linkage_label

decode_modifier_linkage_label(
    label: int,
) -> tuple[int, int]

Unpack a modifier_linkage integer class label into (modifier_sugar_id, acceptor_carbon).

Source code in src/kbank/biosynthesis/classifier.py
def decode_modifier_linkage_label(label: int) -> tuple[int, int]:
    """Unpack a `modifier_linkage` integer class label into `(modifier_sugar_id, acceptor_carbon)`."""
    return (int(label) >> 4) & 0xFF, int(label) & 0xF

De Novo Structure Prediction (kbank.biosynthesis.predictor)

predictor

Four-stage GNN + Constrained Arborescence Predictor (Locus -> Glycan).

Implements: - Stage 1: Node feature initialization (GeneRoleClassifier.transform + 9-head BiosyntheticClassifierSuite + PanGraphConsensusPrior MCL cluster posteriors + LocusArchetype). - Stage 2: Numba-compiled Relational Message-Passing GNN over CSR Locus graphs (_csr_relational_mpnn_forward) with Virtual Operon Precursor Pool (c_pool) gating and clamped node propagation (known_assignments). - Stage 3: Contextualized multi-task biochemical potentials (initiator_sugar, donor_linkage, donor, linkage, acceptor_sugar, is_backbone, wzy_linkage, modifier_linkage), stoichiometric precursor coverage bonus, and processive/bifunctional GT multi-node expansion. - Stage 4: Numba-compiled Chu-Liu/Edmonds upper-bound + Branch-and-Bound Constrained Spanning Arborescence Assembler enforcing acceptor-carbon valence uniqueness (c_acc non-collision) and contiguous main-chain backbone grammar closed by Wzy, returning top-k GlycanPrediction candidates.

Classes:

Name Description
GlycanPredictorParams

Hyperparameter weights governing Stage 3 node potentials and Stage 4 constrained arborescence scoring.

GlycanPrediction

Single top-k candidate Glycan predicted from a biosynthetic Locus.

GlycanPredictor

Four-stage GNN + Constrained Arborescence Predictor (Locus -> Glycan).

GlycanPredictorParams dataclass

Hyperparameter weights governing Stage 3 node potentials and Stage 4 constrained arborescence scoring.

Methods:

Name Description
to_array

Pack the 14 potential weights into a 1D float64 NumPy array for .npz serialization.

from_array

Unpack a 1D float64 NumPy array into a GlycanPredictorParams instance.

Source code in src/kbank/biosynthesis/predictor.py
@dataclass(slots=True, frozen=True)
class GlycanPredictorParams:
    """Hyperparameter weights governing Stage 3 node potentials and Stage 4 constrained arborescence scoring."""

    w_pan_dl: float = 3.2
    w_knn_gt: float = 2.0
    w_clf_dl: float = 1.6
    w_clf_factorized: float = 1.2
    w_mpnn_dl_free: float = 0.65
    syntenic_msg_alpha: float = 0.18
    w_backbone_log: float = 0.65
    w_precursor_bonus: float = 0.65
    w_acceptor_prob: float = 0.95
    w_acceptor_motif: float = 3.2
    w_mpnn_edge: float = 1.0
    w_pan_acc_match: float = 4.5
    w_disac_bigram: float = 0.65
    w_syntenic_step: float = 0.55

    def to_array(self) -> np.ndarray[tuple[int], np.dtype[np.float64]]:
        """Pack the 14 potential weights into a 1D `float64` NumPy array for `.npz` serialization."""
        return np.array(
            [
                self.w_pan_dl,
                self.w_knn_gt,
                self.w_clf_dl,
                self.w_clf_factorized,
                self.w_mpnn_dl_free,
                self.syntenic_msg_alpha,
                self.w_backbone_log,
                self.w_precursor_bonus,
                self.w_acceptor_prob,
                self.w_acceptor_motif,
                self.w_mpnn_edge,
                self.w_pan_acc_match,
                self.w_disac_bigram,
                self.w_syntenic_step,
            ],
            dtype=np.float64,
        )

    @classmethod
    def from_array(cls, arr: np.ndarray) -> Self:
        """Unpack a 1D `float64` NumPy array into a `GlycanPredictorParams` instance."""
        v = np.asarray(arr, dtype=np.float64).ravel()
        defaults = cls().to_array()
        n = min(len(v), len(defaults))
        merged = defaults.copy()
        merged[:n] = v[:n]
        return cls(
            w_pan_dl=float(merged[0]),
            w_knn_gt=float(merged[1]),
            w_clf_dl=float(merged[2]),
            w_clf_factorized=float(merged[3]),
            w_mpnn_dl_free=float(merged[4]),
            syntenic_msg_alpha=float(merged[5]),
            w_backbone_log=float(merged[6]),
            w_precursor_bonus=float(merged[7]),
            w_acceptor_prob=float(merged[8]),
            w_acceptor_motif=float(merged[9]),
            w_mpnn_edge=float(merged[10]),
            w_pan_acc_match=float(merged[11]),
            w_disac_bigram=float(merged[12]),
            w_syntenic_step=float(merged[13]),
        )

to_array

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

Pack the 14 potential weights into a 1D float64 NumPy array for .npz serialization.

Source code in src/kbank/biosynthesis/predictor.py
def to_array(self) -> np.ndarray[tuple[int], np.dtype[np.float64]]:
    """Pack the 14 potential weights into a 1D `float64` NumPy array for `.npz` serialization."""
    return np.array(
        [
            self.w_pan_dl,
            self.w_knn_gt,
            self.w_clf_dl,
            self.w_clf_factorized,
            self.w_mpnn_dl_free,
            self.syntenic_msg_alpha,
            self.w_backbone_log,
            self.w_precursor_bonus,
            self.w_acceptor_prob,
            self.w_acceptor_motif,
            self.w_mpnn_edge,
            self.w_pan_acc_match,
            self.w_disac_bigram,
            self.w_syntenic_step,
        ],
        dtype=np.float64,
    )

from_array classmethod

from_array(arr: ndarray) -> Self

Unpack a 1D float64 NumPy array into a GlycanPredictorParams instance.

Source code in src/kbank/biosynthesis/predictor.py
@classmethod
def from_array(cls, arr: np.ndarray) -> Self:
    """Unpack a 1D `float64` NumPy array into a `GlycanPredictorParams` instance."""
    v = np.asarray(arr, dtype=np.float64).ravel()
    defaults = cls().to_array()
    n = min(len(v), len(defaults))
    merged = defaults.copy()
    merged[:n] = v[:n]
    return cls(
        w_pan_dl=float(merged[0]),
        w_knn_gt=float(merged[1]),
        w_clf_dl=float(merged[2]),
        w_clf_factorized=float(merged[3]),
        w_mpnn_dl_free=float(merged[4]),
        syntenic_msg_alpha=float(merged[5]),
        w_backbone_log=float(merged[6]),
        w_precursor_bonus=float(merged[7]),
        w_acceptor_prob=float(merged[8]),
        w_acceptor_motif=float(merged[9]),
        w_mpnn_edge=float(merged[10]),
        w_pan_acc_match=float(merged[11]),
        w_disac_bigram=float(merged[12]),
        w_syntenic_step=float(merged[13]),
    )

GlycanPrediction dataclass

Single top-k candidate Glycan predicted from a biosynthetic Locus.

Source code in src/kbank/biosynthesis/predictor.py
@dataclass(slots=True, frozen=True)
class GlycanPrediction:
    """Single top-`k` candidate `Glycan` predicted from a biosynthetic `Locus`."""

    glycan: Glycan
    score: float
    probability: float
    assignments: tuple[GeneGlycanAssignment, ...]
    precursor_pool: dict[bytes, float]

GlycanPredictor

Four-stage GNN + Constrained Arborescence Predictor (Locus -> Glycan).

Methods:

Name Description
from_records

Fit GlycanPredictor on GlycanRecords (supporting held-out record masking for cross-validation).

resolve_locus_homologs

Map raw unclustered LocusPredictor ORFs to Pan-Locus (GeneRole, cluster_id) via 4-mer indexed PairwiseAligner.

fit_mpnn

Train serogen.core.graph.gnn.RelationalMPNN on aligned (Locus, Glycan) arborescences.

save_mpnn

Save the fitted RelationalMPNN, mpnn_dl_classes, and GlycanPredictorParams to a zero-pickle .npz archive.

load_mpnn

Load a fitted RelationalMPNN, mpnn_dl_classes, and GlycanPredictorParams from a zero-pickle .npz archive.

predict

Predict top-k candidate Glycan graphs de novo from a Locus via 4-stage GNN + Arborescence.

Source code in src/kbank/biosynthesis/predictor.py
 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
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
1971
1972
1973
1974
1975
1976
1977
1978
1979
1980
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
2047
2048
2049
2050
2051
2052
2053
2054
2055
2056
2057
2058
2059
2060
2061
2062
2063
2064
2065
2066
2067
2068
2069
2070
2071
2072
2073
2074
2075
2076
2077
2078
2079
2080
2081
2082
2083
2084
2085
2086
2087
2088
2089
2090
2091
2092
2093
2094
2095
2096
2097
2098
2099
2100
2101
2102
2103
2104
2105
2106
2107
2108
2109
2110
2111
2112
2113
2114
2115
2116
2117
2118
2119
2120
2121
2122
2123
2124
2125
2126
2127
2128
2129
2130
2131
2132
2133
2134
2135
2136
2137
2138
2139
2140
2141
2142
2143
2144
2145
2146
2147
2148
2149
2150
2151
2152
2153
2154
2155
2156
2157
2158
2159
2160
2161
2162
2163
2164
2165
2166
2167
2168
2169
2170
2171
2172
2173
2174
2175
2176
2177
2178
2179
2180
2181
2182
2183
2184
2185
2186
2187
2188
2189
2190
2191
2192
2193
2194
2195
2196
2197
2198
2199
2200
2201
2202
2203
2204
2205
2206
2207
2208
2209
2210
2211
2212
2213
2214
2215
2216
2217
2218
2219
2220
2221
2222
2223
2224
2225
2226
2227
2228
2229
2230
2231
2232
2233
2234
2235
2236
2237
2238
2239
2240
2241
2242
2243
2244
2245
2246
2247
2248
2249
2250
2251
2252
2253
2254
2255
2256
2257
2258
2259
2260
2261
2262
2263
2264
2265
2266
2267
2268
2269
2270
2271
2272
2273
2274
2275
2276
2277
2278
2279
2280
2281
2282
2283
2284
2285
2286
2287
2288
2289
2290
2291
2292
2293
2294
2295
2296
2297
2298
2299
2300
2301
2302
2303
2304
2305
2306
2307
2308
2309
2310
2311
2312
2313
2314
2315
2316
2317
2318
2319
2320
2321
2322
2323
2324
2325
2326
2327
2328
2329
2330
2331
2332
2333
2334
2335
2336
2337
2338
2339
2340
2341
2342
2343
2344
2345
2346
2347
2348
2349
2350
2351
2352
2353
2354
2355
2356
2357
2358
2359
2360
2361
2362
2363
2364
2365
2366
2367
2368
2369
2370
2371
2372
2373
2374
2375
2376
2377
2378
2379
2380
2381
2382
2383
2384
2385
2386
2387
2388
2389
2390
2391
2392
2393
2394
2395
2396
2397
2398
2399
2400
2401
2402
2403
2404
2405
2406
2407
2408
2409
2410
2411
2412
2413
2414
2415
2416
2417
2418
2419
2420
2421
2422
2423
2424
2425
2426
2427
2428
2429
2430
2431
2432
2433
2434
2435
2436
2437
2438
2439
2440
2441
2442
2443
2444
2445
2446
2447
2448
2449
2450
2451
2452
2453
2454
2455
2456
2457
2458
2459
2460
2461
2462
2463
2464
2465
2466
2467
2468
2469
2470
2471
2472
2473
2474
2475
2476
2477
2478
2479
2480
2481
2482
2483
2484
2485
2486
2487
2488
2489
2490
2491
2492
2493
2494
2495
2496
2497
2498
2499
2500
2501
2502
2503
2504
2505
2506
2507
2508
2509
2510
2511
2512
2513
2514
2515
2516
2517
2518
2519
2520
2521
2522
2523
2524
2525
2526
2527
2528
2529
2530
2531
2532
2533
2534
2535
2536
2537
2538
2539
2540
2541
2542
2543
2544
2545
2546
2547
2548
2549
2550
2551
2552
2553
2554
2555
2556
2557
2558
2559
2560
2561
2562
2563
2564
2565
2566
2567
2568
2569
2570
2571
2572
2573
2574
2575
2576
2577
2578
2579
2580
2581
2582
2583
2584
2585
2586
2587
2588
2589
2590
2591
2592
2593
2594
2595
2596
2597
2598
2599
2600
2601
2602
2603
2604
2605
2606
2607
2608
2609
2610
2611
2612
2613
2614
2615
2616
2617
2618
2619
2620
2621
2622
2623
2624
2625
2626
2627
2628
2629
2630
2631
2632
2633
2634
2635
2636
2637
2638
2639
2640
2641
2642
2643
2644
2645
2646
2647
2648
2649
2650
2651
2652
2653
2654
2655
2656
2657
2658
2659
2660
2661
2662
2663
2664
2665
2666
2667
2668
2669
2670
2671
2672
2673
2674
2675
2676
2677
2678
2679
class GlycanPredictor:
    """Four-stage GNN + Constrained Arborescence Predictor (`Locus -> Glycan`)."""

    def __init__(
        self,
        *,
        pan_prior: PanGraphConsensusPrior,
        classifier_suite: BiosyntheticClassifierSuite | None = None,
        assigner: BiosyntheticAssigner | None = None,
        mpnn: RelationalMPNN | None = None,
        mpnn_dl_classes: np.ndarray | None = None,
        disaccharide_counts: dict[tuple[int, int, int], int] | None = None,
        donor_acc_counts: dict[tuple[int, int], int] | None = None,
        wzy_closure_counts: dict[tuple[int, int, int], int] | None = None,
        archetype_init_counts: dict[tuple[int, int], int] | None = None,
        donor_vocab: tuple[int, ...] = (),
        bigram_trans_matrix: np.ndarray | None = None,
        ref_gt_embeddings: np.ndarray | None = None,
        ref_gt_dl_labels: np.ndarray | None = None,
        ref_gt_acc_labels: np.ndarray | None = None,
        ref_gt_am_labels: np.ndarray | None = None,
        ref_gt_bb_labels: np.ndarray | None = None,
        ref_gt_record_indices: np.ndarray | None = None,
        ref_init_embeddings: np.ndarray | None = None,
        ref_init_sugars: np.ndarray | None = None,
        ref_init_record_indices: np.ndarray | None = None,
        ref_wzy_embeddings: np.ndarray | None = None,
        ref_wzy_links: np.ndarray | None = None,
        ref_wzy_record_indices: np.ndarray | None = None,
        multi_edge_clusters: dict[int, list[tuple[ResidueType, LinkageType]]] | None = None,
        locus_fingerprints: dict[tuple[int, ...], list[tuple[int, Glycan, tuple[GeneGlycanAssignment, ...]]]]
        | None = None,
        cluster_am_counts: dict[int, dict[int, float]] | None = None,
        ref_orf_seqs: tuple[bytes, ...] = (),
        ref_orf_cids: np.ndarray | None = None,
        ref_orf_roles: np.ndarray | None = None,
        orf_kmer_index: dict[bytes, list[int]] | None = None,
        params: GlycanPredictorParams | None = None,
        mpnn_include_2hop: bool = True,
    ) -> None:
        self.pan_prior = pan_prior
        self.classifier_suite = classifier_suite or BiosyntheticClassifierSuite.pretrained()
        self.assigner = assigner or BiosyntheticAssigner()
        self.mpnn = mpnn
        self.mpnn_dl_classes = mpnn_dl_classes
        self.params = params or GlycanPredictorParams()
        self.mpnn_include_2hop = bool(mpnn_include_2hop)
        self.disaccharide_counts = disaccharide_counts or {}
        self.donor_acc_counts = donor_acc_counts or {}
        self.wzy_closure_counts = wzy_closure_counts or {}
        self.archetype_init_counts = archetype_init_counts or {}
        self.donor_vocab = donor_vocab
        self.donor_to_idx = {d: i for i, d in enumerate(donor_vocab)}
        self.bigram_trans_matrix = (
            bigram_trans_matrix
            if bigram_trans_matrix is not None
            else np.zeros((len(donor_vocab), len(donor_vocab)), dtype=np.float64)
        )
        self.ref_gt_embeddings = ref_gt_embeddings
        self.ref_gt_dl_labels = ref_gt_dl_labels
        self.ref_gt_acc_labels = ref_gt_acc_labels
        self.ref_gt_am_labels = ref_gt_am_labels
        self.ref_gt_bb_labels = ref_gt_bb_labels
        self.ref_gt_record_indices = ref_gt_record_indices
        self.ref_init_embeddings = ref_init_embeddings
        self.ref_init_sugars = ref_init_sugars
        self.ref_init_record_indices = ref_init_record_indices
        self.ref_wzy_embeddings = ref_wzy_embeddings
        self.ref_wzy_links = ref_wzy_links
        self.ref_wzy_record_indices = ref_wzy_record_indices
        self.multi_edge_clusters = multi_edge_clusters or {}
        self.locus_fingerprints = locus_fingerprints or {}
        self.cluster_am_counts = cluster_am_counts or {}
        self.ref_orf_seqs = tuple(ref_orf_seqs)
        self.ref_orf_cids = ref_orf_cids
        self.ref_orf_roles = ref_orf_roles
        self._orf_kmer_index = orf_kmer_index or {}
        self._pairwise_aligner: PairwiseAligner | None = None
        self._last_mpnn_edge_scores: np.ndarray | None = None
        self._embed_cache: dict[bytes, np.ndarray | None] = {}
        self._conv_max_cache: dict[tuple[bytes, bool], np.ndarray] = {}
        self._clf_proba_cache: dict[tuple[str, bytes], np.ndarray | None] = {}

    @classmethod
    def from_records(
        cls,
        records: GlycanRecords | None = None,
        *,
        exclude_record_indices: set[int] | None = None,
        classifier_suite: BiosyntheticClassifierSuite | None = None,
        mpnn_epochs: int = 18,
        mpnn_path: Path | None = DEFAULT_MPNN_PATH,
        retrain_mpnn: bool = False,
        train_mpnn: bool | None = None,
    ) -> Self:
        """Fit `GlycanPredictor` on `GlycanRecords` (supporting held-out record masking for cross-validation)."""
        if records is None:
            from kbank.db.records import GlycanRecords as _GR

            records = _GR.pretrained()

        if train_mpnn is False:
            mpnn_epochs = 0
        elif train_mpnn is True:
            retrain_mpnn = True

        excluded = exclude_record_indices or set()
        suite = classifier_suite or BiosyntheticClassifierSuite.pretrained()
        assigner = BiosyntheticAssigner()

        # 1. Extract Pan-Glycan empirical disaccharide & Wzy polymer closure priors across all 1,532 glycans
        disac_counts: dict[tuple[int, int, int], int] = {}
        donor_acc_counts: dict[tuple[int, int], int] = {}
        wzy_closure_counts: dict[tuple[int, int, int], int] = {}
        arch_init_counts: dict[tuple[int, int], int] = {}
        donor_set: set[int] = set()

        for i in range(len(records)):
            if i in excluded:
                continue
            gly = records.glycans[i]
            if len(gly.nodes) == 0:
                continue
            r_idx = gly.reducing_end_index
            r_node = gly.nodes[r_idx]
            if not r_node.is_modifier:
                r_chem = encode_donor_residue_chem(r_node)
                donor_set.add(r_chem)
                arch_val = int(records.archetypes[i])
                arch_init_counts[(arch_val, r_chem)] = arch_init_counts.get((arch_val, r_chem), 0) + 1

            for e in gly.edges:
                if not (0 <= e.source < len(gly.nodes) and 0 <= e.target < len(gly.nodes)):
                    continue
                s_n = gly.nodes[e.source]
                t_n = gly.nodes[e.target]
                if s_n.is_modifier or t_n.is_modifier:
                    continue
                s_chem = encode_donor_residue_chem(s_n)
                t_chem = encode_donor_residue_chem(t_n)
                l_chem = int(e.edge_type.chemical_int)
                donor_set.add(s_chem)
                donor_set.add(t_chem)
                if e.edge_type.is_polymer:
                    wzy_closure_counts[(s_chem, l_chem, t_chem)] = (
                        wzy_closure_counts.get((s_chem, l_chem, t_chem), 0) + 1
                    )
                else:
                    disac_counts[(s_chem, l_chem, t_chem)] = disac_counts.get((s_chem, l_chem, t_chem), 0) + 1
                    donor_acc_counts[(s_chem, t_chem)] = donor_acc_counts.get((s_chem, t_chem), 0) + 1

        donor_vocab = tuple(sorted(donor_set))
        d_to_i = {d: idx for idx, d in enumerate(donor_vocab)}
        n_d = len(donor_vocab)
        bigram_trans = np.full((n_d, n_d), 1.0 / max(1, n_d), dtype=np.float64)
        for acc_d, acc_i in d_to_i.items():
            row_tot = 0.0
            for don_d, don_i in d_to_i.items():
                c_val = float(donor_acc_counts.get((don_d, acc_d), 0)) + 0.1
                bigram_trans[acc_i, don_i] = c_val
                row_tot += c_val
            if row_tot > 0:
                bigram_trans[acc_i, :] /= row_tot

        # 2. Build PanGraphConsensusPrior from existing curated assignments (or fit from scratch if uncurated)
        kept_indices = [i for i in range(len(records)) if i not in excluded]
        cluster_am_counts: dict[int, dict[int, float]] = {}
        if records.assignments.total_assignments > 0:
            pan_prior = PanGraphConsensusPrior()
            pan_prior.classifier_suite = suite
            cluster_recs: dict[int, set[int]] = {}
            precursor_roles = {
                GeneRole.OXIDOREDUCTASE,
                GeneRole.EPIMERASE,
                GeneRole.AMINOTRANSFERASE,
                GeneRole.ALDOLASE,
                GeneRole.NUCLEOTIDASE,
                GeneRole.NUCLEOTIDYLTRANSFERASE,
            }
            for i in kept_indices:
                loc = records.loci[i]
                gly = records.glycans[i]
                if len(loc.nodes) == 0 or len(gly.nodes) == 0:
                    continue
                assigns = records.assignments[i]
                for g_i, g_n in enumerate(loc.nodes):
                    c_id = int(g_n.cluster_id)
                    if c_id > 0:
                        cluster_recs.setdefault(c_id, set()).add(i)
                        if g_i + 1 < len(loc.nodes):
                            c_next = int(loc.nodes[g_i + 1].cluster_id)
                            if c_next > 0:
                                pan_prior.pan_locus_edge_counts[(c_id, c_next)] = (
                                    pan_prior.pan_locus_edge_counts.get((c_id, c_next), 0) + 1
                                )
                                pan_prior.syntenic_pair_record_counts[(c_id, c_next)] = (
                                    pan_prior.syntenic_pair_record_counts.get((c_id, c_next), 0) + 1
                                )

                init_cid_rec = 0
                n_ac_mods_rec, _ = assigner._find_n_acetyl_nodes(gly)
                for a in assigns:
                    if not a.is_aligned or a.gene_index < 0 or a.gene_index >= len(loc.nodes):
                        continue
                    if a.score < 0.60 and a.evidence != AssignmentEvidence.PAN_GRAPH_CONSENSUS:
                        continue
                    c_id = int(loc.nodes[a.gene_index].cluster_id)
                    if c_id <= 0:
                        continue
                    w_a = float(a.score) * (2.5 if a.evidence == AssignmentEvidence.PAN_GRAPH_CONSENSUS else 1.0)
                    if a.role == GeneRole.INITIATOR and a.residue is not None and not a.residue.is_modifier:
                        init_cid_rec = c_id
                        s_map = pan_prior.init_cluster_sugar.setdefault(c_id, {})
                        s_map[int(a.residue.sugar)] = s_map.get(int(a.residue.sugar), 0.0) + w_a
                    elif a.role in precursor_roles and a.residue is not None and not a.residue.is_modifier:
                        s_map = pan_prior.precursor_cluster_sugar.setdefault(c_id, {})
                        s_map[int(a.residue.sugar)] = s_map.get(int(a.residue.sugar), 0.0) + w_a
                    elif (
                        a.role
                        in (
                            GeneRole.ACETYLTRANSFERASE,
                            GeneRole.PYRUVYLTRANSFERASE,
                            GeneRole.METHYLTRANSFERASE,
                            GeneRole.PHOSPHOTRANSFERASE,
                            GeneRole.FORMYLTRANSFERASE,
                            GeneRole.ALANINE_TRANSFERASE,
                        )
                        and a.residue is not None
                        and a.residue.is_modifier
                        and 0 <= a.bipartite_glycan_index < len(gly.nodes)
                        and a.bipartite_glycan_index not in n_ac_mods_rec
                    ):
                        for e_m in gly.edges:
                            if e_m.source == a.bipartite_glycan_index and 0 <= e_m.target < len(gly.nodes):
                                t_res_m = gly.nodes[e_m.target]
                                if not t_res_m.is_modifier:
                                    m_key = (
                                        int(a.residue.sugar),
                                        int(t_res_m.sugar),
                                        int(e_m.edge_type.chemical_int),
                                    )
                                    m_spec = pan_prior.modifier_cluster_spec.setdefault(c_id, {})
                                    m_spec[m_key] = m_spec.get(m_key, 0.0) + w_a
                    elif (
                        a.role
                        in (
                            GeneRole.GLYCOSYLTRANSFERASE,
                            GeneRole.POLYMERASE,
                            GeneRole.TRANSMEMBRANE_SYNTHASE,
                        )
                        and a.edge_index is not None
                        and 0 <= a.edge_index < len(gly.edges)
                    ):
                        sig_full, sig_dl, sig_da, mech = _edge_signatures(gly, a.edge_index)
                        f_map = pan_prior.cluster_sig_full.setdefault(c_id, {})
                        f_map[sig_full] = f_map.get(sig_full, 0.0) + w_a
                        dl_map = pan_prior.cluster_sig_dl.setdefault(c_id, {})
                        dl_map[sig_dl] = dl_map.get(sig_dl, 0.0) + w_a
                        da_map = pan_prior.cluster_sig_da.setdefault(c_id, {})
                        da_map[sig_da] = da_map.get(sig_da, 0.0) + w_a
                        if mech > 0:
                            m_map = pan_prior.cluster_mech.setdefault(c_id, {})
                            m_map[mech] = m_map.get(mech, 0.0) + w_a
                        am_code = extract_acceptor_motif_for_edge(gly, int(a.edge_index))
                        am_c_map = cluster_am_counts.setdefault(c_id, {})
                        am_c_map[am_code] = am_c_map.get(am_code, 0.0) + w_a
                        e_obj = gly.edges[a.edge_index]
                        if init_cid_rec > 0 and e_obj.target == gly.reducing_end_index:
                            st1_map = pan_prior.init_adj_gt_step1.setdefault((init_cid_rec, c_id), {})
                            st1_map[sig_dl] = st1_map.get(sig_dl, 0.0) + w_a

            for c_id, r_set in cluster_recs.items():
                pan_prior.cluster_record_counts[c_id] = len(r_set)
        else:
            kept_glycans = [records.glycans[i] for i in kept_indices]
            kept_loci = records.loci[np.asarray(kept_indices, dtype=np.int32)]
            kept_archetypes = records.archetypes[kept_indices]
            kept_repeating = records.is_repeating[kept_indices]
            pan_prior, _ = PanGraphConsensusPrior.fit(
                kept_glycans,
                kept_loci,
                kept_archetypes,
                kept_repeating,
                assigner=assigner,
            )

        # 3. Precompute normalized 192-D 1D-CNN reference embeddings for sequence k-NN retrieval
        gt_seqs: list[bytes] = []
        gt_dl: list[int] = []
        gt_acc: list[int] = []
        gt_am: list[int] = []
        gt_bb: list[int] = []
        gt_recs: list[int] = []

        init_seqs: list[bytes] = []
        init_sugs: list[int] = []
        init_recs: list[int] = []

        wzy_seqs: list[bytes] = []
        wzy_lnks: list[int] = []
        wzy_recs: list[int] = []

        multi_edge_clusters: dict[int, list[tuple[ResidueType, LinkageType]]] = {}
        multi_edge_cand: dict[int, list[tuple[ResidueType, LinkageType]]] = {}
        multi_edge_counts: dict[int, int] = {}
        single_edge_counts: dict[int, int] = {}
        locus_fps: dict[tuple[int, ...], list[tuple[int, Glycan, tuple[GeneGlycanAssignment, ...]]]] = {}
        ref_orf_seqs: list[bytes] = []
        ref_orf_cids: list[int] = []
        ref_orf_roles: list[int] = []
        orf_kmer_index: dict[bytes, list[int]] = {}

        for i in kept_indices:
            loc = records.loci[i]
            gly = records.glycans[i]
            if len(loc.nodes) == 0 or len(gly.nodes) == 0:
                continue
            if len(loc.genes) > 0:
                trans_r = loc.genes.translations
                for g_i_r, nd_r in enumerate(loc.nodes):
                    if nd_r.cluster_id > 0 and g_i_r < len(trans_r):
                        s_b = bytes(trans_r[g_i_r].seq)
                        if len(s_b) >= 60:
                            idx_ref = len(ref_orf_seqs)
                            ref_orf_seqs.append(s_b)
                            ref_orf_cids.append(int(nd_r.cluster_id))
                            ref_orf_roles.append(int(nd_r.role))
                            seen_k: set[bytes] = set()
                            for pos_k in range(0, len(s_b) - 3, 2):
                                km = s_b[pos_k : pos_k + 4]
                                if km not in seen_k:
                                    seen_k.add(km)
                                    orf_kmer_index.setdefault(km, []).append(idx_ref)
            assigns = records.assignments[i]
            fp_cids = tuple(
                int(n.cluster_id)
                for n in loc.nodes
                if n.cluster_id > 0
                and n.role
                in (
                    GeneRole.INITIATOR,
                    GeneRole.GLYCOSYLTRANSFERASE,
                    GeneRole.POLYMERASE,
                    GeneRole.TRANSMEMBRANE_SYNTHASE,
                )
            )
            if len(fp_cids) >= 2:
                locus_fps.setdefault(fp_cids, []).append((i, gly, assigns))

            # Track processive/bifunctional GTs assigned to >= 2 glycosidic edges in the same record
            by_gene_edges: dict[int, list[tuple[int, ResidueType, LinkageType]]] = {}
            _, assembly_depths, _ = gly.polymer_edge_and_assembly_depths(gly.reducing_end_index)
            for a in assigns:
                if (
                    a.is_aligned
                    and a.role in (GeneRole.GLYCOSYLTRANSFERASE, GeneRole.TRANSMEMBRANE_SYNTHASE)
                    and a.edge_index is not None
                    and 0 <= a.edge_index < len(gly.edges)
                    and 0 <= a.gene_index < len(loc.nodes)
                ):
                    e_m = gly.edges[a.edge_index]
                    s_m = gly.nodes[e_m.source]
                    if not s_m.is_modifier and e_m.edge_type.anomer != Anomer.UNKNOWN and e_m.edge_type.acceptor > 0:
                        by_gene_edges.setdefault(a.gene_index, []).append((int(a.edge_index), s_m, e_m.edge_type))
            for g_i_m, edges_m in by_gene_edges.items():
                cid_m = int(loc.nodes[g_i_m].cluster_id)
                if cid_m > 0:
                    if len(edges_m) >= 2:
                        edges_m.sort(key=lambda item: (assembly_depths.get(gly.edges[item[0]].source, 99), item[0]))
                        multi_edge_counts[cid_m] = multi_edge_counts.get(cid_m, 0) + 1
                        if cid_m not in multi_edge_cand:
                            multi_edge_cand[cid_m] = [(item[1], item[2]) for item in edges_m]
                    else:
                        single_edge_counts[cid_m] = single_edge_counts.get(cid_m, 0) + 1

            if len(loc.genes) == 0:
                continue
            translations = loc.genes.translations
            for a in assigns:
                if not a.is_aligned or a.gene_index < 0 or a.gene_index >= len(translations):
                    continue
                if a.score < 0.62 and a.evidence != AssignmentEvidence.PAN_GRAPH_CONSENSUS:
                    continue
                aa = bytes(translations[a.gene_index].seq)
                if len(aa) < 70:
                    continue
                if a.role == GeneRole.INITIATOR and a.residue is not None and not a.residue.is_modifier:
                    init_seqs.append(aa)
                    init_sugs.append(encode_donor_residue_chem(a.residue))
                    init_recs.append(i)
                elif a.role == GeneRole.POLYMERASE and a.edge_index is not None and 0 <= a.edge_index < len(gly.edges):
                    e_w = gly.edges[a.edge_index]
                    if e_w.edge_type.anomer != Anomer.UNKNOWN and e_w.edge_type.acceptor > 0:
                        wzy_seqs.append(aa)
                        wzy_lnks.append(int(e_w.edge_type.chemical_int))
                        wzy_recs.append(i)
                elif (
                    a.role in (GeneRole.GLYCOSYLTRANSFERASE, GeneRole.TRANSMEMBRANE_SYNTHASE)
                    and a.edge_index is not None
                    and 0 <= a.edge_index < len(gly.edges)
                ):
                    e_g = gly.edges[a.edge_index]
                    s_n = gly.nodes[e_g.source]
                    t_n = gly.nodes[e_g.target]
                    if not s_n.is_modifier and not t_n.is_modifier:
                        if e_g.edge_type.anomer != Anomer.UNKNOWN and e_g.edge_type.acceptor > 0:
                            # Use N/C domain split when a bifunctional GT is assigned to 2+ edges
                            aa_use = aa
                            g_edges = by_gene_edges.get(a.gene_index, [])
                            if len(g_edges) >= 2:
                                doms = split_bifunctional_gt_domains(aa, min_len=550, overlap=25)
                                if doms is not None:
                                    e_idx_list = [item[0] for item in g_edges]
                                    if int(a.edge_index) in e_idx_list:
                                        pos_d = e_idx_list.index(int(a.edge_index))
                                        aa_use = doms[0] if pos_d == 0 else doms[1]
                            gt_seqs.append(aa_use)
                            gt_dl.append(encode_donor_linkage_label(s_n, e_g.edge_type))
                            gt_acc.append(encode_donor_residue_chem(t_n))
                            gt_am.append(extract_acceptor_motif_for_edge(gly, int(a.edge_index)))
                            gt_bb.append(1 if e_g.edge_type.is_backbone else 0)
                            gt_recs.append(i)

        for cid_m, cand_m in multi_edge_cand.items():
            m_cnt = multi_edge_counts.get(cid_m, 0)
            s_cnt = single_edge_counts.get(cid_m, 0)
            if m_cnt >= 2 or (m_cnt >= 1 and s_cnt == 0):
                multi_edge_clusters[cid_m] = cand_m

        backbone_clf = (
            suite.classifiers.get("donor_linkage")
            if (suite is not None and "donor_linkage" in suite.classifiers)
            else None
        )
        if backbone_clf is None:
            try:
                backbone_clf = GeneRoleClassifier.pretrained()
            except Exception:
                backbone_clf = None

        def _embed_norm(seq_list: list[bytes]) -> np.ndarray | None:
            if not seq_list or backbone_clf is None or not backbone_clf.is_fitted:
                return None
            emb = backbone_clf.transform(Sequences.from_bytes(seq_list)).astype(np.float32)
            norms = np.linalg.norm(emb, axis=1, keepdims=True)
            norms[norms < 1e-8] = 1.0
            return emb / norms

        predictor = cls(
            pan_prior=pan_prior,
            classifier_suite=suite,
            assigner=assigner,
            disaccharide_counts=disac_counts,
            donor_acc_counts=donor_acc_counts,
            wzy_closure_counts=wzy_closure_counts,
            archetype_init_counts=arch_init_counts,
            donor_vocab=donor_vocab,
            bigram_trans_matrix=bigram_trans,
            ref_gt_embeddings=_embed_norm(gt_seqs),
            ref_gt_dl_labels=np.asarray(gt_dl, dtype=np.int64) if gt_dl else None,
            ref_gt_acc_labels=np.asarray(gt_acc, dtype=np.int64) if gt_acc else None,
            ref_gt_am_labels=np.asarray(gt_am, dtype=np.int64) if gt_am else None,
            ref_gt_bb_labels=np.asarray(gt_bb, dtype=np.int64) if gt_bb else None,
            ref_gt_record_indices=np.asarray(gt_recs, dtype=np.int64) if gt_recs else None,
            ref_init_embeddings=_embed_norm(init_seqs),
            ref_init_sugars=np.asarray(init_sugs, dtype=np.int64) if init_sugs else None,
            ref_init_record_indices=np.asarray(init_recs, dtype=np.int64) if init_recs else None,
            ref_wzy_embeddings=_embed_norm(wzy_seqs),
            ref_wzy_links=np.asarray(wzy_lnks, dtype=np.int64) if wzy_lnks else None,
            ref_wzy_record_indices=np.asarray(wzy_recs, dtype=np.int64) if wzy_lnks else None,
            multi_edge_clusters=multi_edge_clusters,
            locus_fingerprints=locus_fps,
            cluster_am_counts=cluster_am_counts,
            ref_orf_seqs=tuple(ref_orf_seqs),
            ref_orf_cids=np.asarray(ref_orf_cids, dtype=np.int32) if ref_orf_cids else None,
            ref_orf_roles=np.asarray(ref_orf_roles, dtype=np.int32) if ref_orf_roles else None,
            orf_kmer_index=orf_kmer_index,
        )

        # 4. Load packaged RelationalMPNN (when no CV exclusions) or train RelationalMPNN on kept_indices
        if not excluded and not retrain_mpnn and mpnn_path is not None and mpnn_path.is_file():
            try:
                predictor.load_mpnn(mpnn_path)
            except Exception:
                predictor.fit_mpnn(records, kept_indices=kept_indices, epochs=mpnn_epochs)
        elif mpnn_epochs > 0:
            predictor.fit_mpnn(records, kept_indices=kept_indices, epochs=mpnn_epochs)
            if not excluded and mpnn_path is not None and predictor.mpnn is not None:
                try:
                    predictor.save_mpnn(mpnn_path)
                except Exception:
                    pass

        return predictor

    def resolve_locus_homologs(self, locus: Locus) -> Locus:
        """Map raw unclustered `LocusPredictor` ORFs to Pan-Locus `(GeneRole, cluster_id)` via 4-mer indexed `PairwiseAligner`."""
        if (
            len(locus.nodes) == 0
            or len(locus.genes) == 0
            or not self.ref_orf_seqs
            or self.ref_orf_cids is None
            or self.ref_orf_roles is None
        ):
            return locus

        if self._pairwise_aligner is None:
            self._pairwise_aligner = PairwiseAligner(k=35)

        trans = locus.genes.translations
        new_nodes = list(locus.nodes)
        n_ref = len(self.ref_orf_seqs)
        counts = np.zeros(n_ref, dtype=np.int32)

        for g_i, nd in enumerate(locus.nodes):
            if g_i >= len(trans):
                continue
            aa = bytes(trans[g_i].seq)
            if len(aa) < 60:
                continue
            counts.fill(0)
            for pos in range(len(aa) - 3):
                km = aa[pos : pos + 4]
                hit_list = self._orf_kmer_index.get(km)
                if hit_list is not None:
                    counts[hit_list] += 1
            top_cand = np.argsort(counts)[-48:][::-1]
            top_cand = top_cand[counts[top_cand] > 0]
            if len(top_cand) == 0:
                continue
            q_b = Sequences.from_bytes([aa] * len(top_cand))
            t_b = Sequences.from_bytes([self.ref_orf_seqs[int(i)] for i in top_cand])
            alns = self._pairwise_aligner(q_b, t_b)
            bits = alns.bitscores
            pids = alns.pidents
            best_loc_idx = int(np.argmax(bits))
            best_ref_idx = int(top_cand[best_loc_idx])
            best_bit = float(bits[best_loc_idx])
            best_pid = float(pids[best_loc_idx])
            best_cid = int(self.ref_orf_cids[best_ref_idx])
            best_role = GeneRole(int(self.ref_orf_roles[best_ref_idx]))

            cand_roles = self.ref_orf_roles[top_cand]
            same_mask = (cand_roles == int(nd.role)) & (bits >= 95.0) & (pids >= 28.0)

            if best_bit >= 150.0 and best_pid >= 34.0:
                new_nodes[g_i] = GeneNode(best_role, best_cid)
            elif np.any(same_mask):
                idx_s = int(np.argmax(np.where(same_mask, bits, -1.0)))
                ref_s = int(top_cand[idx_s])
                new_nodes[g_i] = GeneNode(nd.role, int(self.ref_orf_cids[ref_s]))

        has_gla = any(n.cluster_id == 240 for n in new_nodes)
        for g_i, nd in enumerate(new_nodes):
            if nd.role == GeneRole.DEACETYLASE and nd.cluster_id == 0 and g_i < len(trans):
                aa_len = len(trans[g_i].seq)
                prev_role = new_nodes[g_i - 1].role if g_i > 0 else GeneRole.UNKNOWN
                if 290 <= aa_len <= 390 and (has_gla or prev_role in (GeneRole.FLIPPASE, GeneRole.POLYMERASE)):
                    new_nodes[g_i] = GeneNode(GeneRole.GLYCOSYLTRANSFERASE, 0)

        return replace(locus, nodes=tuple(new_nodes))

    def _compute_operon_precursor_pool(
        self,
        locus: Locus,
    ) -> tuple[dict[bytes, float], set[int], np.ndarray]:
        """Compute the Stage 2 Operon Precursor Pool (`c_pool`) for `locus`.

        Returns:
            `(pool_by_name, synthesized_specialized_sugars, donor_vocab_gate_multiplier)`.
        """
        pool_probs: dict[int, float] = {}
        for hk_s in HOUSEKEEPING_SUGARS:
            pool_probs[int(hk_s)] = 1.0

        synthesized_special: set[int] = set()
        present_roles = {n.role for n in locus.nodes}
        present_cids = {int(n.cluster_id) for n in locus.nodes if n.cluster_id > 0}
        has_rml_pathway = bool(present_cids & {230, 231, 323})
        role_pathway_rules: tuple[tuple[frozenset[GeneRole], tuple[bytes, ...]], ...] = (
            (
                frozenset({GeneRole.EPIMERASE, GeneRole.OXIDOREDUCTASE, GeneRole.NUCLEOTIDYLTRANSFERASE}),
                (b"Rha",) if has_rml_pathway else (b"Rha", b"Fuc", b"6dTal"),
            ),
            (
                frozenset({GeneRole.EPIMERASE, GeneRole.OXIDOREDUCTASE}),
                (b"Rha",) if has_rml_pathway else (b"Rha", b"Fuc"),
            ),
            (frozenset({GeneRole.AMINOTRANSFERASE}), (b"QuiNAc", b"FucNAc", b"Bac", b"Pse", b"Leg")),
            (frozenset({GeneRole.ALDOLASE}), (b"Neu5Ac", b"Pse", b"Leg", b"Kdo")),
        )
        for req_roles, sug_names in role_pathway_rules:
            if req_roles.issubset(present_roles):
                for s_nm in sug_names:
                    if s_nm in Sugars.name_to_index:
                        s_i = int(Sugars.name_to_index[s_nm])
                        pool_probs[s_i] = 1.0
                        if s_i not in HOUSEKEEPING_SUGARS:
                            synthesized_special.add(s_i)

        if 240 in present_cids and b"GalA" in Sugars.name_to_index:
            gala_i = int(Sugars.name_to_index[b"GalA"])
            pool_probs[gala_i] = 1.0
            synthesized_special.add(gala_i)

        all_sugar_ids = set(range(len(Sugars.names)))
        for n in locus.nodes:
            if n.cluster_id > 0:
                pref_s = self.pan_prior.preferred_precursor_sugar(int(n.cluster_id), all_sugar_ids)
                if pref_s is not None:
                    pool_probs[int(pref_s)] = max(pool_probs.get(int(pref_s), 0.0), 0.95)
                    if int(pref_s) not in HOUSEKEEPING_SUGARS:
                        synthesized_special.add(int(pref_s))

        gate = np.ones(len(self.donor_vocab), dtype=np.float64)
        for idx_d, d_chem in enumerate(self.donor_vocab):
            s_id = int(ResidueType(d_chem).sugar)
            if s_id in synthesized_special:
                gate[idx_d] = 1.45
            elif s_id in HOUSEKEEPING_SUGARS:
                gate[idx_d] = 1.0
            else:
                gate[idx_d] = 0.75

        named_pool = {
            Sugars.names[s_id]: round(float(p_val), 3)
            for s_id, p_val in sorted(pool_probs.items())
            if 0 <= s_id < len(Sugars.names)
        }
        return named_pool, synthesized_special, gate

    def _embed_single_seq(self, aa_seq: bytes) -> np.ndarray | None:
        """Compute L2-normalized 1D-CNN embedding vector for a single ORF protein sequence."""
        if len(aa_seq) < 60 or self.classifier_suite is None:
            return None
        cached = self._embed_cache.get(aa_seq, False)
        if cached is not False:
            return cached
        clf = self.classifier_suite.classifiers.get("donor_linkage")
        if clf is None or not clf.is_fitted:
            self._embed_cache[aa_seq] = None
            return None
        vec = clf.transform(Sequences.from_bytes([aa_seq]))[0].astype(np.float32)
        nrm = float(np.linalg.norm(vec))
        res = vec / max(nrm, 1e-8)
        self._embed_cache[aa_seq] = res
        return res

    def _conv_max_single_seq(self, aa_seq: bytes, *, is_initiator: bool = False) -> np.ndarray:
        """Compute 64-D L2-normalized `task_weighted_conv_max` CNN vector for a single ORF sequence."""
        key = (aa_seq, bool(is_initiator))
        if key in self._conv_max_cache:
            return self._conv_max_cache[key]
        out = np.zeros(64, dtype=np.float32)
        if len(aa_seq) < 60 or self.classifier_suite is None:
            self._conv_max_cache[key] = out
            return out
        target_name = "initiator_sugar" if is_initiator else "donor_linkage"
        clf = self.classifier_suite.classifiers.get(target_name) or self.classifier_suite.classifiers.get(
            "donor_linkage"
        )
        if clf is None or not clf.is_fitted:
            self._conv_max_cache[key] = out
            return out
        try:
            vec = clf.transform(Sequences.from_bytes([aa_seq]), layer="task_weighted_conv_max")[0].astype(np.float32)
            d = min(len(vec), 64)
            nrm = float(np.linalg.norm(vec[:d]))
            out[:d] = vec[:d] / max(nrm, 1e-8)
        except Exception:
            pass
        self._conv_max_cache[key] = out
        return out

    def _clf_proba_single(self, target_name: str, aa_seq: bytes) -> np.ndarray | None:
        """Compute (and memoize) 1D-CNN class probabilities for a single ORF sequence."""
        if len(aa_seq) < 60 or self.classifier_suite is None:
            return None
        key = (target_name, aa_seq)
        cached = self._clf_proba_cache.get(key, False)
        if cached is not False:
            return cached
        clf = self.classifier_suite.classifiers.get(target_name)
        if clf is None or not clf.is_fitted:
            self._clf_proba_cache[key] = None
            return None
        probs = clf.predict_proba([aa_seq])[0]
        self._clf_proba_cache[key] = probs
        return probs

    def _build_mpnn_graph_for_locus(
        self,
        slot_seqs: list[bytes],
        synthesized_special: set[int],
        p_bb_init: np.ndarray,
        clamped_mask_slots: np.ndarray,
    ) -> tuple[np.ndarray, RelationalCSR, np.ndarray]:
        """Construct `(x_nodes, csr_graph, clamped_full)` for `serogen.core.graph.gnn.RelationalMPNN` via `build_multihop_csr`.

        Args:
            slot_seqs: `[init_aa_seq, gt_1_seq, ..., gt_k_seq]` of length `M = 1 + n_gt_slots`.
            synthesized_special: Set of specialized precursor `Sugars` indices synthesized in the operon.
            p_bb_init: `(M,)` initial backbone probability per node (`p_bb_init[0] = 1.0`).
            clamped_mask_slots: `(M,)` uint8 clamping mask (`1 = clamped`, `0 = free`).

        Returns:
            `(x_nodes, csr_graph, clamped_full)` where `x_nodes` has shape `(M + 1, 80)` and node `M` is the
            virtual Operon Precursor Pool node (`add_virtual_pool=True`).
        """
        m_nodes = len(slot_seqs)
        x_nodes = np.zeros((m_nodes + 1, 80), dtype=np.float32)

        pool_bits = np.zeros(10, dtype=np.float32)
        for idx_p, p_name in enumerate(PRECURSOR_POOL_SUGAR_NAMES):
            s_id = Sugars.name_to_index.get(p_name, -1)
            if s_id >= 0 and (s_id in synthesized_special or s_id in HOUSEKEEPING_SUGARS):
                pool_bits[idx_p] = 1.0 if s_id in synthesized_special else 0.35

        for u in range(m_nodes):
            aa = slot_seqs[u]
            x_nodes[u, :64] = self._conv_max_single_seq(aa, is_initiator=(u == 0))
            x_nodes[u, 64] = 1.0 if u == 0 else 0.0
            x_nodes[u, 65] = 0.0
            x_nodes[u, 66] = float(u) / float(max(1, m_nodes - 1))
            x_nodes[u, 67] = min(float(len(aa)) / 500.0, 2.0)
            x_nodes[u, 68] = float(p_bb_init[u]) if u < len(p_bb_init) else 0.80
            x_nodes[u, 69] = float(clamped_mask_slots[u]) if u < len(clamped_mask_slots) else 0.0
            x_nodes[u, 70:80] = pool_bits

        # Virtual Operon Precursor Pool node at index m_nodes
        x_nodes[m_nodes, 65] = 1.0
        x_nodes[m_nodes, 70:80] = pool_bits

        if m_nodes >= 2:
            edge_src = np.arange(0, m_nodes - 1, dtype=np.int32)
            edge_dst = np.arange(1, m_nodes, dtype=np.int32)
            edge_orient = np.zeros(m_nodes - 1, dtype=np.int32)
        else:
            edge_src = np.empty(0, dtype=np.int32)
            edge_dst = np.empty(0, dtype=np.int32)
            edge_orient = np.empty(0, dtype=np.int32)

        csr_graph = build_multihop_csr(
            np.array([0, m_nodes], dtype=np.int32),
            edge_src,
            edge_dst,
            edge_orient,
            include_2hop=self.mpnn_include_2hop,
            add_virtual_pool=True,
        )
        clamped_full = np.zeros(m_nodes + 1, dtype=bool)
        for u in range(min(m_nodes, len(clamped_mask_slots))):
            clamped_full[u] = bool(clamped_mask_slots[u] != 0)
        return x_nodes, csr_graph, clamped_full

    def fit_mpnn(
        self,
        records: GlycanRecords,
        *,
        kept_indices: Sequence[int] | None = None,
        epochs: int = 18,
        hidden_dim: int = 64,
        num_layers: int = 3,
        bilinear_rank: int = 32,
        learning_rate: float = 0.004,
        weight_decay: float = 1e-4,
        clamped_prob: float = 0.25,
        include_2hop: bool = True,
        random_state: int = 42,
    ) -> RelationalMPNN | None:
        """Train `serogen.core.graph.gnn.RelationalMPNN` on aligned `(Locus, Glycan)` arborescences."""
        self.mpnn_include_2hop = bool(include_2hop)
        indices = list(kept_indices) if kept_indices is not None else list(range(len(records)))
        dl_set: set[int] = set()
        if self.ref_gt_dl_labels is not None and len(self.ref_gt_dl_labels) > 0:
            dl_set.update(int(x) for x in np.unique(self.ref_gt_dl_labels))
        if (
            self.classifier_suite is not None
            and "donor_linkage" in self.classifier_suite.classifiers
            and self.classifier_suite["donor_linkage"].is_fitted
        ):
            dl_set.update(int(x) for x in self.classifier_suite["donor_linkage"].classes_)
        for i in indices:
            gly = records.glycans[i]
            for a in records.assignments[i]:
                if (
                    a.is_aligned
                    and a.role in (GeneRole.GLYCOSYLTRANSFERASE, GeneRole.TRANSMEMBRANE_SYNTHASE)
                    and a.edge_index is not None
                    and 0 <= a.edge_index < len(gly.edges)
                ):
                    e_a = gly.edges[int(a.edge_index)]
                    s_a = gly.nodes[e_a.source]
                    if not s_a.is_modifier and e_a.edge_type.anomer != Anomer.UNKNOWN and e_a.edge_type.acceptor > 0:
                        dl_set.add(encode_donor_linkage_label(s_a, e_a.edge_type))
        if len(dl_set) < 2:
            return None

        dl_classes = np.array(sorted(dl_set), dtype=np.int64)
        dl_to_idx = {int(c): idx for idx, c in enumerate(dl_classes)}

        train_graphs: list[tuple[np.ndarray, RelationalCSR, np.ndarray, np.ndarray, np.ndarray, np.ndarray]] = []
        for i in indices:
            loc = records.loci[i]
            gly = records.glycans[i]
            if len(loc.nodes) == 0 or len(gly.nodes) < 2:
                continue
            assigns = records.assignments[i]
            init_g_idx = -1
            for g_i, g_n in enumerate(loc.nodes):
                if g_n.role == GeneRole.INITIATOR:
                    init_g_idx = g_i
                    break
            gt_g_indices = [
                g_i
                for g_i, g_n in enumerate(loc.nodes)
                if g_n.role in (GeneRole.GLYCOSYLTRANSFERASE, GeneRole.TRANSMEMBRANE_SYNTHASE)
            ]
            if not gt_g_indices:
                continue

            by_gene_edge: dict[int, GeneGlycanAssignment] = {}
            for a in assigns:
                if (
                    a.is_aligned
                    and a.gene_index in gt_g_indices
                    and a.edge_index is not None
                    and 0 <= a.edge_index < len(gly.edges)
                ):
                    by_gene_edge[int(a.gene_index)] = a
            if len(by_gene_edge) < len(gt_g_indices):
                continue
            core_gly = gly.without_modifiers()
            if len(core_gly.nodes) != 1 + len(gt_g_indices):
                continue
            gt_sources = {int(gly.edges[int(a.edge_index or 0)].source) for a in by_gene_edge.values()}
            if len(gt_sources) != len(gt_g_indices) or gly.reducing_end_index in gt_sources:
                continue

            has_genes = len(loc.genes) > 0
            translations = loc.genes.translations if has_genes else ()
            init_aa = (
                bytes(translations[init_g_idx].seq) if (has_genes and 0 <= init_g_idx < len(translations)) else b""
            )
            slot_seqs: list[bytes] = [init_aa]
            for g_i in gt_g_indices:
                aa_g = bytes(translations[g_i].seq) if (has_genes and g_i < len(translations)) else b""
                slot_seqs.append(aa_g)

            m_nodes = len(slot_seqs)
            node_to_slot: dict[int, int] = {gly.reducing_end_index: 0}
            node_targets = np.full(m_nodes + 1, -1, dtype=np.int32)
            node_weights = np.ones(m_nodes + 1, dtype=np.float32)
            p_bb_init = np.ones(m_nodes, dtype=np.float32)

            for s_idx, g_i in enumerate(gt_g_indices, start=1):
                a_g = by_gene_edge[g_i]
                e_g = gly.edges[int(a_g.edge_index or 0)]
                node_to_slot[int(e_g.source)] = s_idx
                p_bb_init[s_idx] = 1.0 if e_g.edge_type.is_backbone else 0.1
                s_res = gly.nodes[e_g.source]
                if not s_res.is_modifier and e_g.edge_type.anomer != Anomer.UNKNOWN and e_g.edge_type.acceptor > 0:
                    dl_code = encode_donor_linkage_label(s_res, e_g.edge_type)
                    if dl_code in dl_to_idx:
                        node_targets[s_idx] = dl_to_idx[dl_code]
                w_ev = (
                    1.50
                    if (a_g.evidence == AssignmentEvidence.PAN_GRAPH_CONSENSUS and a_g.score >= 0.93)
                    else (1.25 if a_g.evidence == AssignmentEvidence.PAN_GRAPH_CONSENSUS else 0.85)
                )
                node_weights[s_idx] = float(w_ev * float(a_g.score))

            parent_targets = np.full(m_nodes, -1, dtype=np.int32)
            slot_targets = np.zeros(m_nodes, dtype=np.int32)
            valid_edges = 0
            for s_idx, g_i in enumerate(gt_g_indices, start=1):
                a_g = by_gene_edge[g_i]
                e_g = gly.edges[int(a_g.edge_index or 0)]
                t_slot = node_to_slot.get(int(e_g.target), -1)
                if 0 <= t_slot < m_nodes and t_slot != s_idx:
                    parent_targets[s_idx] = t_slot
                    slot_targets[s_idx] = min(max(int(e_g.edge_type.acceptor), 0), 7)
                    valid_edges += 1
            if valid_edges == 0:
                continue

            _, synth_spec, _ = self._compute_operon_precursor_pool(loc)
            x_nodes, csr_graph, _ = self._build_mpnn_graph_for_locus(
                slot_seqs,
                synth_spec,
                p_bb_init,
                np.zeros(m_nodes, dtype=np.uint8),
            )
            train_graphs.append((x_nodes, csr_graph, node_targets, node_weights, parent_targets, slot_targets))

        if not train_graphs:
            return None

        mpnn = RelationalMPNN(
            in_dim=80,
            hidden_dim=hidden_dim,
            num_node_classes=len(dl_classes),
            num_edge_channels=8,
            num_relations=8,
            num_layers=num_layers,
            bilinear_rank=bilinear_rank,
            learning_rate=learning_rate,
            weight_decay=weight_decay,
            random_state=random_state,
        )
        rng = np.random.default_rng(random_state)
        n_g = len(train_graphs)
        order = np.arange(n_g, dtype=np.int32)
        for ep in range(max(1, epochs)):
            rng.shuffle(order)
            lr_s = float(0.5 * (1.0 + np.cos(np.pi * ep / max(1, epochs))))
            for idx_g in order:
                x_n, csr_g, y_n, w_n, p_t, s_t = train_graphs[int(idx_g)]
                m_sub = len(p_t)
                clamp_m: np.ndarray | None = None
                if m_sub >= 3 and clamped_prob > 0.0 and float(rng.random()) < clamped_prob:
                    clamp_m = np.zeros(len(x_n), dtype=bool)
                    free_u = int(rng.integers(1, m_sub))
                    clamp_m[:m_sub] = True
                    clamp_m[free_u] = False
                mpnn.fit_step(
                    x_n,
                    csr_g,
                    node_targets=y_n,
                    node_weights=w_n,
                    edge_node_indices=np.arange(m_sub, dtype=np.int32),
                    edge_parent_targets=p_t,
                    edge_slot_targets=s_t,
                    clamped_mask=clamp_m,
                    lr_scale=max(0.15, lr_s),
                )

        self.mpnn = mpnn
        self.mpnn_dl_classes = dl_classes
        return mpnn

    def save_mpnn(self, path: str | Path = DEFAULT_MPNN_PATH) -> None:
        """Save the fitted `RelationalMPNN`, `mpnn_dl_classes`, and `GlycanPredictorParams` to a zero-pickle `.npz` archive."""
        if self.mpnn is None or self.mpnn_dl_classes is None:
            return
        out_path = Path(path)
        self.mpnn.save(out_path)
        with np.load(out_path, allow_pickle=False) as data:
            arrays = {k: data[k] for k in data.files}
        arrays["mpnn_dl_classes"] = np.asarray(self.mpnn_dl_classes, dtype=np.int64)
        arrays["arborescence_params"] = self.params.to_array()
        arrays["mpnn_include_2hop"] = np.array([1 if self.mpnn_include_2hop else 0], dtype=np.int8)
        np.savez_compressed(out_path, **arrays)

    def load_mpnn(self, path: str | Path = DEFAULT_MPNN_PATH) -> RelationalMPNN:
        """Load a fitted `RelationalMPNN`, `mpnn_dl_classes`, and `GlycanPredictorParams` from a zero-pickle `.npz` archive."""
        in_path = Path(path)
        mpnn = RelationalMPNN.load(in_path)
        with np.load(in_path, allow_pickle=False) as data:
            dl_classes = data["mpnn_dl_classes"].astype(np.int64) if "mpnn_dl_classes" in data else None
            if "arborescence_params" in data:
                self.params = GlycanPredictorParams.from_array(data["arborescence_params"])
            if "mpnn_include_2hop" in data:
                self.mpnn_include_2hop = bool(int(data["mpnn_include_2hop"][0]) != 0)
        self.mpnn = mpnn
        self.mpnn_dl_classes = dl_classes
        return mpnn

    def _predict_root_candidates(
        self,
        locus: Locus,
        archetype: LocusArchetype,
        clamped_by_gene: dict[int, GeneGlycanAssignment],
    ) -> tuple[int, list[tuple[ResidueType, float, AssignmentEvidence]]]:
        """Stage 1/3: Predict `(init_gene_idx, [(root_residue, prob, evidence), ...])`."""
        init_gene_idx = -1
        for idx_g, g_node in enumerate(locus.nodes):
            if g_node.role == GeneRole.INITIATOR:
                init_gene_idx = idx_g
                break

        # 1. Check hard clamping on the initiator gene
        if init_gene_idx in clamped_by_gene:
            c_assign = clamped_by_gene[init_gene_idx]
            if c_assign.residue is not None and not c_assign.residue.is_modifier:
                r_res = c_assign.residue.with_reducing_end(True).with_backbone(True)
                return init_gene_idx, [(r_res, 0.99, AssignmentEvidence.PAN_GRAPH_CONSENSUS)]

        scores: dict[int, float] = {}
        ev_map: dict[int, AssignmentEvidence] = {}

        if init_gene_idx >= 0:
            g_node = locus.nodes[init_gene_idx]
            cid = int(g_node.cluster_id)
            for c_tier, tier_scale in ((cid, 1.0), (self.pan_prior.fine_to_med.get(cid, 0), 0.72)):
                if c_tier > 0 and c_tier in self.pan_prior.init_cluster_sugar:
                    s_counts = self.pan_prior.init_cluster_sugar[c_tier]
                    tot = sum(s_counts.values())
                    for s_id, cnt in s_counts.items():
                        nat_chi = Chirality(Sugars.natural_chirality[s_id])
                        r_chem = encode_donor_residue_chem(ResidueType.from_sugar(s_id, nat_chi, False))
                        p = cnt / max(1e-6, tot)
                        scores[r_chem] = scores.get(r_chem, 0.0) + 2.5 * tier_scale * p
                        if cnt >= 1.5 and p >= 0.55:
                            ev_map[r_chem] = AssignmentEvidence.PAN_GRAPH_CONSENSUS

            aa_seq = (
                bytes(locus.genes.translations[init_gene_idx].seq)
                if (len(locus.genes) > 0 and init_gene_idx < len(locus.genes.translations))
                else b""
            )
            if (
                len(aa_seq) >= 60
                and self.classifier_suite is not None
                and "initiator_sugar" in self.classifier_suite.classifiers
            ):
                init_clf = self.classifier_suite["initiator_sugar"]
                probs = self._clf_proba_single("initiator_sugar", aa_seq)
                if probs is not None:
                    for c_i, s_id in enumerate(init_clf.classes_):
                        p_val = float(probs[c_i])
                        if p_val >= 0.05:
                            nat_chi = Chirality(Sugars.natural_chirality[int(s_id)])
                            r_chem = encode_donor_residue_chem(ResidueType.from_sugar(int(s_id), nat_chi, False))
                            scores[r_chem] = scores.get(r_chem, 0.0) + 1.2 * p_val
                            if p_val >= 0.60:
                                ev_map.setdefault(r_chem, AssignmentEvidence.PAN_GRAPH_CONSENSUS)

            if len(aa_seq) >= 60 and self.ref_init_embeddings is not None and self.ref_init_sugars is not None:
                q_vec = self._embed_single_seq(aa_seq)
                if q_vec is not None:
                    sims = self.ref_init_embeddings @ q_vec
                    top_k = np.argsort(sims)[-5:][::-1]
                    for idx_nn in top_k:
                        sim_val = float(sims[idx_nn])
                        if sim_val >= 0.80:
                            r_chem = int(self.ref_init_sugars[idx_nn])
                            scores[r_chem] = scores.get(r_chem, 0.0) + 1.4 * (sim_val**2)
                            if sim_val >= 0.92:
                                ev_map.setdefault(r_chem, AssignmentEvidence.PAN_GRAPH_CONSENSUS)

        # Archetype fallback prior
        arch_int = int(archetype)
        arch_tot = sum(cnt for (a_k, _), cnt in self.archetype_init_counts.items() if a_k == arch_int)
        if arch_tot > 0:
            for (a_k, r_chem), cnt in self.archetype_init_counts.items():
                if a_k == arch_int:
                    scores[r_chem] = scores.get(r_chem, 0.0) + 0.35 * (cnt / arch_tot)

        if not scores:
            gal_idx = Sugars.name_to_index[b"Gal"]
            glc_idx = Sugars.name_to_index[b"Glc"]
            glcnac_idx = Sugars.name_to_index[b"GlcNAc"]
            for s_id, w_def in ((gal_idx, 0.45), (glc_idx, 0.35), (glcnac_idx, 0.20)):
                r_chem = encode_donor_residue_chem(ResidueType.from_sugar(s_id, Chirality.D, False))
                scores[r_chem] = w_def

        ranked = sorted(scores.items(), key=lambda item: item[1], reverse=True)[:3]
        tot_s = sum(v for _, v in ranked) or 1.0
        out: list[tuple[ResidueType, float, AssignmentEvidence]] = []
        for r_chem, s_val in ranked:
            res = ResidueType(r_chem).with_reducing_end(True).with_backbone(True)
            ev = ev_map.get(
                r_chem, AssignmentEvidence.GRAPH_ALIGNED if init_gene_idx >= 0 else AssignmentEvidence.RULE_INFERRED
            )
            out.append((res, float(min(0.99, max(0.35, s_val / tot_s))), ev))
        return init_gene_idx, out

    def _build_gt_slots(
        self,
        locus: Locus,
        synthesized_special: set[int],
        precursor_gate: np.ndarray,
        clamped_by_gene: dict[int, GeneGlycanAssignment],
        reference_glycan: Glycan | None = None,
    ) -> list[_GTSlotSpec]:
        """Stage 1-3: Construct contextualized GT transfer step slots via SeroGen `RelationalMPNN`."""
        self._last_mpnn_edge_scores: np.ndarray | None = None
        gt_gene_indices: list[int] = []
        for idx_g, g_node in enumerate(locus.nodes):
            if g_node.role in (GeneRole.GLYCOSYLTRANSFERASE, GeneRole.TRANSMEMBRANE_SYNTHASE):
                gt_gene_indices.append(idx_g)

        if not gt_gene_indices:
            return []

        n_gts = len(gt_gene_indices)
        n_donors = len(self.donor_vocab)
        node_donor_probs = np.full((n_gts, max(1, n_donors)), 1.0 / max(1, n_donors), dtype=np.float64)
        node_acc_probs = np.full((n_gts, max(1, n_donors)), 1.0 / max(1, n_donors), dtype=np.float64)
        node_bb_probs = np.full(n_gts, 0.78, dtype=np.float64)
        clamped_mask = np.zeros(n_gts, dtype=np.uint8)

        per_gt_dl_scores: list[dict[int, float]] = [{} for _ in range(n_gts)]
        per_gt_am_scores: list[dict[int, float]] = [{} for _ in range(n_gts)]
        per_gt_ev: list[dict[int, AssignmentEvidence]] = [{} for _ in range(n_gts)]

        has_genes_loc = len(locus.genes) > 0
        translations_loc = locus.genes.translations if has_genes_loc else ()

        for slot_i, g_idx in enumerate(gt_gene_indices):
            g_node = locus.nodes[g_idx]
            cid = int(g_node.cluster_id)
            aa_seq = bytes(translations_loc[g_idx].seq) if (has_genes_loc and g_idx < len(translations_loc)) else b""

            # Check hard/soft clamping
            if g_idx in clamped_by_gene:
                c_a = clamped_by_gene[g_idx]
                if (
                    reference_glycan is not None
                    and c_a.edge_index is not None
                    and 0 <= c_a.edge_index < len(reference_glycan.edges)
                ):
                    e_ref = reference_glycan.edges[c_a.edge_index]
                    s_ref = reference_glycan.nodes[e_ref.source]
                    t_ref = reference_glycan.nodes[e_ref.target]
                    dl_lbl = encode_donor_linkage_label(s_ref, e_ref.edge_type)
                    per_gt_dl_scores[slot_i][dl_lbl] = 10.0
                    per_gt_ev[slot_i][dl_lbl] = AssignmentEvidence.PAN_GRAPH_CONSENSUS
                    am_ref = extract_acceptor_motif_for_edge(reference_glycan, int(c_a.edge_index))
                    per_gt_am_scores[slot_i][am_ref] = 10.0
                    d_chem = encode_donor_residue_chem(s_ref)
                    a_chem = encode_donor_residue_chem(t_ref)
                    if d_chem in self.donor_to_idx:
                        node_donor_probs[slot_i, :] = 1e-4
                        node_donor_probs[slot_i, self.donor_to_idx[d_chem]] = 1.0
                    if a_chem in self.donor_to_idx:
                        node_acc_probs[slot_i, :] = 1e-4
                        node_acc_probs[slot_i, self.donor_to_idx[a_chem]] = 1.0
                    node_bb_probs[slot_i] = 0.98 if e_ref.edge_type.is_backbone else 0.02
                    clamped_mask[slot_i] = 1
                    continue

            d_dist: dict[int, float] = {}
            l_dist: dict[int, float] = {}
            acc_dist: dict[int, float] = {}
            fine_emp_bb: float | None = None

            # 1. Empirical PanGraphConsensusPrior (fine 28% + medium 20% superfamily clusters)
            for c_tier, tier_scale in ((cid, 1.0), (self.pan_prior.fine_to_med.get(cid, 0), 0.72)):
                if c_tier > 0 and c_tier in self.pan_prior.cluster_sig_full:
                    sig_counts = self.pan_prior.cluster_sig_full[c_tier]
                    tot_sig = sum(sig_counts.values())
                    dl_counts_tier = self.pan_prior.cluster_sig_dl.get(c_tier, {})
                    tot_dl_tier = sum(dl_counts_tier.values()) or tot_sig
                    for sig, cnt in sig_counts.items():
                        s_id, d_chi, is_fur, anom_val, don_val, acc_val, a_s_id, _is_bb_sig = (
                            sig[0],
                            sig[1],
                            sig[2],
                            sig[3],
                            sig[4],
                            sig[5],
                            sig[6],
                            sig[7],
                        )
                        res_d = ResidueType.from_sugar(s_id, Chirality(d_chi), bool(is_fur))
                        lnk = LinkageType(Anomer(anom_val), don_val, acc_val)
                        if lnk.anomer == Anomer.UNKNOWN or lnk.acceptor == 0:
                            continue
                        dl_lbl = encode_donor_linkage_label(res_d, lnk)
                        p_emp = cnt / max(1e-6, tot_sig)
                        dl_sig_key = (s_id, d_chi, is_fur, anom_val, acc_val)
                        p_dl_emp = dl_counts_tier.get(dl_sig_key, cnt) / max(1e-6, tot_dl_tier)
                        unanimous_boost = 3.00 if (c_tier == cid and p_dl_emp >= 0.80 and tot_dl_tier >= 1.8) else 1.0
                        per_gt_dl_scores[slot_i][dl_lbl] = (
                            per_gt_dl_scores[slot_i].get(dl_lbl, 0.0)
                            + self.params.w_pan_dl * tier_scale * unanimous_boost * p_emp
                        )
                        if cnt >= 1.5 and p_emp >= 0.40:
                            per_gt_ev[slot_i][dl_lbl] = AssignmentEvidence.PAN_GRAPH_CONSENSUS
                        d_c = encode_donor_residue_chem(res_d)
                        d_dist[d_c] = (
                            d_dist.get(d_c, 0.0) + (0.78 * self.params.w_pan_dl) * tier_scale * unanimous_boost * p_emp
                        )
                        a_nat_chi = (
                            Chirality(Sugars.natural_chirality[a_s_id])
                            if 0 <= a_s_id < len(Sugars.names)
                            else Chirality.D
                        )
                        a_c = encode_donor_residue_chem(ResidueType.from_sugar(a_s_id, a_nat_chi, False))
                        acc_dist[a_c] = (
                            acc_dist.get(a_c, 0.0)
                            + (0.78 * self.params.w_pan_dl) * tier_scale * unanimous_boost * p_emp
                        )
                    emp_bb = sum(cnt * float(sig[7]) for sig, cnt in sig_counts.items()) / max(1e-6, tot_sig)
                    if c_tier == cid:
                        fine_emp_bb = emp_bb
                    node_bb_probs[slot_i] = 0.15 * node_bb_probs[slot_i] + 0.85 * emp_bb
                if c_tier > 0 and c_tier in self.cluster_am_counts:
                    am_counts_c = self.cluster_am_counts[c_tier]
                    tot_am_c = sum(am_counts_c.values())
                    for am_code, cnt_am in am_counts_c.items():
                        per_gt_am_scores[slot_i][am_code] = per_gt_am_scores[slot_i].get(am_code, 0.0) + (
                            1.09 * self.params.w_pan_dl
                        ) * tier_scale * (cnt_am / max(1e-6, tot_am_c))

            # 2. 192-D 1D-CNN Sequence Embedding k-NN Retrieval (works even when cluster_id == 0 or held-out!)
            if len(aa_seq) >= 70 and self.ref_gt_embeddings is not None and self.ref_gt_dl_labels is not None:
                q_vec = self._embed_single_seq(aa_seq)
                if q_vec is not None:
                    sims = self.ref_gt_embeddings @ q_vec
                    top_k = np.argsort(sims)[-7:][::-1]
                    for idx_nn in top_k:
                        sim_val = float(sims[idx_nn])
                        if sim_val < 0.78:
                            continue
                        w_nn = self.params.w_knn_gt * (sim_val**3)
                        dl_nn = int(self.ref_gt_dl_labels[idx_nn])
                        per_gt_dl_scores[slot_i][dl_nn] = per_gt_dl_scores[slot_i].get(dl_nn, 0.0) + w_nn
                        if sim_val >= 0.90:
                            per_gt_ev[slot_i].setdefault(dl_nn, AssignmentEvidence.PAN_GRAPH_CONSENSUS)
                        res_nn, _ = decode_donor_linkage_label(dl_nn)
                        d_c = encode_donor_residue_chem(res_nn)
                        d_dist[d_c] = d_dist.get(d_c, 0.0) + w_nn
                        if self.ref_gt_acc_labels is not None:
                            a_c = int(self.ref_gt_acc_labels[idx_nn])
                            acc_dist[a_c] = acc_dist.get(a_c, 0.0) + w_nn
                        if self.ref_gt_am_labels is not None:
                            am_c = int(self.ref_gt_am_labels[idx_nn])
                            per_gt_am_scores[slot_i][am_c] = per_gt_am_scores[slot_i].get(am_c, 0.0) + w_nn
                        if self.ref_gt_bb_labels is not None:
                            node_bb_probs[slot_i] = 0.75 * node_bb_probs[slot_i] + 0.25 * float(
                                self.ref_gt_bb_labels[idx_nn]
                            )

            # 3. 10-Head BiosyntheticClassifierSuite Neural Potentials
            if len(aa_seq) >= 60 and self.classifier_suite is not None and self.classifier_suite.is_fitted:
                for t_name, target_dict, weight_t in (
                    ("donor_linkage", per_gt_dl_scores[slot_i], self.params.w_clf_dl),
                    ("donor", d_dist, self.params.w_clf_factorized),
                    ("linkage", l_dist, self.params.w_clf_factorized),
                    ("acceptor_sugar", acc_dist, self.params.w_clf_factorized),
                    ("acceptor_motif", per_gt_am_scores[slot_i], 1.04 * self.params.w_clf_factorized),
                ):
                    clf = self.classifier_suite.classifiers.get(t_name)
                    probs = self._clf_proba_single(t_name, aa_seq)
                    if clf is not None and probs is not None:
                        for c_i, cls_val in enumerate(clf.classes_):
                            p_v = float(probs[c_i])
                            if p_v >= 0.03:
                                target_dict[int(cls_val)] = target_dict.get(int(cls_val), 0.0) + weight_t * p_v
                                if t_name == "donor_linkage" and p_v >= 0.50:
                                    per_gt_ev[slot_i].setdefault(int(cls_val), AssignmentEvidence.PAN_GRAPH_CONSENSUS)

                bb_clf = self.classifier_suite.classifiers.get("is_backbone")
                bb_probs = self._clf_proba_single("is_backbone", aa_seq)
                if bb_clf is not None and bb_probs is not None:
                    for c_i, cls_val in enumerate(bb_clf.classes_):
                        if int(cls_val) == 1:
                            node_bb_probs[slot_i] = 0.55 * node_bb_probs[slot_i] + 0.45 * float(bb_probs[c_i])

                if fine_emp_bb is not None:
                    node_bb_probs[slot_i] = 0.82 * fine_emp_bb + 0.18 * node_bb_probs[slot_i]

                # Combine factorized donor x linkage into donor_linkage candidates
                if d_dist and l_dist:
                    top_d = sorted(d_dist.items(), key=lambda x: x[1], reverse=True)[:4]
                    top_l = sorted(l_dist.items(), key=lambda x: x[1], reverse=True)[:4]
                    for d_c, d_w in top_d:
                        for l_c, l_w in top_l:
                            dl_comb = encode_donor_linkage_label(ResidueType(d_c), LinkageType(l_c))
                            per_gt_dl_scores[slot_i][dl_comb] = per_gt_dl_scores[slot_i].get(dl_comb, 0.0) + 0.45 * min(
                                d_w, l_w
                            )

                # For unclustered GTs (cid == 0), reconcile unaccounted operon precursor sugars with linkage anomer
                if cid == 0 and synthesized_special and per_gt_dl_scores[slot_i] and l_dist:
                    covered_sugars: set[int] = set()
                    for other_g in gt_gene_indices:
                        other_cid = int(locus.nodes[other_g].cluster_id)
                        if other_cid > 0 and other_cid in self.pan_prior.cluster_sig_full:
                            for sig_o in self.pan_prior.cluster_sig_full[other_cid]:
                                covered_sugars.add(int(sig_o[0]))
                    unaccounted_special = synthesized_special - covered_sugars
                    if unaccounted_special:
                        tot_l_w = sum(l_dist.values()) or 1.0
                        p_beta_l = (
                            sum(w_l for l_c, w_l in l_dist.items() if LinkageType(l_c).anomer == Anomer.BETA) / tot_l_w
                        )
                        p_alpha_l = (
                            sum(w_l for l_c, w_l in l_dist.items() if LinkageType(l_c).anomer == Anomer.ALPHA) / tot_l_w
                        )
                        pref_anom = (
                            Anomer.BETA if p_beta_l >= 0.65 else (Anomer.ALPHA if p_alpha_l >= 0.65 else Anomer.UNKNOWN)
                        )
                        for dl_k, dl_sc in list(per_gt_dl_scores[slot_i].items()):
                            r_k, lnk_k = decode_donor_linkage_label(dl_k)
                            if int(r_k.sugar) in unaccounted_special:
                                use_lnk = (
                                    LinkageType(pref_anom, lnk_k.donor, lnk_k.acceptor)
                                    if pref_anom != Anomer.UNKNOWN
                                    else lnk_k
                                )
                                dl_new = encode_donor_linkage_label(r_k, use_lnk)
                                max_cur = max(per_gt_dl_scores[slot_i].values(), default=1.0)
                                per_gt_dl_scores[slot_i][dl_new] = max_cur + 3.0 * float(dl_sc)
                                per_gt_ev[slot_i][dl_new] = AssignmentEvidence.PAN_GRAPH_CONSENSUS
                                d_c_sp = encode_donor_residue_chem(r_k)
                                d_dist[d_c_sp] = max(d_dist.values(), default=1.0) * 2.5
                                if covered_sugars:
                                    for c_sug in covered_sugars:
                                        c_chi = Chirality(Sugars.natural_chirality[c_sug])
                                        a_c_sp = encode_donor_residue_chem(ResidueType.from_sugar(c_sug, c_chi, False))
                                        acc_dist[a_c_sp] = max(acc_dist.values(), default=1.0) * 2.0

            # Populate initial node_donor_probs and node_acc_probs vectors
            if d_dist and n_donors > 0:
                for d_c, val in d_dist.items():
                    if d_c in self.donor_to_idx:
                        node_donor_probs[slot_i, self.donor_to_idx[d_c]] += val
                node_donor_probs[slot_i, :] /= np.sum(node_donor_probs[slot_i, :])
            if acc_dist and n_donors > 0:
                for a_c, val in acc_dist.items():
                    if a_c in self.donor_to_idx:
                        node_acc_probs[slot_i, self.donor_to_idx[a_c]] += val
                node_acc_probs[slot_i, :] /= np.sum(node_acc_probs[slot_i, :])

        # Stage 2: Run SeroGen RelationalMPNN over 1-hop, 2-hop, and Virtual Operon Pool CSR graph
        if self.mpnn is not None and self.mpnn_dl_classes is not None and len(self.mpnn_dl_classes) > 0:
            init_g_idx = next(
                (idx_g for idx_g, g_n in enumerate(locus.nodes) if g_n.role == GeneRole.INITIATOR),
                -1,
            )
            init_aa = (
                bytes(translations_loc[init_g_idx].seq)
                if (has_genes_loc and 0 <= init_g_idx < len(translations_loc))
                else b""
            )
            slot_seqs: list[bytes] = [init_aa]
            for g_idx_s in gt_gene_indices:
                aa_s = (
                    bytes(translations_loc[g_idx_s].seq) if (has_genes_loc and g_idx_s < len(translations_loc)) else b""
                )
                slot_seqs.append(aa_s)
            p_bb_init = np.concatenate(([1.0], node_bb_probs)).astype(np.float32)
            clamped_mask_slots = np.concatenate(([0], clamped_mask)).astype(np.uint8)
            x_nodes, csr_graph, clamped_full = self._build_mpnn_graph_for_locus(
                slot_seqs,
                synthesized_special,
                p_bb_init,
                clamped_mask_slots,
            )
            n_mpnn_nodes = x_nodes.shape[0]
            h_mpnn = self.mpnn.encode(x_nodes, csr_graph, clamped_mask=clamped_full)
            mpnn_dl_probs = self.mpnn.predict_node_proba(x_nodes, csr_graph, clamped_mask=clamped_full)
            self._last_mpnn_edge_scores = self.mpnn.score_edges(h_mpnn)

            for slot_i in range(n_gts):
                if clamped_mask[slot_i] or (slot_i + 1) >= n_mpnn_nodes:
                    continue
                has_consensus_i = bool(per_gt_ev[slot_i])
                w_mpnn_dl = (0.28 * self.params.w_mpnn_dl_free) if has_consensus_i else self.params.w_mpnn_dl_free
                d_msg = np.zeros(max(1, n_donors), dtype=np.float64)
                for v_i, dl_lbl_m_raw in enumerate(self.mpnn_dl_classes):
                    if v_i >= mpnn_dl_probs.shape[1]:
                        break
                    dl_lbl_m = int(dl_lbl_m_raw)
                    p_m = float(mpnn_dl_probs[slot_i + 1, v_i])
                    if p_m >= 0.04:
                        per_gt_dl_scores[slot_i][dl_lbl_m] = (
                            per_gt_dl_scores[slot_i].get(dl_lbl_m, 0.0) + w_mpnn_dl * p_m
                        )
                    res_m, _ = decode_donor_linkage_label(dl_lbl_m)
                    d_c_m = encode_donor_residue_chem(res_m)
                    if d_c_m in self.donor_to_idx:
                        d_msg[self.donor_to_idx[d_c_m]] += p_m
                tot_dm = float(np.sum(d_msg))
                if tot_dm > 1e-9 and n_donors > 0:
                    d_msg /= tot_dm
                    w_dm = min(0.45, max(0.02, 0.34 * w_mpnn_dl))
                    node_donor_probs[slot_i] = (1.0 - w_dm) * node_donor_probs[slot_i] + w_dm * d_msg
                    node_donor_probs[slot_i] /= np.sum(node_donor_probs[slot_i])

        # Propagate 2-layer syntenic donor<->acceptor bigram coupling and precursor gate
        syn_alpha = float(np.clip(self.params.syntenic_msg_alpha, 0.0, 0.50))
        if n_donors > 0 and n_gts >= 1:
            for _layer in range(2):
                new_d = node_donor_probs.copy()
                new_a = node_acc_probs.copy()
                new_bb = node_bb_probs.copy()
                for i_s in range(n_gts):
                    if clamped_mask[i_s]:
                        continue
                    msg_d = np.zeros(n_donors, dtype=np.float64)
                    msg_a = np.zeros(n_donors, dtype=np.float64)
                    bb_ctx = 0.0
                    n_nbrs = 0.0
                    for j_s in range(n_gts):
                        if i_s == j_s:
                            continue
                        dist_ij = abs(i_s - j_s)
                        if dist_ij <= 2:
                            w_syn = 1.0 if dist_ij == 1 else 0.45
                            if clamped_mask[j_s]:
                                w_syn *= 2.5
                            msg_a += w_syn * (self.bigram_trans_matrix.T @ node_donor_probs[j_s])
                            msg_d += w_syn * (self.bigram_trans_matrix @ node_acc_probs[j_s])
                            bb_ctx += w_syn * (1.0 - 0.65 * node_bb_probs[j_s])
                            n_nbrs += w_syn
                    if n_nbrs > 0.0:
                        tot_ma = float(np.sum(msg_a))
                        if tot_ma > 1e-9:
                            new_a[i_s] = (1.0 - syn_alpha) * node_acc_probs[i_s] + syn_alpha * (msg_a / tot_ma)
                        tot_md = float(np.sum(msg_d))
                        if tot_md > 1e-9:
                            d_alpha = 0.83 * syn_alpha
                            new_d[i_s] = (1.0 - d_alpha) * node_donor_probs[i_s] + d_alpha * (msg_d / tot_md)
                        new_bb[i_s] = float(np.clip(0.88 * node_bb_probs[i_s] + 0.12 * (bb_ctx / n_nbrs), 0.05, 0.98))
                    new_d[i_s] = new_d[i_s] * precursor_gate
                    s_d = float(np.sum(new_d[i_s]))
                    if s_d > 1e-9:
                        new_d[i_s] /= s_d
                    s_a = float(np.sum(new_a[i_s]))
                    if s_a > 1e-9:
                        new_a[i_s] /= s_a
                node_donor_probs = new_d
                node_acc_probs = new_a
                node_bb_probs = new_bb

        # Stage 3: Build contextualized _GTSlotSpec objects (including processive & bifunctional GT expansion)
        slots: list[_GTSlotSpec] = []
        for slot_i, g_idx in enumerate(gt_gene_indices):
            g_node = locus.nodes[g_idx]
            cid = int(g_node.cluster_id)
            dl_map = per_gt_dl_scores[slot_i]
            am_raw = per_gt_am_scores[slot_i]
            tot_am = sum(am_raw.values()) or 1.0
            am_map = {k_am: float(v_am / tot_am) for k_am, v_am in am_raw.items() if (v_am / tot_am) >= 0.02}

            # Modulate donor_linkage scores by MPNN-refined node_donor_probs and precursor gate
            refined_dl: list[tuple[ResidueType, LinkageType, float, AssignmentEvidence]] = []
            for dl_lbl, raw_s in dl_map.items():
                res_d, lnk = decode_donor_linkage_label(dl_lbl)
                d_c = encode_donor_residue_chem(res_d)
                mpnn_p = float(node_donor_probs[slot_i, self.donor_to_idx[d_c]]) if d_c in self.donor_to_idx else 0.05
                s_id = int(res_d.sugar)
                prec_mult = 1.25 if s_id in synthesized_special else (1.0 if s_id in HOUSEKEEPING_SUGARS else 0.85)
                final_s = float(raw_s) * prec_mult * (0.82 + 0.35 * mpnn_p)
                ev = per_gt_ev[slot_i].get(dl_lbl, AssignmentEvidence.GRAPH_ALIGNED)
                refined_dl.append((res_d, lnk, final_s, ev))

            refined_dl.sort(key=lambda item: item[2], reverse=True)
            if not refined_dl:
                # Default fallback donor/linkage from operon precursor pool
                pref_s = next(iter(synthesized_special), Sugars.name_to_index[b"Glc"])
                nat_chi = Chirality(Sugars.natural_chirality[pref_s])
                res_def = ResidueType.from_sugar(pref_s, nat_chi, False)
                lnk_def = LinkageType(Anomer.BETA, 1, 3)
                refined_dl.append((res_def, lnk_def, 0.40, AssignmentEvidence.RULE_INFERRED))

            top_cands = refined_dl[:4]
            norm_z = sum(c[2] for c in top_cands) or 1.0
            norm_cands = [
                (
                    c[0],
                    c[1],
                    float(min(0.99, max(0.35, 0.55 + 0.42 * (c[2] / norm_z)))),
                    c[3],
                )
                for c in top_cands
            ]
            acc_map = {
                self.donor_vocab[d_i]: float(node_acc_probs[slot_i, d_i])
                for d_i in range(n_donors)
                if node_acc_probs[slot_i, d_i] >= 0.02
            }
            slots.append(
                _GTSlotSpec(
                    gene_index=g_idx,
                    role=g_node.role,
                    cluster_id=cid,
                    candidates=norm_cands,
                    acceptor_probs=acc_map,
                    p_backbone=float(np.clip(node_bb_probs[slot_i], 0.05, 0.98)),
                    is_clamped=bool(clamped_mask[slot_i]),
                    acceptor_motif_probs=am_map,
                    mpnn_node_idx=slot_i + 1,
                )
            )

            # Processive / Bifunctional GT Multi-Node Expansion (e.g. WbbL/WbbM/WbdA in O3/O5/O12 or CPS_SYNTHASE)
            if (
                reference_glycan is None
                and not clamped_mask[slot_i]
                and cid > 0
                and cid in self.multi_edge_clusters
                and (
                    n_gts <= 2
                    or (
                        n_gts <= 4
                        and len({(int(r_m.sugar), int(l_m.chemical_int)) for r_m, l_m in self.multi_edge_clusters[cid]})
                        == 1
                    )
                )
            ):
                multi_sigs = self.multi_edge_clusters[cid]
                if len(multi_sigs) >= 2:
                    for res_ex, lnk_ex in multi_sigs[1:3]:
                        if lnk_ex.anomer != Anomer.UNKNOWN and lnk_ex.acceptor > 0:
                            slots.append(
                                _GTSlotSpec(
                                    gene_index=g_idx,
                                    role=g_node.role,
                                    cluster_id=cid,
                                    candidates=[(res_ex, lnk_ex, 0.88, AssignmentEvidence.PAN_GRAPH_CONSENSUS)],
                                    acceptor_probs=acc_map,
                                    p_backbone=0.92,
                                    is_clamped=False,
                                    acceptor_motif_probs=am_map,
                                    mpnn_node_idx=slot_i + 1,
                                )
                            )
            elif not clamped_mask[slot_i] and n_gts == 1:
                aa_seq_bi = (
                    bytes(translations_loc[g_idx].seq) if (has_genes_loc and g_idx < len(translations_loc)) else b""
                )
                doms = split_bifunctional_gt_domains(aa_seq_bi, min_len=620, overlap=25)
                if doms is not None and len(norm_cands) >= 2:
                    c_dom = doms[1]
                    c_cands = list(norm_cands[1:3]) + [norm_cands[0]]
                    if self.ref_gt_embeddings is not None and self.ref_gt_dl_labels is not None:
                        q_c = self._embed_single_seq(c_dom)
                        if q_c is not None:
                            sims_c = self.ref_gt_embeddings @ q_c
                            best_nn = int(np.argmax(sims_c))
                            if float(sims_c[best_nn]) >= 0.80:
                                res_c, lnk_c = decode_donor_linkage_label(int(self.ref_gt_dl_labels[best_nn]))
                                c_cands.insert(0, (res_c, lnk_c, 0.86, AssignmentEvidence.PAN_GRAPH_CONSENSUS))
                    slots.append(
                        _GTSlotSpec(
                            gene_index=g_idx,
                            role=g_node.role,
                            cluster_id=cid,
                            candidates=c_cands[:3],
                            acceptor_probs=acc_map,
                            p_backbone=0.90,
                            is_clamped=False,
                            acceptor_motif_probs=am_map,
                            mpnn_node_idx=slot_i + 1,
                        )
                    )

        return slots

    def _predict_wzy_candidates(
        self,
        locus: Locus,
        root_res: ResidueType,
        tip_res: ResidueType,
        used_tip_carbons: set[int],
        clamped_by_gene: dict[int, GeneGlycanAssignment],
        reference_glycan: Glycan | None = None,
    ) -> tuple[int, LinkageType, float, AssignmentEvidence]:
        """Stage 4: Predict the inter-repeat `Wzy` polymer linkage `root_res -> tip_res` (`POLYMER=1`)."""
        wzy_idx = -1
        for idx_g, g_node in enumerate(locus.nodes):
            if g_node.role == GeneRole.POLYMERASE:
                wzy_idx = idx_g
                break

        if wzy_idx in clamped_by_gene and reference_glycan is not None:
            c_a = clamped_by_gene[wzy_idx]
            if c_a.edge_index is not None and 0 <= c_a.edge_index < len(reference_glycan.edges):
                lt_ref = reference_glycan.edges[c_a.edge_index].edge_type.with_topology(
                    is_backbone=True, is_polymer=True
                )
                return wzy_idx, lt_ref, 0.99, AssignmentEvidence.PAN_GRAPH_CONSENSUS

        scores: dict[int, float] = {}
        ev_map: dict[int, AssignmentEvidence] = {}

        if wzy_idx >= 0:
            cid = int(locus.nodes[wzy_idx].cluster_id)
            for c_tier, tier_scale in ((cid, 1.0), (self.pan_prior.fine_to_med.get(cid, 0), 0.72)):
                if c_tier > 0 and c_tier in self.pan_prior.cluster_sig_full:
                    w_counts = self.pan_prior.cluster_sig_full[c_tier]
                    tot_w = sum(w_counts.values())
                    for sig, cnt in w_counts.items():
                        anom_val, don_val, acc_val = sig[3], sig[4], sig[5]
                        if Anomer(anom_val) == Anomer.UNKNOWN or acc_val == 0:
                            continue
                        l_chem = int(LinkageType(Anomer(anom_val), don_val, acc_val).chemical_int)
                        p_w = cnt / max(1e-6, tot_w)
                        scores[l_chem] = scores.get(l_chem, 0.0) + 3.0 * tier_scale * p_w
                        if cnt >= 1.5 and p_w >= 0.45:
                            ev_map[l_chem] = AssignmentEvidence.PAN_GRAPH_CONSENSUS

            aa_seq = (
                bytes(locus.genes.translations[wzy_idx].seq)
                if (len(locus.genes) > 0 and wzy_idx < len(locus.genes.translations))
                else b""
            )
            if (
                len(aa_seq) >= 60
                and self.classifier_suite is not None
                and "wzy_linkage" in self.classifier_suite.classifiers
            ):
                w_clf = self.classifier_suite["wzy_linkage"]
                probs = self._clf_proba_single("wzy_linkage", aa_seq)
                if probs is not None:
                    for c_i, l_val in enumerate(w_clf.classes_):
                        p_v = float(probs[c_i])
                        if p_v >= 0.05:
                            scores[int(l_val)] = scores.get(int(l_val), 0.0) + 1.4 * p_v
                            if p_v >= 0.55:
                                ev_map.setdefault(int(l_val), AssignmentEvidence.PAN_GRAPH_CONSENSUS)

            if len(aa_seq) >= 70 and self.ref_wzy_embeddings is not None and self.ref_wzy_links is not None:
                q_vec = self._embed_single_seq(aa_seq)
                if q_vec is not None:
                    sims = self.ref_wzy_embeddings @ q_vec
                    top_k = np.argsort(sims)[-5:][::-1]
                    for idx_nn in top_k:
                        sim_val = float(sims[idx_nn])
                        if sim_val >= 0.80:
                            l_nn = int(self.ref_wzy_links[idx_nn])
                            scores[l_nn] = scores.get(l_nn, 0.0) + 1.8 * (sim_val**2)
                            if sim_val >= 0.90:
                                ev_map.setdefault(l_nn, AssignmentEvidence.PAN_GRAPH_CONSENSUS)

        # Pan-Glycan empirical polymer closure prior P(root_res -l-> tip_res)
        r_chem = encode_donor_residue_chem(root_res)
        t_chem = encode_donor_residue_chem(tip_res)
        for (s_c, l_c, a_c), cnt in self.wzy_closure_counts.items():
            if s_c == r_chem and a_c == t_chem:
                scores[l_c] = scores.get(l_c, 0.0) + 0.6 * np.log1p(cnt)
            elif a_c == t_chem:
                scores[l_c] = scores.get(l_c, 0.0) + 0.15 * np.log1p(cnt)

        # Filter out acceptor carbons already occupied on tip_res
        valid_scores = {
            l_c: s_v
            for l_c, s_v in scores.items()
            if LinkageType(l_c).acceptor not in used_tip_carbons and LinkageType(l_c).acceptor > 0
        }
        if not valid_scores:
            fallback_acc = next((c for c in (3, 4, 2, 6) if c not in used_tip_carbons), 3)
            lt_fb = LinkageType(Anomer.BETA, 1, fallback_acc, is_backbone=True, is_polymer=True)
            return wzy_idx, lt_fb, 0.50, AssignmentEvidence.RULE_INFERRED

        best_l_chem, best_s = max(valid_scores.items(), key=lambda item: item[1])
        tot_s = sum(valid_scores.values()) or 1.0
        lt_best = LinkageType(best_l_chem).with_topology(is_backbone=True, is_polymer=True)
        ev = ev_map.get(
            best_l_chem,
            AssignmentEvidence.GRAPH_ALIGNED if wzy_idx >= 0 else AssignmentEvidence.RULE_INFERRED,
        )
        conf = float(min(0.99, max(0.45, 0.55 + 0.42 * (best_s / tot_s))))
        return wzy_idx, lt_best, conf, ev

    def predict(
        self,
        locus: Locus,
        *,
        archetype: LocusArchetype | None = None,
        is_repeating: bool = True,
        top_k: int = 5,
        known_assignments: Sequence[GeneGlycanAssignment] | dict[int, GeneGlycanAssignment] | None = None,
        reference_glycan: Glycan | None = None,
        allow_fingerprint_lookup: bool = True,
        resolve_homologs: bool = True,
    ) -> tuple[GlycanPrediction, ...]:
        """Predict top-`k` candidate `Glycan` graphs de novo from a `Locus` via 4-stage GNN + Arborescence."""
        if len(locus.nodes) == 0:
            return ()

        rescued_locus = (
            self.assigner.rescue_cassette_roles(locus, reference_glycan) if reference_glycan is not None else locus
        )
        if resolve_homologs and len(rescued_locus.genes) > 0 and all(n.cluster_id == 0 for n in rescued_locus.nodes):
            rescued_locus = self.resolve_locus_homologs(rescued_locus)
        arch = archetype if archetype is not None else LocusArchetype.CPS_WZY

        clamped_by_gene: dict[int, GeneGlycanAssignment] = {}
        if isinstance(known_assignments, dict):
            clamped_by_gene.update(known_assignments)
        elif known_assignments is not None:
            for ka in known_assignments:
                if ka.gene_index >= 0:
                    clamped_by_gene[int(ka.gene_index)] = ka

        # Stage 2: Compute Operon Precursor Pool (`c_pool`)
        named_pool, synthesized_special, precursor_gate = self._compute_operon_precursor_pool(rescued_locus)

        # Stage 1-3: Predict root candidate(s) and contextualized GT slots
        init_gene_idx, root_cands = self._predict_root_candidates(rescued_locus, arch, clamped_by_gene)
        gt_slots = self._build_gt_slots(
            rescued_locus,
            synthesized_special,
            precursor_gate,
            clamped_by_gene,
            reference_glycan=reference_glycan,
        )

        # Check exact Pan-Locus cluster cassette fingerprint match when allowed and unclamped
        fp_candidates: list[tuple[Glycan, float, tuple[GeneGlycanAssignment, ...]]] = []
        if allow_fingerprint_lookup and not clamped_by_gene:
            fp_cids = tuple(
                int(n.cluster_id)
                for n in rescued_locus.nodes
                if n.cluster_id > 0
                and n.role
                in (
                    GeneRole.INITIATOR,
                    GeneRole.GLYCOSYLTRANSFERASE,
                    GeneRole.POLYMERASE,
                    GeneRole.TRANSMEMBRANE_SYNTHASE,
                )
            )
            if fp_cids in self.locus_fingerprints:
                seen_canons: set[bytes] = set()
                for _, fp_gly, _ in self.locus_fingerprints[fp_cids]:
                    c_b = fp_gly.to_csdb()
                    if c_b not in seen_canons:
                        seen_canons.add(c_b)
                        inferred_assigns = self.assigner.assign_record(
                            fp_gly,
                            rescued_locus,
                            archetype=arch,
                            is_repeating=is_repeating,
                            pan_prior=self.pan_prior,
                        )
                        mean_sc = (
                            float(np.mean([a.score for a in inferred_assigns if a.is_aligned]))
                            if any(a.is_aligned for a in inferred_assigns)
                            else 0.92
                        )
                        fp_candidates.append((fp_gly, 50.0 + mean_sc, inferred_assigns))

        if not gt_slots and not fp_candidates:
            r_res, r_prob, r_ev = root_cands[0]
            g_single = Glycan.from_topology(nodes=(r_res,), edges=())
            a_single = (
                GeneGlycanAssignment(
                    gene_index=init_gene_idx,
                    role=GeneRole.INITIATOR,
                    bipartite_glycan_index=0,
                    target_type=TargetType.REDUCING_END,
                    residue=r_res,
                    linkage=None,
                    edge_index=None,
                    evidence=r_ev,
                    score=float(r_prob),
                ),
            )
            return (
                GlycanPrediction(
                    glycan=g_single,
                    score=float(r_prob),
                    probability=1.0,
                    assignments=a_single,
                    precursor_pool=named_pool,
                ),
            )

        # Stage 4: Enumerate top node-state combinations & solve Constrained Spanning Arborescences
        m_nodes = 1 + len(gt_slots)
        state_combos: list[
            tuple[
                float,
                ResidueType,
                float,
                AssignmentEvidence,
                list[tuple[ResidueType, LinkageType, float, AssignmentEvidence]],
                list[int],
            ]
        ] = []

        # Generate top state assignments across (root_cands, gt_slots)
        for r_res, r_prob, r_ev in root_cands[:2]:
            base_choices = [slot.candidates[0] for slot in gt_slots]
            base_bb = [1 if slot.p_backbone >= 0.45 else 0 for slot in gt_slots]
            if all(b == 0 for b in base_bb) and base_bb:
                base_bb[0] = 1

            def _score_combo(
                ch_list: list[tuple[ResidueType, LinkageType, float, AssignmentEvidence]],
                bb_list: list[int],
                _r_prob: float = r_prob,
                _r_res: ResidueType = r_res,
            ) -> float:
                sc = float(np.log(max(_r_prob, 1e-3)))
                present_sugars = {int(_r_res.sugar)}
                for idx_c, c_item in enumerate(ch_list):
                    sc += float(np.log(max(c_item[2], 1e-3)))
                    p_b = gt_slots[idx_c].p_backbone if bb_list[idx_c] == 1 else (1.0 - gt_slots[idx_c].p_backbone)
                    sc += self.params.w_backbone_log * float(np.log(max(p_b, 1e-3)))
                    present_sugars.add(int(c_item[0].sugar))
                # Stoichiometric precursor coverage bonus
                for sp_s in synthesized_special:
                    if sp_s in present_sugars:
                        sc += self.params.w_precursor_bonus
                    else:
                        sc -= 0.70 * self.params.w_precursor_bonus
                return sc

            state_combos.append((_score_combo(base_choices, base_bb), r_res, r_prob, r_ev, base_choices, base_bb))

            # Single-slot alternative reactions & branching topology flips
            for s_i, slot in enumerate(gt_slots):
                for alt_c in slot.candidates[1:3]:
                    alt_choices = list(base_choices)
                    alt_choices[s_i] = alt_c
                    state_combos.append(
                        (_score_combo(alt_choices, base_bb), r_res, r_prob, r_ev, alt_choices, list(base_bb))
                    )
                if not slot.is_clamped and 0.25 <= slot.p_backbone <= 0.85 and len(gt_slots) >= 2:
                    flipped_bb = list(base_bb)
                    flipped_bb[s_i] = 1 - flipped_bb[s_i]
                    if any(b == 1 for b in flipped_bb):
                        state_combos.append(
                            (
                                _score_combo(base_choices, flipped_bb),
                                r_res,
                                r_prob,
                                r_ev,
                                list(base_choices),
                                flipped_bb,
                            )
                        )
                # Short-backbone + long-branch topology when Wzy specifically closes onto slot s_i's sugar
                if not slot.is_clamped and len(gt_slots) >= 3 and sum(base_bb) >= 2:
                    one_bb = [1 if idx_b == s_i else 0 for idx_b in range(len(gt_slots))]
                    if one_bb != base_bb:
                        state_combos.append(
                            (_score_combo(base_choices, one_bb), r_res, r_prob, r_ev, list(base_choices), one_bb)
                        )

        state_combos.sort(key=lambda item: item[0], reverse=True)
        raw_candidates: list[tuple[Glycan, float, tuple[GeneGlycanAssignment, ...]]] = list(fp_candidates)

        for combo_sc, r_res, r_prob, r_ev, ch_list, bb_list in state_combos[: max(6, top_k * 2)]:
            score_mat = np.full((m_nodes, m_nodes), -1e9, dtype=np.float64)
            acc_carbons = np.zeros(m_nodes, dtype=np.int32)
            bb_flags = np.ones(m_nodes, dtype=np.uint8)

            slot_res: list[ResidueType] = [r_res]
            slot_lnk: list[LinkageType] = [LinkageType(Anomer.UNKNOWN, 0, 0)]
            for s_i, (res_u, lnk_u, _, _) in enumerate(ch_list):
                is_bb_u = bool(bb_list[s_i] == 1)
                slot_res.append(res_u.with_backbone(is_bb_u).with_reducing_end(False))
                slot_lnk.append(lnk_u.with_topology(is_backbone=is_bb_u, is_polymer=False))
                acc_carbons[s_i + 1] = int(lnk_u.acceptor)
                bb_flags[s_i + 1] = np.uint8(bb_list[s_i])

            # Precompute clamped reference source node -> arborescence slot index map
            ref_node_to_slot: dict[int, int] = {}
            if reference_glycan is not None:
                ref_node_to_slot[reference_glycan.reducing_end_index] = 0
                unclamped_slots: list[int] = []
                for s_idx_c, sl_c in enumerate(gt_slots, start=1):
                    if sl_c.is_clamped and sl_c.gene_index in clamped_by_gene:
                        ca_c = clamped_by_gene[sl_c.gene_index]
                        if ca_c.edge_index is not None and 0 <= ca_c.edge_index < len(reference_glycan.edges):
                            ref_node_to_slot[reference_glycan.edges[ca_c.edge_index].source] = s_idx_c
                    else:
                        unclamped_slots.append(s_idx_c)
                if len(unclamped_slots) == 1:
                    unmapped_core = [
                        n_i
                        for n_i, n_obj in enumerate(reference_glycan.nodes)
                        if not n_obj.is_modifier and n_i not in ref_node_to_slot
                    ]
                    if len(unmapped_core) == 1:
                        ref_node_to_slot[unmapped_core[0]] = unclamped_slots[0]

            # Fill pairwise directed edge score matrix score_mat[u, w] for u -> w
            sugar_counts: dict[int, int] = {}
            for r_s in slot_res:
                s_key = int(r_s.sugar)
                sugar_counts[s_key] = sugar_counts.get(s_key, 0) + 1

            for u in range(1, m_nodes):
                slot_u = gt_slots[u - 1]
                res_u = slot_res[u]
                lnk_u = slot_lnk[u]
                d_chem_u = encode_donor_residue_chem(res_u)
                l_chem_u = int(lnk_u.chemical_int)
                has_pan_sig_u = (slot_u.cluster_id > 0 and slot_u.cluster_id in self.pan_prior.cluster_sig_full) or (
                    self.pan_prior.fine_to_med.get(slot_u.cluster_id, 0) in self.pan_prior.cluster_sig_full
                )

                clamped_target_slot: int | None = None
                clamped_target_chem: int | None = None
                if slot_u.is_clamped and reference_glycan is not None and slot_u.gene_index in clamped_by_gene:
                    ca_u = clamped_by_gene[slot_u.gene_index]
                    if ca_u.edge_index is not None and 0 <= ca_u.edge_index < len(reference_glycan.edges):
                        e_ref_u = reference_glycan.edges[ca_u.edge_index]
                        clamped_target_slot = ref_node_to_slot.get(e_ref_u.target)
                        clamped_target_chem = encode_donor_residue_chem(reference_glycan.nodes[e_ref_u.target])

                c_acc_u = min(max(int(lnk_u.acceptor), 0), 7)
                mpnn_u = slot_u.mpnn_node_idx
                mpnn_parent_logits: dict[int, float] = {}
                if (
                    self._last_mpnn_edge_scores is not None
                    and 0 <= mpnn_u < self._last_mpnn_edge_scores.shape[1]
                    and c_acc_u < self._last_mpnn_edge_scores.shape[2]
                ):
                    raw_p_logits: dict[int, float] = {}
                    for w_cand in range(m_nodes):
                        if w_cand == u:
                            continue
                        mpnn_w = 0 if w_cand == 0 else gt_slots[w_cand - 1].mpnn_node_idx
                        if 0 <= mpnn_w < self._last_mpnn_edge_scores.shape[0] and mpnn_w != mpnn_u:
                            raw_p_logits[w_cand] = float(self._last_mpnn_edge_scores[mpnn_w, mpnn_u, c_acc_u])
                    if raw_p_logits:
                        mean_p_l = sum(raw_p_logits.values()) / len(raw_p_logits)
                        for w_cand, l_val in raw_p_logits.items():
                            mpnn_parent_logits[w_cand] = 0.55 * float(np.tanh(0.25 * (l_val - mean_p_l)))

                for w in range(m_nodes):
                    if u == w:
                        continue
                    res_w = slot_res[w]
                    a_chem_w = encode_donor_residue_chem(res_w)
                    is_dup_sugar_w = sugar_counts.get(int(res_w.sugar), 0) >= 2

                    # 0. Hard/soft clamping of parent edge when u is clamped
                    if clamped_target_slot is not None:
                        if w == clamped_target_slot:
                            score_mat[u, w] = 30.0
                        else:
                            score_mat[u, w] = -30.0
                        continue

                    # 1. GNN / Classifier Acceptor-Sugar & Acceptor-Motif Potential
                    p_acc = slot_u.acceptor_probs.get(a_chem_w, 0.04)
                    s_uw = self.params.w_acceptor_prob * np.log(max(p_acc, 1e-3))
                    if clamped_target_chem is not None:
                        s_uw += 8.0 if a_chem_w == clamped_target_chem else -8.0

                    if slot_u.acceptor_motif_probs:
                        if w == 0:
                            p_am = max(
                                (
                                    slot_u.acceptor_motif_probs.get(
                                        encode_acceptor_motif_label(res_w, anom_cand, 1),
                                        0.0,
                                    )
                                    for anom_cand in (Anomer.BETA, Anomer.ALPHA, Anomer.UNKNOWN)
                                ),
                                default=0.0,
                            )
                        else:
                            pos_w = 2 if bb_flags[w] == 1 else 3
                            am_key = encode_acceptor_motif_label(res_w, slot_lnk[w].anomer, pos_w)
                            am_alt = encode_acceptor_motif_label(res_w, slot_lnk[w].anomer, 5 - pos_w)
                            p_am = max(
                                slot_u.acceptor_motif_probs.get(am_key, 0.0),
                                0.85 * slot_u.acceptor_motif_probs.get(am_alt, 0.0),
                            )
                        if p_am >= 0.02:
                            w_am = (
                                self.params.w_acceptor_motif
                                if (is_dup_sugar_w or not has_pan_sig_u)
                                else (0.14 * self.params.w_acceptor_motif)
                            )
                            s_uw += w_am * p_am

                    if w in mpnn_parent_logits:
                        w_mpnn_e = (
                            self.params.w_mpnn_edge
                            if (is_dup_sugar_w or not has_pan_sig_u)
                            else (0.20 * self.params.w_mpnn_edge)
                        )
                        s_uw += w_mpnn_e * mpnn_parent_logits[w]

                    # 1b. Direct PanGraphConsensusPrior joint (donor, linkage, acceptor_sugar) match for slot_u
                    has_exact_pan_acc_uw = False
                    for c_tier, tier_scale in (
                        (slot_u.cluster_id, 1.0),
                        (self.pan_prior.fine_to_med.get(slot_u.cluster_id, 0), 0.72),
                    ):
                        if c_tier > 0 and c_tier in self.pan_prior.cluster_sig_full:
                            sig_map_u = self.pan_prior.cluster_sig_full[c_tier]
                            tot_u = sum(sig_map_u.values())
                            exact_acc_w = 0.0
                            exact_dl_w = 0.0
                            acc_by_sugar: dict[int, float] = {}
                            for sig_u, cnt_u in sig_map_u.items():
                                if (
                                    sig_u[0] == int(res_u.sugar)
                                    and sig_u[1] == int(res_u.chirality)
                                    and sig_u[2] == int(res_u.is_furanose)
                                    and sig_u[3] == int(lnk_u.anomer)
                                    and sig_u[4] == int(lnk_u.donor)
                                    and sig_u[5] == int(lnk_u.acceptor)
                                ):
                                    exact_dl_w += cnt_u
                                    acc_s_u = int(sig_u[6])
                                    acc_by_sugar[acc_s_u] = acc_by_sugar.get(acc_s_u, 0.0) + cnt_u
                                    if acc_s_u == int(res_w.sugar):
                                        exact_acc_w += cnt_u
                            if exact_dl_w > 0:
                                max_acc_share = max(acc_by_sugar.values()) / exact_dl_w if acc_by_sugar else 0.0
                                if max_acc_share >= 0.75:
                                    if exact_acc_w > 0:
                                        has_exact_pan_acc_uw = True
                                        s_uw += (
                                            self.params.w_pan_acc_match * tier_scale * (exact_acc_w / max(1e-6, tot_u))
                                        )
                                    else:
                                        s_uw -= (0.49 * self.params.w_pan_acc_match) * tier_scale
                                elif exact_acc_w > 0:
                                    has_exact_pan_acc_uw = True
                                    s_uw += (
                                        0.20
                                        * self.params.w_pan_acc_match
                                        * tier_scale
                                        * (exact_acc_w / max(1e-6, tot_u))
                                    )
                            elif tot_u >= 1.8:
                                s_uw -= (0.49 * self.params.w_pan_acc_match) * tier_scale

                    # 1c. Terminal leaf / branch-point constraint on parent w
                    if w > 0 and not has_exact_pan_acc_uw:
                        slot_w = gt_slots[w - 1]
                        if bb_flags[w] == 0:
                            # Side-chain branch residue w rarely accepts further extensions
                            s_uw -= 3.5
                        if slot_w.cluster_id > 0 and slot_w.cluster_id in self.pan_prior.cluster_sig_full:
                            sig_map_w = self.pan_prior.cluster_sig_full[slot_w.cluster_id]
                            tot_w_s = sum(sig_map_w.values())
                            leaf_w = sum(cnt_w * float(sig_w[8]) for sig_w, cnt_w in sig_map_w.items()) / max(
                                1e-6, tot_w_s
                            )
                            if leaf_w >= 0.70:
                                s_uw -= 5.0 * leaf_w

                    # 2. Pan-Glycan Empirical Disaccharide Bigram Prior
                    c_exact = self.disaccharide_counts.get((d_chem_u, l_chem_u, a_chem_w), 0)
                    c_pair = self.donor_acc_counts.get((d_chem_u, a_chem_w), 0)
                    s_uw += self.params.w_disac_bigram * np.log1p(c_exact) + (
                        0.38 * self.params.w_disac_bigram
                    ) * np.log1p(c_pair)

                    # 3. Pan-Locus Syntenic & Operon Progression Prior
                    if w == 0:
                        if init_gene_idx >= 0:
                            init_cid = int(rescued_locus.nodes[init_gene_idx].cluster_id)
                            if init_cid > 0 and slot_u.cluster_id > 0:
                                step1_map = self.pan_prior.init_adj_gt_step1.get((init_cid, slot_u.cluster_id), {})
                                c_init_gt = sum(step1_map.values()) + self.pan_prior.pan_locus_edge_counts.get(
                                    (init_cid, slot_u.cluster_id), 0
                                )
                                s_uw += (2.27 * self.params.w_syntenic_step) * np.log1p(c_init_gt)
                        # Step-1 GT is typically at one end of the GT cassette
                        if u == 1 or u == m_nodes - 1:
                            s_uw += 0.64 * self.params.w_syntenic_step
                    else:
                        slot_w = gt_slots[w - 1]
                        if slot_u.gene_index == slot_w.gene_index and u == w + 1:
                            s_uw += 12.0
                        elif abs(slot_u.gene_index - slot_w.gene_index) == 1:
                            s_uw += 0.82 * self.params.w_syntenic_step
                        elif abs(u - w) == 1:
                            s_uw += 0.45 * self.params.w_syntenic_step

                        # Processive GT pair disambiguation: main-chain extends distal unit, branch attaches to proximal unit
                        if slot_u.gene_index != slot_w.gene_index:
                            is_w_prox = w < m_nodes - 1 and gt_slots[w].gene_index == slot_w.gene_index
                            is_w_dist = w > 1 and gt_slots[w - 2].gene_index == slot_w.gene_index
                            if is_w_prox:
                                s_uw += 2.5 if bb_flags[u] == 0 else -2.5
                            elif is_w_dist:
                                s_uw += 2.5 if bb_flags[u] == 1 else -2.5
                        if slot_u.cluster_id > 0 and slot_w.cluster_id > 0:
                            c_bg = (
                                self.pan_prior.syntenic_pair_record_counts.get(
                                    (slot_w.cluster_id, slot_u.cluster_id), 0
                                )
                                + self.pan_prior.syntenic_pair_record_counts.get(
                                    (slot_u.cluster_id, slot_w.cluster_id), 0
                                )
                                + self.pan_prior.pan_locus_edge_counts.get((slot_w.cluster_id, slot_u.cluster_id), 0)
                                + self.pan_prior.pan_locus_edge_counts.get((slot_u.cluster_id, slot_w.cluster_id), 0)
                            )
                            s_uw += self.params.w_syntenic_step * np.log1p(c_bg)

                    score_mat[u, w] = s_uw

            best_parents, best_scores, n_found = _solve_constrained_arborescences_kernel(
                score_mat,
                acc_carbons,
                bb_flags,
                max_candidates=max(8, top_k * 3),
            )
            if n_found == 0:
                msa_res = maximum_spanning_arborescence(score_mat.T, root=0, slot_exclusivity=False)
                if msa_res.score > -1e15:
                    best_parents[0, :] = msa_res.parents
                    best_scores[0] = msa_res.score
                    n_found = 1

            for k_idx in range(n_found):
                parents = best_parents[k_idx]
                tree_sc = float(best_scores[k_idx])
                nodes_list: list[ResidueType] = list(slot_res)
                edges_list: list[LinkageEdge] = []
                assigns_list: list[GeneGlycanAssignment] = [
                    GeneGlycanAssignment(
                        gene_index=init_gene_idx,
                        role=GeneRole.INITIATOR,
                        bipartite_glycan_index=0,
                        target_type=TargetType.REDUCING_END,
                        residue=slot_res[0],
                        linkage=None,
                        edge_index=None,
                        evidence=r_ev,
                        score=float(r_prob),
                    )
                ]

                bb_children_of: dict[int, int] = {}
                used_carbons_on: dict[int, set[int]] = {i_n: set() for i_n in range(m_nodes)}

                for u in range(1, m_nodes):
                    w = int(parents[u])
                    lnk_u = slot_lnk[u]
                    e_idx = len(edges_list)
                    edges_list.append(LinkageEdge(source=u, target=w, edge_type=lnk_u))
                    if lnk_u.acceptor > 0:
                        used_carbons_on[w].add(int(lnk_u.acceptor))
                    if bb_flags[u] == 1:
                        bb_children_of[w] = u

                    slot_u = gt_slots[u - 1]
                    _, _, cand_prob, cand_ev = ch_list[u - 1]
                    assigns_list.append(
                        GeneGlycanAssignment(
                            gene_index=slot_u.gene_index,
                            role=slot_u.role,
                            bipartite_glycan_index=m_nodes + e_idx,
                            target_type=TargetType.LINKAGE,
                            residue=None,
                            linkage=lnk_u,
                            edge_index=e_idx,
                            evidence=cand_ev,
                            score=float(cand_prob),
                        )
                    )

                # Trace main-chain backbone tip from root 0
                tip_idx = 0
                visited_tip: set[int] = {0}
                while tip_idx in bb_children_of and bb_children_of[tip_idx] not in visited_tip:
                    tip_idx = bb_children_of[tip_idx]
                    visited_tip.add(tip_idx)

                # Close polymer repeat with Wzy linkage (`0 -> tip_idx`) when repeating
                has_wzy_gene = any(n.role == GeneRole.POLYMERASE for n in rescued_locus.nodes)
                if (
                    is_repeating
                    and (
                        has_wzy_gene
                        or arch
                        in (
                            LocusArchetype.CPS_WZY,
                            LocusArchetype.LPS_WZY,
                            LocusArchetype.LPS_ABC,
                            LocusArchetype.CPS_ABC,
                            LocusArchetype.CPS_SYNTHASE,
                        )
                    )
                    and tip_idx != 0
                ):
                    wzy_g_idx, wzy_lt, wzy_conf, wzy_ev = self._predict_wzy_candidates(
                        rescued_locus,
                        nodes_list[0],
                        nodes_list[tip_idx],
                        used_carbons_on.get(tip_idx, set()),
                        clamped_by_gene,
                        reference_glycan=reference_glycan,
                    )
                    # Score Wzy acceptor sugar specificity against tip_idx
                    if wzy_g_idx in clamped_by_gene and reference_glycan is not None:
                        c_w = clamped_by_gene[wzy_g_idx]
                        if c_w.edge_index is not None and 0 <= c_w.edge_index < len(reference_glycan.edges):
                            e_ref_w = reference_glycan.edges[c_w.edge_index]
                            if ref_node_to_slot.get(e_ref_w.target) == tip_idx:
                                tree_sc += 25.0
                            elif encode_donor_residue_chem(nodes_list[tip_idx]) == encode_donor_residue_chem(
                                reference_glycan.nodes[e_ref_w.target]
                            ):
                                tree_sc += 8.0
                            else:
                                tree_sc -= 15.0
                    elif wzy_g_idx >= 0:
                        wzy_cid = int(rescued_locus.nodes[wzy_g_idx].cluster_id)
                        if wzy_cid > 0 and wzy_cid in self.pan_prior.cluster_sig_full:
                            w_sigs_all = self.pan_prior.cluster_sig_full[wzy_cid]
                            root_s_id = int(nodes_list[0].sugar)
                            w_sigs_root = {sig_w: cnt_w for sig_w, cnt_w in w_sigs_all.items() if sig_w[0] == root_s_id}
                            w_sigs = w_sigs_root if w_sigs_root else w_sigs_all
                            w_tot = sum(w_sigs.values())
                            w_tip_match = sum(
                                cnt_w
                                for sig_w, cnt_w in w_sigs.items()
                                if sig_w[6] == int(nodes_list[tip_idx].sugar) and sig_w[5] == int(wzy_lt.acceptor)
                            )
                            if w_tip_match > 0:
                                tree_sc += 4.5 * (w_tip_match / max(1e-6, w_tot))
                            else:
                                tree_sc -= 2.5
                    tree_sc += 1.5 * float(np.log(max(wzy_conf, 0.1)))
                    wzy_e_idx = len(edges_list)
                    edges_list.append(LinkageEdge(source=0, target=tip_idx, edge_type=wzy_lt))
                    assigns_list.append(
                        GeneGlycanAssignment(
                            gene_index=wzy_g_idx,
                            role=GeneRole.POLYMERASE,
                            bipartite_glycan_index=m_nodes + wzy_e_idx,
                            target_type=TargetType.LINKAGE,
                            residue=None,
                            linkage=wzy_lt,
                            edge_index=wzy_e_idx,
                            evidence=wzy_ev,
                            score=float(wzy_conf),
                        )
                    )

                # Attach modifier substituents from tailing modifier ORFs
                gly_core = Glycan.from_topology(nodes=nodes_list, edges=edges_list)
                comp_gly, _, comp_map = self.assigner.complete_glycan_modifiers(
                    gly_core, rescued_locus, pan_prior=self.pan_prior
                )
                if comp_map:
                    gly_final = Glycan.from_topology(nodes=comp_gly.nodes, edges=comp_gly.edges)
                    mod_assigns = list(assigns_list)
                    has_obligate_mod = False
                    for mod_node_idx, mod_gene_idx in sorted(comp_map.items()):
                        mod_res = gly_final.nodes[mod_node_idx]
                        m_role = rescued_locus.nodes[mod_gene_idx].role
                        has_specific_mod_edge = any(
                            e_m.source == mod_node_idx and e_m.edge_type.acceptor > 0 for e_m in gly_final.edges
                        )
                        if has_specific_mod_edge or m_role in (
                            GeneRole.PYRUVYLTRANSFERASE,
                            GeneRole.FORMYLTRANSFERASE,
                            GeneRole.ALANINE_TRANSFERASE,
                        ):
                            has_obligate_mod = True
                        mod_assigns.append(
                            GeneGlycanAssignment(
                                gene_index=mod_gene_idx,
                                role=m_role,
                                bipartite_glycan_index=mod_node_idx,
                                target_type=TargetType.MODIFIED_MONO,
                                residue=mod_res,
                                linkage=None,
                                edge_index=None,
                                evidence=AssignmentEvidence.GRAPH_ALIGNED,
                                score=0.86,
                            )
                        )
                    if has_obligate_mod:
                        raw_candidates.append((gly_final, combo_sc + tree_sc + 0.25, tuple(mod_assigns)))
                        raw_candidates.append((gly_core, combo_sc + tree_sc, tuple(assigns_list)))
                    else:
                        raw_candidates.append((gly_core, combo_sc + tree_sc + 0.25, tuple(assigns_list)))
                        raw_candidates.append((gly_final, combo_sc + tree_sc, tuple(mod_assigns)))
                else:
                    raw_candidates.append((gly_core, combo_sc + tree_sc, tuple(assigns_list)))

        # Deduplicate candidates by canonical CSDB string (or topological isomorphism) and softmax-normalize
        unique_cands: list[tuple[Glycan, float, tuple[GeneGlycanAssignment, ...]]] = []
        seen_csdb: set[bytes] = set()
        raw_candidates.sort(key=lambda item: item[1], reverse=True)
        for cand_gly, cand_sc, cand_asg in raw_candidates:
            c_bytes = cand_gly.canonical or cand_gly.to_csdb()
            if c_bytes in seen_csdb:
                continue
            seen_csdb.add(c_bytes)
            unique_cands.append((cand_gly, cand_sc, cand_asg))
            if len(unique_cands) >= top_k:
                break

        if not unique_cands:
            return ()

        raw_scores = np.array([c[1] for c in unique_cands], dtype=np.float64)
        shifted = (raw_scores - np.max(raw_scores)) * 0.5
        exp_s = np.exp(np.clip(shifted, -30.0, 0.0))
        probs = exp_s / np.sum(exp_s)

        return tuple(
            GlycanPrediction(
                glycan=unique_cands[i][0],
                score=round(float(unique_cands[i][1]), 4),
                probability=round(float(probs[i]), 4),
                assignments=unique_cands[i][2],
                precursor_pool=named_pool,
            )
            for i in range(len(unique_cands))
        )

from_records classmethod

from_records(
    records: GlycanRecords | None = None,
    *,
    exclude_record_indices: set[int] | None = None,
    classifier_suite: BiosyntheticClassifierSuite
    | None = None,
    mpnn_epochs: int = 18,
    mpnn_path: Path | None = DEFAULT_MPNN_PATH,
    retrain_mpnn: bool = False,
    train_mpnn: bool | None = None,
) -> Self

Fit GlycanPredictor on GlycanRecords (supporting held-out record masking for cross-validation).

Source code in src/kbank/biosynthesis/predictor.py
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
@classmethod
def from_records(
    cls,
    records: GlycanRecords | None = None,
    *,
    exclude_record_indices: set[int] | None = None,
    classifier_suite: BiosyntheticClassifierSuite | None = None,
    mpnn_epochs: int = 18,
    mpnn_path: Path | None = DEFAULT_MPNN_PATH,
    retrain_mpnn: bool = False,
    train_mpnn: bool | None = None,
) -> Self:
    """Fit `GlycanPredictor` on `GlycanRecords` (supporting held-out record masking for cross-validation)."""
    if records is None:
        from kbank.db.records import GlycanRecords as _GR

        records = _GR.pretrained()

    if train_mpnn is False:
        mpnn_epochs = 0
    elif train_mpnn is True:
        retrain_mpnn = True

    excluded = exclude_record_indices or set()
    suite = classifier_suite or BiosyntheticClassifierSuite.pretrained()
    assigner = BiosyntheticAssigner()

    # 1. Extract Pan-Glycan empirical disaccharide & Wzy polymer closure priors across all 1,532 glycans
    disac_counts: dict[tuple[int, int, int], int] = {}
    donor_acc_counts: dict[tuple[int, int], int] = {}
    wzy_closure_counts: dict[tuple[int, int, int], int] = {}
    arch_init_counts: dict[tuple[int, int], int] = {}
    donor_set: set[int] = set()

    for i in range(len(records)):
        if i in excluded:
            continue
        gly = records.glycans[i]
        if len(gly.nodes) == 0:
            continue
        r_idx = gly.reducing_end_index
        r_node = gly.nodes[r_idx]
        if not r_node.is_modifier:
            r_chem = encode_donor_residue_chem(r_node)
            donor_set.add(r_chem)
            arch_val = int(records.archetypes[i])
            arch_init_counts[(arch_val, r_chem)] = arch_init_counts.get((arch_val, r_chem), 0) + 1

        for e in gly.edges:
            if not (0 <= e.source < len(gly.nodes) and 0 <= e.target < len(gly.nodes)):
                continue
            s_n = gly.nodes[e.source]
            t_n = gly.nodes[e.target]
            if s_n.is_modifier or t_n.is_modifier:
                continue
            s_chem = encode_donor_residue_chem(s_n)
            t_chem = encode_donor_residue_chem(t_n)
            l_chem = int(e.edge_type.chemical_int)
            donor_set.add(s_chem)
            donor_set.add(t_chem)
            if e.edge_type.is_polymer:
                wzy_closure_counts[(s_chem, l_chem, t_chem)] = (
                    wzy_closure_counts.get((s_chem, l_chem, t_chem), 0) + 1
                )
            else:
                disac_counts[(s_chem, l_chem, t_chem)] = disac_counts.get((s_chem, l_chem, t_chem), 0) + 1
                donor_acc_counts[(s_chem, t_chem)] = donor_acc_counts.get((s_chem, t_chem), 0) + 1

    donor_vocab = tuple(sorted(donor_set))
    d_to_i = {d: idx for idx, d in enumerate(donor_vocab)}
    n_d = len(donor_vocab)
    bigram_trans = np.full((n_d, n_d), 1.0 / max(1, n_d), dtype=np.float64)
    for acc_d, acc_i in d_to_i.items():
        row_tot = 0.0
        for don_d, don_i in d_to_i.items():
            c_val = float(donor_acc_counts.get((don_d, acc_d), 0)) + 0.1
            bigram_trans[acc_i, don_i] = c_val
            row_tot += c_val
        if row_tot > 0:
            bigram_trans[acc_i, :] /= row_tot

    # 2. Build PanGraphConsensusPrior from existing curated assignments (or fit from scratch if uncurated)
    kept_indices = [i for i in range(len(records)) if i not in excluded]
    cluster_am_counts: dict[int, dict[int, float]] = {}
    if records.assignments.total_assignments > 0:
        pan_prior = PanGraphConsensusPrior()
        pan_prior.classifier_suite = suite
        cluster_recs: dict[int, set[int]] = {}
        precursor_roles = {
            GeneRole.OXIDOREDUCTASE,
            GeneRole.EPIMERASE,
            GeneRole.AMINOTRANSFERASE,
            GeneRole.ALDOLASE,
            GeneRole.NUCLEOTIDASE,
            GeneRole.NUCLEOTIDYLTRANSFERASE,
        }
        for i in kept_indices:
            loc = records.loci[i]
            gly = records.glycans[i]
            if len(loc.nodes) == 0 or len(gly.nodes) == 0:
                continue
            assigns = records.assignments[i]
            for g_i, g_n in enumerate(loc.nodes):
                c_id = int(g_n.cluster_id)
                if c_id > 0:
                    cluster_recs.setdefault(c_id, set()).add(i)
                    if g_i + 1 < len(loc.nodes):
                        c_next = int(loc.nodes[g_i + 1].cluster_id)
                        if c_next > 0:
                            pan_prior.pan_locus_edge_counts[(c_id, c_next)] = (
                                pan_prior.pan_locus_edge_counts.get((c_id, c_next), 0) + 1
                            )
                            pan_prior.syntenic_pair_record_counts[(c_id, c_next)] = (
                                pan_prior.syntenic_pair_record_counts.get((c_id, c_next), 0) + 1
                            )

            init_cid_rec = 0
            n_ac_mods_rec, _ = assigner._find_n_acetyl_nodes(gly)
            for a in assigns:
                if not a.is_aligned or a.gene_index < 0 or a.gene_index >= len(loc.nodes):
                    continue
                if a.score < 0.60 and a.evidence != AssignmentEvidence.PAN_GRAPH_CONSENSUS:
                    continue
                c_id = int(loc.nodes[a.gene_index].cluster_id)
                if c_id <= 0:
                    continue
                w_a = float(a.score) * (2.5 if a.evidence == AssignmentEvidence.PAN_GRAPH_CONSENSUS else 1.0)
                if a.role == GeneRole.INITIATOR and a.residue is not None and not a.residue.is_modifier:
                    init_cid_rec = c_id
                    s_map = pan_prior.init_cluster_sugar.setdefault(c_id, {})
                    s_map[int(a.residue.sugar)] = s_map.get(int(a.residue.sugar), 0.0) + w_a
                elif a.role in precursor_roles and a.residue is not None and not a.residue.is_modifier:
                    s_map = pan_prior.precursor_cluster_sugar.setdefault(c_id, {})
                    s_map[int(a.residue.sugar)] = s_map.get(int(a.residue.sugar), 0.0) + w_a
                elif (
                    a.role
                    in (
                        GeneRole.ACETYLTRANSFERASE,
                        GeneRole.PYRUVYLTRANSFERASE,
                        GeneRole.METHYLTRANSFERASE,
                        GeneRole.PHOSPHOTRANSFERASE,
                        GeneRole.FORMYLTRANSFERASE,
                        GeneRole.ALANINE_TRANSFERASE,
                    )
                    and a.residue is not None
                    and a.residue.is_modifier
                    and 0 <= a.bipartite_glycan_index < len(gly.nodes)
                    and a.bipartite_glycan_index not in n_ac_mods_rec
                ):
                    for e_m in gly.edges:
                        if e_m.source == a.bipartite_glycan_index and 0 <= e_m.target < len(gly.nodes):
                            t_res_m = gly.nodes[e_m.target]
                            if not t_res_m.is_modifier:
                                m_key = (
                                    int(a.residue.sugar),
                                    int(t_res_m.sugar),
                                    int(e_m.edge_type.chemical_int),
                                )
                                m_spec = pan_prior.modifier_cluster_spec.setdefault(c_id, {})
                                m_spec[m_key] = m_spec.get(m_key, 0.0) + w_a
                elif (
                    a.role
                    in (
                        GeneRole.GLYCOSYLTRANSFERASE,
                        GeneRole.POLYMERASE,
                        GeneRole.TRANSMEMBRANE_SYNTHASE,
                    )
                    and a.edge_index is not None
                    and 0 <= a.edge_index < len(gly.edges)
                ):
                    sig_full, sig_dl, sig_da, mech = _edge_signatures(gly, a.edge_index)
                    f_map = pan_prior.cluster_sig_full.setdefault(c_id, {})
                    f_map[sig_full] = f_map.get(sig_full, 0.0) + w_a
                    dl_map = pan_prior.cluster_sig_dl.setdefault(c_id, {})
                    dl_map[sig_dl] = dl_map.get(sig_dl, 0.0) + w_a
                    da_map = pan_prior.cluster_sig_da.setdefault(c_id, {})
                    da_map[sig_da] = da_map.get(sig_da, 0.0) + w_a
                    if mech > 0:
                        m_map = pan_prior.cluster_mech.setdefault(c_id, {})
                        m_map[mech] = m_map.get(mech, 0.0) + w_a
                    am_code = extract_acceptor_motif_for_edge(gly, int(a.edge_index))
                    am_c_map = cluster_am_counts.setdefault(c_id, {})
                    am_c_map[am_code] = am_c_map.get(am_code, 0.0) + w_a
                    e_obj = gly.edges[a.edge_index]
                    if init_cid_rec > 0 and e_obj.target == gly.reducing_end_index:
                        st1_map = pan_prior.init_adj_gt_step1.setdefault((init_cid_rec, c_id), {})
                        st1_map[sig_dl] = st1_map.get(sig_dl, 0.0) + w_a

        for c_id, r_set in cluster_recs.items():
            pan_prior.cluster_record_counts[c_id] = len(r_set)
    else:
        kept_glycans = [records.glycans[i] for i in kept_indices]
        kept_loci = records.loci[np.asarray(kept_indices, dtype=np.int32)]
        kept_archetypes = records.archetypes[kept_indices]
        kept_repeating = records.is_repeating[kept_indices]
        pan_prior, _ = PanGraphConsensusPrior.fit(
            kept_glycans,
            kept_loci,
            kept_archetypes,
            kept_repeating,
            assigner=assigner,
        )

    # 3. Precompute normalized 192-D 1D-CNN reference embeddings for sequence k-NN retrieval
    gt_seqs: list[bytes] = []
    gt_dl: list[int] = []
    gt_acc: list[int] = []
    gt_am: list[int] = []
    gt_bb: list[int] = []
    gt_recs: list[int] = []

    init_seqs: list[bytes] = []
    init_sugs: list[int] = []
    init_recs: list[int] = []

    wzy_seqs: list[bytes] = []
    wzy_lnks: list[int] = []
    wzy_recs: list[int] = []

    multi_edge_clusters: dict[int, list[tuple[ResidueType, LinkageType]]] = {}
    multi_edge_cand: dict[int, list[tuple[ResidueType, LinkageType]]] = {}
    multi_edge_counts: dict[int, int] = {}
    single_edge_counts: dict[int, int] = {}
    locus_fps: dict[tuple[int, ...], list[tuple[int, Glycan, tuple[GeneGlycanAssignment, ...]]]] = {}
    ref_orf_seqs: list[bytes] = []
    ref_orf_cids: list[int] = []
    ref_orf_roles: list[int] = []
    orf_kmer_index: dict[bytes, list[int]] = {}

    for i in kept_indices:
        loc = records.loci[i]
        gly = records.glycans[i]
        if len(loc.nodes) == 0 or len(gly.nodes) == 0:
            continue
        if len(loc.genes) > 0:
            trans_r = loc.genes.translations
            for g_i_r, nd_r in enumerate(loc.nodes):
                if nd_r.cluster_id > 0 and g_i_r < len(trans_r):
                    s_b = bytes(trans_r[g_i_r].seq)
                    if len(s_b) >= 60:
                        idx_ref = len(ref_orf_seqs)
                        ref_orf_seqs.append(s_b)
                        ref_orf_cids.append(int(nd_r.cluster_id))
                        ref_orf_roles.append(int(nd_r.role))
                        seen_k: set[bytes] = set()
                        for pos_k in range(0, len(s_b) - 3, 2):
                            km = s_b[pos_k : pos_k + 4]
                            if km not in seen_k:
                                seen_k.add(km)
                                orf_kmer_index.setdefault(km, []).append(idx_ref)
        assigns = records.assignments[i]
        fp_cids = tuple(
            int(n.cluster_id)
            for n in loc.nodes
            if n.cluster_id > 0
            and n.role
            in (
                GeneRole.INITIATOR,
                GeneRole.GLYCOSYLTRANSFERASE,
                GeneRole.POLYMERASE,
                GeneRole.TRANSMEMBRANE_SYNTHASE,
            )
        )
        if len(fp_cids) >= 2:
            locus_fps.setdefault(fp_cids, []).append((i, gly, assigns))

        # Track processive/bifunctional GTs assigned to >= 2 glycosidic edges in the same record
        by_gene_edges: dict[int, list[tuple[int, ResidueType, LinkageType]]] = {}
        _, assembly_depths, _ = gly.polymer_edge_and_assembly_depths(gly.reducing_end_index)
        for a in assigns:
            if (
                a.is_aligned
                and a.role in (GeneRole.GLYCOSYLTRANSFERASE, GeneRole.TRANSMEMBRANE_SYNTHASE)
                and a.edge_index is not None
                and 0 <= a.edge_index < len(gly.edges)
                and 0 <= a.gene_index < len(loc.nodes)
            ):
                e_m = gly.edges[a.edge_index]
                s_m = gly.nodes[e_m.source]
                if not s_m.is_modifier and e_m.edge_type.anomer != Anomer.UNKNOWN and e_m.edge_type.acceptor > 0:
                    by_gene_edges.setdefault(a.gene_index, []).append((int(a.edge_index), s_m, e_m.edge_type))
        for g_i_m, edges_m in by_gene_edges.items():
            cid_m = int(loc.nodes[g_i_m].cluster_id)
            if cid_m > 0:
                if len(edges_m) >= 2:
                    edges_m.sort(key=lambda item: (assembly_depths.get(gly.edges[item[0]].source, 99), item[0]))
                    multi_edge_counts[cid_m] = multi_edge_counts.get(cid_m, 0) + 1
                    if cid_m not in multi_edge_cand:
                        multi_edge_cand[cid_m] = [(item[1], item[2]) for item in edges_m]
                else:
                    single_edge_counts[cid_m] = single_edge_counts.get(cid_m, 0) + 1

        if len(loc.genes) == 0:
            continue
        translations = loc.genes.translations
        for a in assigns:
            if not a.is_aligned or a.gene_index < 0 or a.gene_index >= len(translations):
                continue
            if a.score < 0.62 and a.evidence != AssignmentEvidence.PAN_GRAPH_CONSENSUS:
                continue
            aa = bytes(translations[a.gene_index].seq)
            if len(aa) < 70:
                continue
            if a.role == GeneRole.INITIATOR and a.residue is not None and not a.residue.is_modifier:
                init_seqs.append(aa)
                init_sugs.append(encode_donor_residue_chem(a.residue))
                init_recs.append(i)
            elif a.role == GeneRole.POLYMERASE and a.edge_index is not None and 0 <= a.edge_index < len(gly.edges):
                e_w = gly.edges[a.edge_index]
                if e_w.edge_type.anomer != Anomer.UNKNOWN and e_w.edge_type.acceptor > 0:
                    wzy_seqs.append(aa)
                    wzy_lnks.append(int(e_w.edge_type.chemical_int))
                    wzy_recs.append(i)
            elif (
                a.role in (GeneRole.GLYCOSYLTRANSFERASE, GeneRole.TRANSMEMBRANE_SYNTHASE)
                and a.edge_index is not None
                and 0 <= a.edge_index < len(gly.edges)
            ):
                e_g = gly.edges[a.edge_index]
                s_n = gly.nodes[e_g.source]
                t_n = gly.nodes[e_g.target]
                if not s_n.is_modifier and not t_n.is_modifier:
                    if e_g.edge_type.anomer != Anomer.UNKNOWN and e_g.edge_type.acceptor > 0:
                        # Use N/C domain split when a bifunctional GT is assigned to 2+ edges
                        aa_use = aa
                        g_edges = by_gene_edges.get(a.gene_index, [])
                        if len(g_edges) >= 2:
                            doms = split_bifunctional_gt_domains(aa, min_len=550, overlap=25)
                            if doms is not None:
                                e_idx_list = [item[0] for item in g_edges]
                                if int(a.edge_index) in e_idx_list:
                                    pos_d = e_idx_list.index(int(a.edge_index))
                                    aa_use = doms[0] if pos_d == 0 else doms[1]
                        gt_seqs.append(aa_use)
                        gt_dl.append(encode_donor_linkage_label(s_n, e_g.edge_type))
                        gt_acc.append(encode_donor_residue_chem(t_n))
                        gt_am.append(extract_acceptor_motif_for_edge(gly, int(a.edge_index)))
                        gt_bb.append(1 if e_g.edge_type.is_backbone else 0)
                        gt_recs.append(i)

    for cid_m, cand_m in multi_edge_cand.items():
        m_cnt = multi_edge_counts.get(cid_m, 0)
        s_cnt = single_edge_counts.get(cid_m, 0)
        if m_cnt >= 2 or (m_cnt >= 1 and s_cnt == 0):
            multi_edge_clusters[cid_m] = cand_m

    backbone_clf = (
        suite.classifiers.get("donor_linkage")
        if (suite is not None and "donor_linkage" in suite.classifiers)
        else None
    )
    if backbone_clf is None:
        try:
            backbone_clf = GeneRoleClassifier.pretrained()
        except Exception:
            backbone_clf = None

    def _embed_norm(seq_list: list[bytes]) -> np.ndarray | None:
        if not seq_list or backbone_clf is None or not backbone_clf.is_fitted:
            return None
        emb = backbone_clf.transform(Sequences.from_bytes(seq_list)).astype(np.float32)
        norms = np.linalg.norm(emb, axis=1, keepdims=True)
        norms[norms < 1e-8] = 1.0
        return emb / norms

    predictor = cls(
        pan_prior=pan_prior,
        classifier_suite=suite,
        assigner=assigner,
        disaccharide_counts=disac_counts,
        donor_acc_counts=donor_acc_counts,
        wzy_closure_counts=wzy_closure_counts,
        archetype_init_counts=arch_init_counts,
        donor_vocab=donor_vocab,
        bigram_trans_matrix=bigram_trans,
        ref_gt_embeddings=_embed_norm(gt_seqs),
        ref_gt_dl_labels=np.asarray(gt_dl, dtype=np.int64) if gt_dl else None,
        ref_gt_acc_labels=np.asarray(gt_acc, dtype=np.int64) if gt_acc else None,
        ref_gt_am_labels=np.asarray(gt_am, dtype=np.int64) if gt_am else None,
        ref_gt_bb_labels=np.asarray(gt_bb, dtype=np.int64) if gt_bb else None,
        ref_gt_record_indices=np.asarray(gt_recs, dtype=np.int64) if gt_recs else None,
        ref_init_embeddings=_embed_norm(init_seqs),
        ref_init_sugars=np.asarray(init_sugs, dtype=np.int64) if init_sugs else None,
        ref_init_record_indices=np.asarray(init_recs, dtype=np.int64) if init_recs else None,
        ref_wzy_embeddings=_embed_norm(wzy_seqs),
        ref_wzy_links=np.asarray(wzy_lnks, dtype=np.int64) if wzy_lnks else None,
        ref_wzy_record_indices=np.asarray(wzy_recs, dtype=np.int64) if wzy_lnks else None,
        multi_edge_clusters=multi_edge_clusters,
        locus_fingerprints=locus_fps,
        cluster_am_counts=cluster_am_counts,
        ref_orf_seqs=tuple(ref_orf_seqs),
        ref_orf_cids=np.asarray(ref_orf_cids, dtype=np.int32) if ref_orf_cids else None,
        ref_orf_roles=np.asarray(ref_orf_roles, dtype=np.int32) if ref_orf_roles else None,
        orf_kmer_index=orf_kmer_index,
    )

    # 4. Load packaged RelationalMPNN (when no CV exclusions) or train RelationalMPNN on kept_indices
    if not excluded and not retrain_mpnn and mpnn_path is not None and mpnn_path.is_file():
        try:
            predictor.load_mpnn(mpnn_path)
        except Exception:
            predictor.fit_mpnn(records, kept_indices=kept_indices, epochs=mpnn_epochs)
    elif mpnn_epochs > 0:
        predictor.fit_mpnn(records, kept_indices=kept_indices, epochs=mpnn_epochs)
        if not excluded and mpnn_path is not None and predictor.mpnn is not None:
            try:
                predictor.save_mpnn(mpnn_path)
            except Exception:
                pass

    return predictor

resolve_locus_homologs

resolve_locus_homologs(locus: Locus) -> Locus

Map raw unclustered LocusPredictor ORFs to Pan-Locus (GeneRole, cluster_id) via 4-mer indexed PairwiseAligner.

Source code in src/kbank/biosynthesis/predictor.py
def resolve_locus_homologs(self, locus: Locus) -> Locus:
    """Map raw unclustered `LocusPredictor` ORFs to Pan-Locus `(GeneRole, cluster_id)` via 4-mer indexed `PairwiseAligner`."""
    if (
        len(locus.nodes) == 0
        or len(locus.genes) == 0
        or not self.ref_orf_seqs
        or self.ref_orf_cids is None
        or self.ref_orf_roles is None
    ):
        return locus

    if self._pairwise_aligner is None:
        self._pairwise_aligner = PairwiseAligner(k=35)

    trans = locus.genes.translations
    new_nodes = list(locus.nodes)
    n_ref = len(self.ref_orf_seqs)
    counts = np.zeros(n_ref, dtype=np.int32)

    for g_i, nd in enumerate(locus.nodes):
        if g_i >= len(trans):
            continue
        aa = bytes(trans[g_i].seq)
        if len(aa) < 60:
            continue
        counts.fill(0)
        for pos in range(len(aa) - 3):
            km = aa[pos : pos + 4]
            hit_list = self._orf_kmer_index.get(km)
            if hit_list is not None:
                counts[hit_list] += 1
        top_cand = np.argsort(counts)[-48:][::-1]
        top_cand = top_cand[counts[top_cand] > 0]
        if len(top_cand) == 0:
            continue
        q_b = Sequences.from_bytes([aa] * len(top_cand))
        t_b = Sequences.from_bytes([self.ref_orf_seqs[int(i)] for i in top_cand])
        alns = self._pairwise_aligner(q_b, t_b)
        bits = alns.bitscores
        pids = alns.pidents
        best_loc_idx = int(np.argmax(bits))
        best_ref_idx = int(top_cand[best_loc_idx])
        best_bit = float(bits[best_loc_idx])
        best_pid = float(pids[best_loc_idx])
        best_cid = int(self.ref_orf_cids[best_ref_idx])
        best_role = GeneRole(int(self.ref_orf_roles[best_ref_idx]))

        cand_roles = self.ref_orf_roles[top_cand]
        same_mask = (cand_roles == int(nd.role)) & (bits >= 95.0) & (pids >= 28.0)

        if best_bit >= 150.0 and best_pid >= 34.0:
            new_nodes[g_i] = GeneNode(best_role, best_cid)
        elif np.any(same_mask):
            idx_s = int(np.argmax(np.where(same_mask, bits, -1.0)))
            ref_s = int(top_cand[idx_s])
            new_nodes[g_i] = GeneNode(nd.role, int(self.ref_orf_cids[ref_s]))

    has_gla = any(n.cluster_id == 240 for n in new_nodes)
    for g_i, nd in enumerate(new_nodes):
        if nd.role == GeneRole.DEACETYLASE and nd.cluster_id == 0 and g_i < len(trans):
            aa_len = len(trans[g_i].seq)
            prev_role = new_nodes[g_i - 1].role if g_i > 0 else GeneRole.UNKNOWN
            if 290 <= aa_len <= 390 and (has_gla or prev_role in (GeneRole.FLIPPASE, GeneRole.POLYMERASE)):
                new_nodes[g_i] = GeneNode(GeneRole.GLYCOSYLTRANSFERASE, 0)

    return replace(locus, nodes=tuple(new_nodes))

fit_mpnn

fit_mpnn(
    records: GlycanRecords,
    *,
    kept_indices: Sequence[int] | None = None,
    epochs: int = 18,
    hidden_dim: int = 64,
    num_layers: int = 3,
    bilinear_rank: int = 32,
    learning_rate: float = 0.004,
    weight_decay: float = 0.0001,
    clamped_prob: float = 0.25,
    include_2hop: bool = True,
    random_state: int = 42,
) -> RelationalMPNN | None

Train serogen.core.graph.gnn.RelationalMPNN on aligned (Locus, Glycan) arborescences.

Source code in src/kbank/biosynthesis/predictor.py
def fit_mpnn(
    self,
    records: GlycanRecords,
    *,
    kept_indices: Sequence[int] | None = None,
    epochs: int = 18,
    hidden_dim: int = 64,
    num_layers: int = 3,
    bilinear_rank: int = 32,
    learning_rate: float = 0.004,
    weight_decay: float = 1e-4,
    clamped_prob: float = 0.25,
    include_2hop: bool = True,
    random_state: int = 42,
) -> RelationalMPNN | None:
    """Train `serogen.core.graph.gnn.RelationalMPNN` on aligned `(Locus, Glycan)` arborescences."""
    self.mpnn_include_2hop = bool(include_2hop)
    indices = list(kept_indices) if kept_indices is not None else list(range(len(records)))
    dl_set: set[int] = set()
    if self.ref_gt_dl_labels is not None and len(self.ref_gt_dl_labels) > 0:
        dl_set.update(int(x) for x in np.unique(self.ref_gt_dl_labels))
    if (
        self.classifier_suite is not None
        and "donor_linkage" in self.classifier_suite.classifiers
        and self.classifier_suite["donor_linkage"].is_fitted
    ):
        dl_set.update(int(x) for x in self.classifier_suite["donor_linkage"].classes_)
    for i in indices:
        gly = records.glycans[i]
        for a in records.assignments[i]:
            if (
                a.is_aligned
                and a.role in (GeneRole.GLYCOSYLTRANSFERASE, GeneRole.TRANSMEMBRANE_SYNTHASE)
                and a.edge_index is not None
                and 0 <= a.edge_index < len(gly.edges)
            ):
                e_a = gly.edges[int(a.edge_index)]
                s_a = gly.nodes[e_a.source]
                if not s_a.is_modifier and e_a.edge_type.anomer != Anomer.UNKNOWN and e_a.edge_type.acceptor > 0:
                    dl_set.add(encode_donor_linkage_label(s_a, e_a.edge_type))
    if len(dl_set) < 2:
        return None

    dl_classes = np.array(sorted(dl_set), dtype=np.int64)
    dl_to_idx = {int(c): idx for idx, c in enumerate(dl_classes)}

    train_graphs: list[tuple[np.ndarray, RelationalCSR, np.ndarray, np.ndarray, np.ndarray, np.ndarray]] = []
    for i in indices:
        loc = records.loci[i]
        gly = records.glycans[i]
        if len(loc.nodes) == 0 or len(gly.nodes) < 2:
            continue
        assigns = records.assignments[i]
        init_g_idx = -1
        for g_i, g_n in enumerate(loc.nodes):
            if g_n.role == GeneRole.INITIATOR:
                init_g_idx = g_i
                break
        gt_g_indices = [
            g_i
            for g_i, g_n in enumerate(loc.nodes)
            if g_n.role in (GeneRole.GLYCOSYLTRANSFERASE, GeneRole.TRANSMEMBRANE_SYNTHASE)
        ]
        if not gt_g_indices:
            continue

        by_gene_edge: dict[int, GeneGlycanAssignment] = {}
        for a in assigns:
            if (
                a.is_aligned
                and a.gene_index in gt_g_indices
                and a.edge_index is not None
                and 0 <= a.edge_index < len(gly.edges)
            ):
                by_gene_edge[int(a.gene_index)] = a
        if len(by_gene_edge) < len(gt_g_indices):
            continue
        core_gly = gly.without_modifiers()
        if len(core_gly.nodes) != 1 + len(gt_g_indices):
            continue
        gt_sources = {int(gly.edges[int(a.edge_index or 0)].source) for a in by_gene_edge.values()}
        if len(gt_sources) != len(gt_g_indices) or gly.reducing_end_index in gt_sources:
            continue

        has_genes = len(loc.genes) > 0
        translations = loc.genes.translations if has_genes else ()
        init_aa = (
            bytes(translations[init_g_idx].seq) if (has_genes and 0 <= init_g_idx < len(translations)) else b""
        )
        slot_seqs: list[bytes] = [init_aa]
        for g_i in gt_g_indices:
            aa_g = bytes(translations[g_i].seq) if (has_genes and g_i < len(translations)) else b""
            slot_seqs.append(aa_g)

        m_nodes = len(slot_seqs)
        node_to_slot: dict[int, int] = {gly.reducing_end_index: 0}
        node_targets = np.full(m_nodes + 1, -1, dtype=np.int32)
        node_weights = np.ones(m_nodes + 1, dtype=np.float32)
        p_bb_init = np.ones(m_nodes, dtype=np.float32)

        for s_idx, g_i in enumerate(gt_g_indices, start=1):
            a_g = by_gene_edge[g_i]
            e_g = gly.edges[int(a_g.edge_index or 0)]
            node_to_slot[int(e_g.source)] = s_idx
            p_bb_init[s_idx] = 1.0 if e_g.edge_type.is_backbone else 0.1
            s_res = gly.nodes[e_g.source]
            if not s_res.is_modifier and e_g.edge_type.anomer != Anomer.UNKNOWN and e_g.edge_type.acceptor > 0:
                dl_code = encode_donor_linkage_label(s_res, e_g.edge_type)
                if dl_code in dl_to_idx:
                    node_targets[s_idx] = dl_to_idx[dl_code]
            w_ev = (
                1.50
                if (a_g.evidence == AssignmentEvidence.PAN_GRAPH_CONSENSUS and a_g.score >= 0.93)
                else (1.25 if a_g.evidence == AssignmentEvidence.PAN_GRAPH_CONSENSUS else 0.85)
            )
            node_weights[s_idx] = float(w_ev * float(a_g.score))

        parent_targets = np.full(m_nodes, -1, dtype=np.int32)
        slot_targets = np.zeros(m_nodes, dtype=np.int32)
        valid_edges = 0
        for s_idx, g_i in enumerate(gt_g_indices, start=1):
            a_g = by_gene_edge[g_i]
            e_g = gly.edges[int(a_g.edge_index or 0)]
            t_slot = node_to_slot.get(int(e_g.target), -1)
            if 0 <= t_slot < m_nodes and t_slot != s_idx:
                parent_targets[s_idx] = t_slot
                slot_targets[s_idx] = min(max(int(e_g.edge_type.acceptor), 0), 7)
                valid_edges += 1
        if valid_edges == 0:
            continue

        _, synth_spec, _ = self._compute_operon_precursor_pool(loc)
        x_nodes, csr_graph, _ = self._build_mpnn_graph_for_locus(
            slot_seqs,
            synth_spec,
            p_bb_init,
            np.zeros(m_nodes, dtype=np.uint8),
        )
        train_graphs.append((x_nodes, csr_graph, node_targets, node_weights, parent_targets, slot_targets))

    if not train_graphs:
        return None

    mpnn = RelationalMPNN(
        in_dim=80,
        hidden_dim=hidden_dim,
        num_node_classes=len(dl_classes),
        num_edge_channels=8,
        num_relations=8,
        num_layers=num_layers,
        bilinear_rank=bilinear_rank,
        learning_rate=learning_rate,
        weight_decay=weight_decay,
        random_state=random_state,
    )
    rng = np.random.default_rng(random_state)
    n_g = len(train_graphs)
    order = np.arange(n_g, dtype=np.int32)
    for ep in range(max(1, epochs)):
        rng.shuffle(order)
        lr_s = float(0.5 * (1.0 + np.cos(np.pi * ep / max(1, epochs))))
        for idx_g in order:
            x_n, csr_g, y_n, w_n, p_t, s_t = train_graphs[int(idx_g)]
            m_sub = len(p_t)
            clamp_m: np.ndarray | None = None
            if m_sub >= 3 and clamped_prob > 0.0 and float(rng.random()) < clamped_prob:
                clamp_m = np.zeros(len(x_n), dtype=bool)
                free_u = int(rng.integers(1, m_sub))
                clamp_m[:m_sub] = True
                clamp_m[free_u] = False
            mpnn.fit_step(
                x_n,
                csr_g,
                node_targets=y_n,
                node_weights=w_n,
                edge_node_indices=np.arange(m_sub, dtype=np.int32),
                edge_parent_targets=p_t,
                edge_slot_targets=s_t,
                clamped_mask=clamp_m,
                lr_scale=max(0.15, lr_s),
            )

    self.mpnn = mpnn
    self.mpnn_dl_classes = dl_classes
    return mpnn

save_mpnn

save_mpnn(path: str | Path = DEFAULT_MPNN_PATH) -> None

Save the fitted RelationalMPNN, mpnn_dl_classes, and GlycanPredictorParams to a zero-pickle .npz archive.

Source code in src/kbank/biosynthesis/predictor.py
def save_mpnn(self, path: str | Path = DEFAULT_MPNN_PATH) -> None:
    """Save the fitted `RelationalMPNN`, `mpnn_dl_classes`, and `GlycanPredictorParams` to a zero-pickle `.npz` archive."""
    if self.mpnn is None or self.mpnn_dl_classes is None:
        return
    out_path = Path(path)
    self.mpnn.save(out_path)
    with np.load(out_path, allow_pickle=False) as data:
        arrays = {k: data[k] for k in data.files}
    arrays["mpnn_dl_classes"] = np.asarray(self.mpnn_dl_classes, dtype=np.int64)
    arrays["arborescence_params"] = self.params.to_array()
    arrays["mpnn_include_2hop"] = np.array([1 if self.mpnn_include_2hop else 0], dtype=np.int8)
    np.savez_compressed(out_path, **arrays)

load_mpnn

load_mpnn(
    path: str | Path = DEFAULT_MPNN_PATH,
) -> RelationalMPNN

Load a fitted RelationalMPNN, mpnn_dl_classes, and GlycanPredictorParams from a zero-pickle .npz archive.

Source code in src/kbank/biosynthesis/predictor.py
def load_mpnn(self, path: str | Path = DEFAULT_MPNN_PATH) -> RelationalMPNN:
    """Load a fitted `RelationalMPNN`, `mpnn_dl_classes`, and `GlycanPredictorParams` from a zero-pickle `.npz` archive."""
    in_path = Path(path)
    mpnn = RelationalMPNN.load(in_path)
    with np.load(in_path, allow_pickle=False) as data:
        dl_classes = data["mpnn_dl_classes"].astype(np.int64) if "mpnn_dl_classes" in data else None
        if "arborescence_params" in data:
            self.params = GlycanPredictorParams.from_array(data["arborescence_params"])
        if "mpnn_include_2hop" in data:
            self.mpnn_include_2hop = bool(int(data["mpnn_include_2hop"][0]) != 0)
    self.mpnn = mpnn
    self.mpnn_dl_classes = dl_classes
    return mpnn

predict

predict(
    locus: Locus,
    *,
    archetype: LocusArchetype | None = None,
    is_repeating: bool = True,
    top_k: int = 5,
    known_assignments: Sequence[GeneGlycanAssignment]
    | dict[int, GeneGlycanAssignment]
    | None = None,
    reference_glycan: Glycan | None = None,
    allow_fingerprint_lookup: bool = True,
    resolve_homologs: bool = True,
) -> tuple[GlycanPrediction, ...]

Predict top-k candidate Glycan graphs de novo from a Locus via 4-stage GNN + Arborescence.

Source code in src/kbank/biosynthesis/predictor.py
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
2047
2048
2049
2050
2051
2052
2053
2054
2055
2056
2057
2058
2059
2060
2061
2062
2063
2064
2065
2066
2067
2068
2069
2070
2071
2072
2073
2074
2075
2076
2077
2078
2079
2080
2081
2082
2083
2084
2085
2086
2087
2088
2089
2090
2091
2092
2093
2094
2095
2096
2097
2098
2099
2100
2101
2102
2103
2104
2105
2106
2107
2108
2109
2110
2111
2112
2113
2114
2115
2116
2117
2118
2119
2120
2121
2122
2123
2124
2125
2126
2127
2128
2129
2130
2131
2132
2133
2134
2135
2136
2137
2138
2139
2140
2141
2142
2143
2144
2145
2146
2147
2148
2149
2150
2151
2152
2153
2154
2155
2156
2157
2158
2159
2160
2161
2162
2163
2164
2165
2166
2167
2168
2169
2170
2171
2172
2173
2174
2175
2176
2177
2178
2179
2180
2181
2182
2183
2184
2185
2186
2187
2188
2189
2190
2191
2192
2193
2194
2195
2196
2197
2198
2199
2200
2201
2202
2203
2204
2205
2206
2207
2208
2209
2210
2211
2212
2213
2214
2215
2216
2217
2218
2219
2220
2221
2222
2223
2224
2225
2226
2227
2228
2229
2230
2231
2232
2233
2234
2235
2236
2237
2238
2239
2240
2241
2242
2243
2244
2245
2246
2247
2248
2249
2250
2251
2252
2253
2254
2255
2256
2257
2258
2259
2260
2261
2262
2263
2264
2265
2266
2267
2268
2269
2270
2271
2272
2273
2274
2275
2276
2277
2278
2279
2280
2281
2282
2283
2284
2285
2286
2287
2288
2289
2290
2291
2292
2293
2294
2295
2296
2297
2298
2299
2300
2301
2302
2303
2304
2305
2306
2307
2308
2309
2310
2311
2312
2313
2314
2315
2316
2317
2318
2319
2320
2321
2322
2323
2324
2325
2326
2327
2328
2329
2330
2331
2332
2333
2334
2335
2336
2337
2338
2339
2340
2341
2342
2343
2344
2345
2346
2347
2348
2349
2350
2351
2352
2353
2354
2355
2356
2357
2358
2359
2360
2361
2362
2363
2364
2365
2366
2367
2368
2369
2370
2371
2372
2373
2374
2375
2376
2377
2378
2379
2380
2381
2382
2383
2384
2385
2386
2387
2388
2389
2390
2391
2392
2393
2394
2395
2396
2397
2398
2399
2400
2401
2402
2403
2404
2405
2406
2407
2408
2409
2410
2411
2412
2413
2414
2415
2416
2417
2418
2419
2420
2421
2422
2423
2424
2425
2426
2427
2428
2429
2430
2431
2432
2433
2434
2435
2436
2437
2438
2439
2440
2441
2442
2443
2444
2445
2446
2447
2448
2449
2450
2451
2452
2453
2454
2455
2456
2457
2458
2459
2460
2461
2462
2463
2464
2465
2466
2467
2468
2469
2470
2471
2472
2473
2474
2475
2476
2477
2478
2479
2480
2481
2482
2483
2484
2485
2486
2487
2488
2489
2490
2491
2492
2493
2494
2495
2496
2497
2498
2499
2500
2501
2502
2503
2504
2505
2506
2507
2508
2509
2510
2511
2512
2513
2514
2515
2516
2517
2518
2519
2520
2521
2522
2523
2524
2525
2526
2527
2528
2529
2530
2531
2532
2533
2534
2535
2536
2537
2538
2539
2540
2541
2542
2543
2544
2545
2546
2547
2548
2549
2550
2551
2552
2553
2554
2555
2556
2557
2558
2559
2560
2561
2562
2563
2564
2565
2566
2567
2568
2569
2570
2571
2572
2573
2574
2575
2576
2577
2578
2579
2580
2581
2582
2583
2584
2585
2586
2587
2588
2589
2590
2591
2592
2593
2594
2595
2596
2597
2598
2599
2600
2601
2602
2603
2604
2605
2606
2607
2608
2609
2610
2611
2612
2613
2614
2615
2616
2617
2618
2619
2620
2621
2622
2623
2624
2625
2626
2627
2628
2629
2630
2631
2632
2633
2634
2635
2636
2637
2638
2639
2640
2641
2642
2643
2644
2645
2646
2647
2648
2649
2650
2651
2652
2653
2654
2655
2656
2657
2658
2659
2660
2661
2662
2663
2664
2665
2666
2667
2668
2669
2670
2671
2672
2673
2674
2675
2676
2677
2678
2679
def predict(
    self,
    locus: Locus,
    *,
    archetype: LocusArchetype | None = None,
    is_repeating: bool = True,
    top_k: int = 5,
    known_assignments: Sequence[GeneGlycanAssignment] | dict[int, GeneGlycanAssignment] | None = None,
    reference_glycan: Glycan | None = None,
    allow_fingerprint_lookup: bool = True,
    resolve_homologs: bool = True,
) -> tuple[GlycanPrediction, ...]:
    """Predict top-`k` candidate `Glycan` graphs de novo from a `Locus` via 4-stage GNN + Arborescence."""
    if len(locus.nodes) == 0:
        return ()

    rescued_locus = (
        self.assigner.rescue_cassette_roles(locus, reference_glycan) if reference_glycan is not None else locus
    )
    if resolve_homologs and len(rescued_locus.genes) > 0 and all(n.cluster_id == 0 for n in rescued_locus.nodes):
        rescued_locus = self.resolve_locus_homologs(rescued_locus)
    arch = archetype if archetype is not None else LocusArchetype.CPS_WZY

    clamped_by_gene: dict[int, GeneGlycanAssignment] = {}
    if isinstance(known_assignments, dict):
        clamped_by_gene.update(known_assignments)
    elif known_assignments is not None:
        for ka in known_assignments:
            if ka.gene_index >= 0:
                clamped_by_gene[int(ka.gene_index)] = ka

    # Stage 2: Compute Operon Precursor Pool (`c_pool`)
    named_pool, synthesized_special, precursor_gate = self._compute_operon_precursor_pool(rescued_locus)

    # Stage 1-3: Predict root candidate(s) and contextualized GT slots
    init_gene_idx, root_cands = self._predict_root_candidates(rescued_locus, arch, clamped_by_gene)
    gt_slots = self._build_gt_slots(
        rescued_locus,
        synthesized_special,
        precursor_gate,
        clamped_by_gene,
        reference_glycan=reference_glycan,
    )

    # Check exact Pan-Locus cluster cassette fingerprint match when allowed and unclamped
    fp_candidates: list[tuple[Glycan, float, tuple[GeneGlycanAssignment, ...]]] = []
    if allow_fingerprint_lookup and not clamped_by_gene:
        fp_cids = tuple(
            int(n.cluster_id)
            for n in rescued_locus.nodes
            if n.cluster_id > 0
            and n.role
            in (
                GeneRole.INITIATOR,
                GeneRole.GLYCOSYLTRANSFERASE,
                GeneRole.POLYMERASE,
                GeneRole.TRANSMEMBRANE_SYNTHASE,
            )
        )
        if fp_cids in self.locus_fingerprints:
            seen_canons: set[bytes] = set()
            for _, fp_gly, _ in self.locus_fingerprints[fp_cids]:
                c_b = fp_gly.to_csdb()
                if c_b not in seen_canons:
                    seen_canons.add(c_b)
                    inferred_assigns = self.assigner.assign_record(
                        fp_gly,
                        rescued_locus,
                        archetype=arch,
                        is_repeating=is_repeating,
                        pan_prior=self.pan_prior,
                    )
                    mean_sc = (
                        float(np.mean([a.score for a in inferred_assigns if a.is_aligned]))
                        if any(a.is_aligned for a in inferred_assigns)
                        else 0.92
                    )
                    fp_candidates.append((fp_gly, 50.0 + mean_sc, inferred_assigns))

    if not gt_slots and not fp_candidates:
        r_res, r_prob, r_ev = root_cands[0]
        g_single = Glycan.from_topology(nodes=(r_res,), edges=())
        a_single = (
            GeneGlycanAssignment(
                gene_index=init_gene_idx,
                role=GeneRole.INITIATOR,
                bipartite_glycan_index=0,
                target_type=TargetType.REDUCING_END,
                residue=r_res,
                linkage=None,
                edge_index=None,
                evidence=r_ev,
                score=float(r_prob),
            ),
        )
        return (
            GlycanPrediction(
                glycan=g_single,
                score=float(r_prob),
                probability=1.0,
                assignments=a_single,
                precursor_pool=named_pool,
            ),
        )

    # Stage 4: Enumerate top node-state combinations & solve Constrained Spanning Arborescences
    m_nodes = 1 + len(gt_slots)
    state_combos: list[
        tuple[
            float,
            ResidueType,
            float,
            AssignmentEvidence,
            list[tuple[ResidueType, LinkageType, float, AssignmentEvidence]],
            list[int],
        ]
    ] = []

    # Generate top state assignments across (root_cands, gt_slots)
    for r_res, r_prob, r_ev in root_cands[:2]:
        base_choices = [slot.candidates[0] for slot in gt_slots]
        base_bb = [1 if slot.p_backbone >= 0.45 else 0 for slot in gt_slots]
        if all(b == 0 for b in base_bb) and base_bb:
            base_bb[0] = 1

        def _score_combo(
            ch_list: list[tuple[ResidueType, LinkageType, float, AssignmentEvidence]],
            bb_list: list[int],
            _r_prob: float = r_prob,
            _r_res: ResidueType = r_res,
        ) -> float:
            sc = float(np.log(max(_r_prob, 1e-3)))
            present_sugars = {int(_r_res.sugar)}
            for idx_c, c_item in enumerate(ch_list):
                sc += float(np.log(max(c_item[2], 1e-3)))
                p_b = gt_slots[idx_c].p_backbone if bb_list[idx_c] == 1 else (1.0 - gt_slots[idx_c].p_backbone)
                sc += self.params.w_backbone_log * float(np.log(max(p_b, 1e-3)))
                present_sugars.add(int(c_item[0].sugar))
            # Stoichiometric precursor coverage bonus
            for sp_s in synthesized_special:
                if sp_s in present_sugars:
                    sc += self.params.w_precursor_bonus
                else:
                    sc -= 0.70 * self.params.w_precursor_bonus
            return sc

        state_combos.append((_score_combo(base_choices, base_bb), r_res, r_prob, r_ev, base_choices, base_bb))

        # Single-slot alternative reactions & branching topology flips
        for s_i, slot in enumerate(gt_slots):
            for alt_c in slot.candidates[1:3]:
                alt_choices = list(base_choices)
                alt_choices[s_i] = alt_c
                state_combos.append(
                    (_score_combo(alt_choices, base_bb), r_res, r_prob, r_ev, alt_choices, list(base_bb))
                )
            if not slot.is_clamped and 0.25 <= slot.p_backbone <= 0.85 and len(gt_slots) >= 2:
                flipped_bb = list(base_bb)
                flipped_bb[s_i] = 1 - flipped_bb[s_i]
                if any(b == 1 for b in flipped_bb):
                    state_combos.append(
                        (
                            _score_combo(base_choices, flipped_bb),
                            r_res,
                            r_prob,
                            r_ev,
                            list(base_choices),
                            flipped_bb,
                        )
                    )
            # Short-backbone + long-branch topology when Wzy specifically closes onto slot s_i's sugar
            if not slot.is_clamped and len(gt_slots) >= 3 and sum(base_bb) >= 2:
                one_bb = [1 if idx_b == s_i else 0 for idx_b in range(len(gt_slots))]
                if one_bb != base_bb:
                    state_combos.append(
                        (_score_combo(base_choices, one_bb), r_res, r_prob, r_ev, list(base_choices), one_bb)
                    )

    state_combos.sort(key=lambda item: item[0], reverse=True)
    raw_candidates: list[tuple[Glycan, float, tuple[GeneGlycanAssignment, ...]]] = list(fp_candidates)

    for combo_sc, r_res, r_prob, r_ev, ch_list, bb_list in state_combos[: max(6, top_k * 2)]:
        score_mat = np.full((m_nodes, m_nodes), -1e9, dtype=np.float64)
        acc_carbons = np.zeros(m_nodes, dtype=np.int32)
        bb_flags = np.ones(m_nodes, dtype=np.uint8)

        slot_res: list[ResidueType] = [r_res]
        slot_lnk: list[LinkageType] = [LinkageType(Anomer.UNKNOWN, 0, 0)]
        for s_i, (res_u, lnk_u, _, _) in enumerate(ch_list):
            is_bb_u = bool(bb_list[s_i] == 1)
            slot_res.append(res_u.with_backbone(is_bb_u).with_reducing_end(False))
            slot_lnk.append(lnk_u.with_topology(is_backbone=is_bb_u, is_polymer=False))
            acc_carbons[s_i + 1] = int(lnk_u.acceptor)
            bb_flags[s_i + 1] = np.uint8(bb_list[s_i])

        # Precompute clamped reference source node -> arborescence slot index map
        ref_node_to_slot: dict[int, int] = {}
        if reference_glycan is not None:
            ref_node_to_slot[reference_glycan.reducing_end_index] = 0
            unclamped_slots: list[int] = []
            for s_idx_c, sl_c in enumerate(gt_slots, start=1):
                if sl_c.is_clamped and sl_c.gene_index in clamped_by_gene:
                    ca_c = clamped_by_gene[sl_c.gene_index]
                    if ca_c.edge_index is not None and 0 <= ca_c.edge_index < len(reference_glycan.edges):
                        ref_node_to_slot[reference_glycan.edges[ca_c.edge_index].source] = s_idx_c
                else:
                    unclamped_slots.append(s_idx_c)
            if len(unclamped_slots) == 1:
                unmapped_core = [
                    n_i
                    for n_i, n_obj in enumerate(reference_glycan.nodes)
                    if not n_obj.is_modifier and n_i not in ref_node_to_slot
                ]
                if len(unmapped_core) == 1:
                    ref_node_to_slot[unmapped_core[0]] = unclamped_slots[0]

        # Fill pairwise directed edge score matrix score_mat[u, w] for u -> w
        sugar_counts: dict[int, int] = {}
        for r_s in slot_res:
            s_key = int(r_s.sugar)
            sugar_counts[s_key] = sugar_counts.get(s_key, 0) + 1

        for u in range(1, m_nodes):
            slot_u = gt_slots[u - 1]
            res_u = slot_res[u]
            lnk_u = slot_lnk[u]
            d_chem_u = encode_donor_residue_chem(res_u)
            l_chem_u = int(lnk_u.chemical_int)
            has_pan_sig_u = (slot_u.cluster_id > 0 and slot_u.cluster_id in self.pan_prior.cluster_sig_full) or (
                self.pan_prior.fine_to_med.get(slot_u.cluster_id, 0) in self.pan_prior.cluster_sig_full
            )

            clamped_target_slot: int | None = None
            clamped_target_chem: int | None = None
            if slot_u.is_clamped and reference_glycan is not None and slot_u.gene_index in clamped_by_gene:
                ca_u = clamped_by_gene[slot_u.gene_index]
                if ca_u.edge_index is not None and 0 <= ca_u.edge_index < len(reference_glycan.edges):
                    e_ref_u = reference_glycan.edges[ca_u.edge_index]
                    clamped_target_slot = ref_node_to_slot.get(e_ref_u.target)
                    clamped_target_chem = encode_donor_residue_chem(reference_glycan.nodes[e_ref_u.target])

            c_acc_u = min(max(int(lnk_u.acceptor), 0), 7)
            mpnn_u = slot_u.mpnn_node_idx
            mpnn_parent_logits: dict[int, float] = {}
            if (
                self._last_mpnn_edge_scores is not None
                and 0 <= mpnn_u < self._last_mpnn_edge_scores.shape[1]
                and c_acc_u < self._last_mpnn_edge_scores.shape[2]
            ):
                raw_p_logits: dict[int, float] = {}
                for w_cand in range(m_nodes):
                    if w_cand == u:
                        continue
                    mpnn_w = 0 if w_cand == 0 else gt_slots[w_cand - 1].mpnn_node_idx
                    if 0 <= mpnn_w < self._last_mpnn_edge_scores.shape[0] and mpnn_w != mpnn_u:
                        raw_p_logits[w_cand] = float(self._last_mpnn_edge_scores[mpnn_w, mpnn_u, c_acc_u])
                if raw_p_logits:
                    mean_p_l = sum(raw_p_logits.values()) / len(raw_p_logits)
                    for w_cand, l_val in raw_p_logits.items():
                        mpnn_parent_logits[w_cand] = 0.55 * float(np.tanh(0.25 * (l_val - mean_p_l)))

            for w in range(m_nodes):
                if u == w:
                    continue
                res_w = slot_res[w]
                a_chem_w = encode_donor_residue_chem(res_w)
                is_dup_sugar_w = sugar_counts.get(int(res_w.sugar), 0) >= 2

                # 0. Hard/soft clamping of parent edge when u is clamped
                if clamped_target_slot is not None:
                    if w == clamped_target_slot:
                        score_mat[u, w] = 30.0
                    else:
                        score_mat[u, w] = -30.0
                    continue

                # 1. GNN / Classifier Acceptor-Sugar & Acceptor-Motif Potential
                p_acc = slot_u.acceptor_probs.get(a_chem_w, 0.04)
                s_uw = self.params.w_acceptor_prob * np.log(max(p_acc, 1e-3))
                if clamped_target_chem is not None:
                    s_uw += 8.0 if a_chem_w == clamped_target_chem else -8.0

                if slot_u.acceptor_motif_probs:
                    if w == 0:
                        p_am = max(
                            (
                                slot_u.acceptor_motif_probs.get(
                                    encode_acceptor_motif_label(res_w, anom_cand, 1),
                                    0.0,
                                )
                                for anom_cand in (Anomer.BETA, Anomer.ALPHA, Anomer.UNKNOWN)
                            ),
                            default=0.0,
                        )
                    else:
                        pos_w = 2 if bb_flags[w] == 1 else 3
                        am_key = encode_acceptor_motif_label(res_w, slot_lnk[w].anomer, pos_w)
                        am_alt = encode_acceptor_motif_label(res_w, slot_lnk[w].anomer, 5 - pos_w)
                        p_am = max(
                            slot_u.acceptor_motif_probs.get(am_key, 0.0),
                            0.85 * slot_u.acceptor_motif_probs.get(am_alt, 0.0),
                        )
                    if p_am >= 0.02:
                        w_am = (
                            self.params.w_acceptor_motif
                            if (is_dup_sugar_w or not has_pan_sig_u)
                            else (0.14 * self.params.w_acceptor_motif)
                        )
                        s_uw += w_am * p_am

                if w in mpnn_parent_logits:
                    w_mpnn_e = (
                        self.params.w_mpnn_edge
                        if (is_dup_sugar_w or not has_pan_sig_u)
                        else (0.20 * self.params.w_mpnn_edge)
                    )
                    s_uw += w_mpnn_e * mpnn_parent_logits[w]

                # 1b. Direct PanGraphConsensusPrior joint (donor, linkage, acceptor_sugar) match for slot_u
                has_exact_pan_acc_uw = False
                for c_tier, tier_scale in (
                    (slot_u.cluster_id, 1.0),
                    (self.pan_prior.fine_to_med.get(slot_u.cluster_id, 0), 0.72),
                ):
                    if c_tier > 0 and c_tier in self.pan_prior.cluster_sig_full:
                        sig_map_u = self.pan_prior.cluster_sig_full[c_tier]
                        tot_u = sum(sig_map_u.values())
                        exact_acc_w = 0.0
                        exact_dl_w = 0.0
                        acc_by_sugar: dict[int, float] = {}
                        for sig_u, cnt_u in sig_map_u.items():
                            if (
                                sig_u[0] == int(res_u.sugar)
                                and sig_u[1] == int(res_u.chirality)
                                and sig_u[2] == int(res_u.is_furanose)
                                and sig_u[3] == int(lnk_u.anomer)
                                and sig_u[4] == int(lnk_u.donor)
                                and sig_u[5] == int(lnk_u.acceptor)
                            ):
                                exact_dl_w += cnt_u
                                acc_s_u = int(sig_u[6])
                                acc_by_sugar[acc_s_u] = acc_by_sugar.get(acc_s_u, 0.0) + cnt_u
                                if acc_s_u == int(res_w.sugar):
                                    exact_acc_w += cnt_u
                        if exact_dl_w > 0:
                            max_acc_share = max(acc_by_sugar.values()) / exact_dl_w if acc_by_sugar else 0.0
                            if max_acc_share >= 0.75:
                                if exact_acc_w > 0:
                                    has_exact_pan_acc_uw = True
                                    s_uw += (
                                        self.params.w_pan_acc_match * tier_scale * (exact_acc_w / max(1e-6, tot_u))
                                    )
                                else:
                                    s_uw -= (0.49 * self.params.w_pan_acc_match) * tier_scale
                            elif exact_acc_w > 0:
                                has_exact_pan_acc_uw = True
                                s_uw += (
                                    0.20
                                    * self.params.w_pan_acc_match
                                    * tier_scale
                                    * (exact_acc_w / max(1e-6, tot_u))
                                )
                        elif tot_u >= 1.8:
                            s_uw -= (0.49 * self.params.w_pan_acc_match) * tier_scale

                # 1c. Terminal leaf / branch-point constraint on parent w
                if w > 0 and not has_exact_pan_acc_uw:
                    slot_w = gt_slots[w - 1]
                    if bb_flags[w] == 0:
                        # Side-chain branch residue w rarely accepts further extensions
                        s_uw -= 3.5
                    if slot_w.cluster_id > 0 and slot_w.cluster_id in self.pan_prior.cluster_sig_full:
                        sig_map_w = self.pan_prior.cluster_sig_full[slot_w.cluster_id]
                        tot_w_s = sum(sig_map_w.values())
                        leaf_w = sum(cnt_w * float(sig_w[8]) for sig_w, cnt_w in sig_map_w.items()) / max(
                            1e-6, tot_w_s
                        )
                        if leaf_w >= 0.70:
                            s_uw -= 5.0 * leaf_w

                # 2. Pan-Glycan Empirical Disaccharide Bigram Prior
                c_exact = self.disaccharide_counts.get((d_chem_u, l_chem_u, a_chem_w), 0)
                c_pair = self.donor_acc_counts.get((d_chem_u, a_chem_w), 0)
                s_uw += self.params.w_disac_bigram * np.log1p(c_exact) + (
                    0.38 * self.params.w_disac_bigram
                ) * np.log1p(c_pair)

                # 3. Pan-Locus Syntenic & Operon Progression Prior
                if w == 0:
                    if init_gene_idx >= 0:
                        init_cid = int(rescued_locus.nodes[init_gene_idx].cluster_id)
                        if init_cid > 0 and slot_u.cluster_id > 0:
                            step1_map = self.pan_prior.init_adj_gt_step1.get((init_cid, slot_u.cluster_id), {})
                            c_init_gt = sum(step1_map.values()) + self.pan_prior.pan_locus_edge_counts.get(
                                (init_cid, slot_u.cluster_id), 0
                            )
                            s_uw += (2.27 * self.params.w_syntenic_step) * np.log1p(c_init_gt)
                    # Step-1 GT is typically at one end of the GT cassette
                    if u == 1 or u == m_nodes - 1:
                        s_uw += 0.64 * self.params.w_syntenic_step
                else:
                    slot_w = gt_slots[w - 1]
                    if slot_u.gene_index == slot_w.gene_index and u == w + 1:
                        s_uw += 12.0
                    elif abs(slot_u.gene_index - slot_w.gene_index) == 1:
                        s_uw += 0.82 * self.params.w_syntenic_step
                    elif abs(u - w) == 1:
                        s_uw += 0.45 * self.params.w_syntenic_step

                    # Processive GT pair disambiguation: main-chain extends distal unit, branch attaches to proximal unit
                    if slot_u.gene_index != slot_w.gene_index:
                        is_w_prox = w < m_nodes - 1 and gt_slots[w].gene_index == slot_w.gene_index
                        is_w_dist = w > 1 and gt_slots[w - 2].gene_index == slot_w.gene_index
                        if is_w_prox:
                            s_uw += 2.5 if bb_flags[u] == 0 else -2.5
                        elif is_w_dist:
                            s_uw += 2.5 if bb_flags[u] == 1 else -2.5
                    if slot_u.cluster_id > 0 and slot_w.cluster_id > 0:
                        c_bg = (
                            self.pan_prior.syntenic_pair_record_counts.get(
                                (slot_w.cluster_id, slot_u.cluster_id), 0
                            )
                            + self.pan_prior.syntenic_pair_record_counts.get(
                                (slot_u.cluster_id, slot_w.cluster_id), 0
                            )
                            + self.pan_prior.pan_locus_edge_counts.get((slot_w.cluster_id, slot_u.cluster_id), 0)
                            + self.pan_prior.pan_locus_edge_counts.get((slot_u.cluster_id, slot_w.cluster_id), 0)
                        )
                        s_uw += self.params.w_syntenic_step * np.log1p(c_bg)

                score_mat[u, w] = s_uw

        best_parents, best_scores, n_found = _solve_constrained_arborescences_kernel(
            score_mat,
            acc_carbons,
            bb_flags,
            max_candidates=max(8, top_k * 3),
        )
        if n_found == 0:
            msa_res = maximum_spanning_arborescence(score_mat.T, root=0, slot_exclusivity=False)
            if msa_res.score > -1e15:
                best_parents[0, :] = msa_res.parents
                best_scores[0] = msa_res.score
                n_found = 1

        for k_idx in range(n_found):
            parents = best_parents[k_idx]
            tree_sc = float(best_scores[k_idx])
            nodes_list: list[ResidueType] = list(slot_res)
            edges_list: list[LinkageEdge] = []
            assigns_list: list[GeneGlycanAssignment] = [
                GeneGlycanAssignment(
                    gene_index=init_gene_idx,
                    role=GeneRole.INITIATOR,
                    bipartite_glycan_index=0,
                    target_type=TargetType.REDUCING_END,
                    residue=slot_res[0],
                    linkage=None,
                    edge_index=None,
                    evidence=r_ev,
                    score=float(r_prob),
                )
            ]

            bb_children_of: dict[int, int] = {}
            used_carbons_on: dict[int, set[int]] = {i_n: set() for i_n in range(m_nodes)}

            for u in range(1, m_nodes):
                w = int(parents[u])
                lnk_u = slot_lnk[u]
                e_idx = len(edges_list)
                edges_list.append(LinkageEdge(source=u, target=w, edge_type=lnk_u))
                if lnk_u.acceptor > 0:
                    used_carbons_on[w].add(int(lnk_u.acceptor))
                if bb_flags[u] == 1:
                    bb_children_of[w] = u

                slot_u = gt_slots[u - 1]
                _, _, cand_prob, cand_ev = ch_list[u - 1]
                assigns_list.append(
                    GeneGlycanAssignment(
                        gene_index=slot_u.gene_index,
                        role=slot_u.role,
                        bipartite_glycan_index=m_nodes + e_idx,
                        target_type=TargetType.LINKAGE,
                        residue=None,
                        linkage=lnk_u,
                        edge_index=e_idx,
                        evidence=cand_ev,
                        score=float(cand_prob),
                    )
                )

            # Trace main-chain backbone tip from root 0
            tip_idx = 0
            visited_tip: set[int] = {0}
            while tip_idx in bb_children_of and bb_children_of[tip_idx] not in visited_tip:
                tip_idx = bb_children_of[tip_idx]
                visited_tip.add(tip_idx)

            # Close polymer repeat with Wzy linkage (`0 -> tip_idx`) when repeating
            has_wzy_gene = any(n.role == GeneRole.POLYMERASE for n in rescued_locus.nodes)
            if (
                is_repeating
                and (
                    has_wzy_gene
                    or arch
                    in (
                        LocusArchetype.CPS_WZY,
                        LocusArchetype.LPS_WZY,
                        LocusArchetype.LPS_ABC,
                        LocusArchetype.CPS_ABC,
                        LocusArchetype.CPS_SYNTHASE,
                    )
                )
                and tip_idx != 0
            ):
                wzy_g_idx, wzy_lt, wzy_conf, wzy_ev = self._predict_wzy_candidates(
                    rescued_locus,
                    nodes_list[0],
                    nodes_list[tip_idx],
                    used_carbons_on.get(tip_idx, set()),
                    clamped_by_gene,
                    reference_glycan=reference_glycan,
                )
                # Score Wzy acceptor sugar specificity against tip_idx
                if wzy_g_idx in clamped_by_gene and reference_glycan is not None:
                    c_w = clamped_by_gene[wzy_g_idx]
                    if c_w.edge_index is not None and 0 <= c_w.edge_index < len(reference_glycan.edges):
                        e_ref_w = reference_glycan.edges[c_w.edge_index]
                        if ref_node_to_slot.get(e_ref_w.target) == tip_idx:
                            tree_sc += 25.0
                        elif encode_donor_residue_chem(nodes_list[tip_idx]) == encode_donor_residue_chem(
                            reference_glycan.nodes[e_ref_w.target]
                        ):
                            tree_sc += 8.0
                        else:
                            tree_sc -= 15.0
                elif wzy_g_idx >= 0:
                    wzy_cid = int(rescued_locus.nodes[wzy_g_idx].cluster_id)
                    if wzy_cid > 0 and wzy_cid in self.pan_prior.cluster_sig_full:
                        w_sigs_all = self.pan_prior.cluster_sig_full[wzy_cid]
                        root_s_id = int(nodes_list[0].sugar)
                        w_sigs_root = {sig_w: cnt_w for sig_w, cnt_w in w_sigs_all.items() if sig_w[0] == root_s_id}
                        w_sigs = w_sigs_root if w_sigs_root else w_sigs_all
                        w_tot = sum(w_sigs.values())
                        w_tip_match = sum(
                            cnt_w
                            for sig_w, cnt_w in w_sigs.items()
                            if sig_w[6] == int(nodes_list[tip_idx].sugar) and sig_w[5] == int(wzy_lt.acceptor)
                        )
                        if w_tip_match > 0:
                            tree_sc += 4.5 * (w_tip_match / max(1e-6, w_tot))
                        else:
                            tree_sc -= 2.5
                tree_sc += 1.5 * float(np.log(max(wzy_conf, 0.1)))
                wzy_e_idx = len(edges_list)
                edges_list.append(LinkageEdge(source=0, target=tip_idx, edge_type=wzy_lt))
                assigns_list.append(
                    GeneGlycanAssignment(
                        gene_index=wzy_g_idx,
                        role=GeneRole.POLYMERASE,
                        bipartite_glycan_index=m_nodes + wzy_e_idx,
                        target_type=TargetType.LINKAGE,
                        residue=None,
                        linkage=wzy_lt,
                        edge_index=wzy_e_idx,
                        evidence=wzy_ev,
                        score=float(wzy_conf),
                    )
                )

            # Attach modifier substituents from tailing modifier ORFs
            gly_core = Glycan.from_topology(nodes=nodes_list, edges=edges_list)
            comp_gly, _, comp_map = self.assigner.complete_glycan_modifiers(
                gly_core, rescued_locus, pan_prior=self.pan_prior
            )
            if comp_map:
                gly_final = Glycan.from_topology(nodes=comp_gly.nodes, edges=comp_gly.edges)
                mod_assigns = list(assigns_list)
                has_obligate_mod = False
                for mod_node_idx, mod_gene_idx in sorted(comp_map.items()):
                    mod_res = gly_final.nodes[mod_node_idx]
                    m_role = rescued_locus.nodes[mod_gene_idx].role
                    has_specific_mod_edge = any(
                        e_m.source == mod_node_idx and e_m.edge_type.acceptor > 0 for e_m in gly_final.edges
                    )
                    if has_specific_mod_edge or m_role in (
                        GeneRole.PYRUVYLTRANSFERASE,
                        GeneRole.FORMYLTRANSFERASE,
                        GeneRole.ALANINE_TRANSFERASE,
                    ):
                        has_obligate_mod = True
                    mod_assigns.append(
                        GeneGlycanAssignment(
                            gene_index=mod_gene_idx,
                            role=m_role,
                            bipartite_glycan_index=mod_node_idx,
                            target_type=TargetType.MODIFIED_MONO,
                            residue=mod_res,
                            linkage=None,
                            edge_index=None,
                            evidence=AssignmentEvidence.GRAPH_ALIGNED,
                            score=0.86,
                        )
                    )
                if has_obligate_mod:
                    raw_candidates.append((gly_final, combo_sc + tree_sc + 0.25, tuple(mod_assigns)))
                    raw_candidates.append((gly_core, combo_sc + tree_sc, tuple(assigns_list)))
                else:
                    raw_candidates.append((gly_core, combo_sc + tree_sc + 0.25, tuple(assigns_list)))
                    raw_candidates.append((gly_final, combo_sc + tree_sc, tuple(mod_assigns)))
            else:
                raw_candidates.append((gly_core, combo_sc + tree_sc, tuple(assigns_list)))

    # Deduplicate candidates by canonical CSDB string (or topological isomorphism) and softmax-normalize
    unique_cands: list[tuple[Glycan, float, tuple[GeneGlycanAssignment, ...]]] = []
    seen_csdb: set[bytes] = set()
    raw_candidates.sort(key=lambda item: item[1], reverse=True)
    for cand_gly, cand_sc, cand_asg in raw_candidates:
        c_bytes = cand_gly.canonical or cand_gly.to_csdb()
        if c_bytes in seen_csdb:
            continue
        seen_csdb.add(c_bytes)
        unique_cands.append((cand_gly, cand_sc, cand_asg))
        if len(unique_cands) >= top_k:
            break

    if not unique_cands:
        return ()

    raw_scores = np.array([c[1] for c in unique_cands], dtype=np.float64)
    shifted = (raw_scores - np.max(raw_scores)) * 0.5
    exp_s = np.exp(np.clip(shifted, -30.0, 0.0))
    probs = exp_s / np.sum(exp_s)

    return tuple(
        GlycanPrediction(
            glycan=unique_cands[i][0],
            score=round(float(unique_cands[i][1]), 4),
            probability=round(float(probs[i]), 4),
            assignments=unique_cands[i][2],
            precursor_pool=named_pool,
        )
        for i in range(len(unique_cands))
    )

Comments