Skip to content

kbank.db & kbank.io

Curated bacterial surface polysaccharide database records, multi-database curation pipelines, and CSDB / IUPAC / KEGG structural parsers.


Record & Metadata Models (kbank.db.models)

models

Data models for multi-database bacterial glycan records and de novo SeroGen locus pairings.

Provides immutable dataclasses for individual glycan entries integrated from CSDB, ECODAB, EK3D, EPSDB, and KEGG Glycan, paired with de novo predicted biosynthetic gene clusters (Locus, LocusArchetype) from serogen.

Classes:

Name Description
GlycanRecord

Immutable record for a bacterial surface glycan entry across integrated databases.

GlycanRecord dataclass

Immutable record for a bacterial surface glycan entry across integrated databases.

Consolidates taxonomic metadata, literature provenance, structural notations, parsed Glycan graphs, de novo matched Locus gene clusters from serogen, and resolved GeneGlycanAssignment bipartite edges. Uses byte strings for textual fields to ensure zero-copy compatibility with Structure-of-Arrays (SoA) containers.

Attributes:

Name Type Description
source_db bytes

Source database identifier (b"CSDB", b"ECODAB", b"EK3D", b"EPSDB", b"KEGG").

record_id bytes

Primary identifier within the source database (e.g. b"70", b"K1", b"G00525").

organism bytes

Canonical bacterial organism name (e.g. b"Escherichia coli").

strain bytes

Specific bacterial strain designation (e.g. b"K12", b"ATCC 43816").

serotype bytes

Serotype or antigen designation (e.g. b"O1", b"K2").

antigen_type bytes

Surface polysaccharide antigen class (b"CPS", b"O-antigen", b"EPS", b"WTA", b"LPS").

assembly_id bytes

Linked genome assembly or nucleotide accession (e.g. b"GCF_000005845.2", b"AB811615").

archetype LocusArchetype

Biosynthetic locus archetype (LocusArchetype) matched or inferred for this glycan.

locus Locus | None

Matched Locus biosynthetic gene cluster, or None if unpaired.

alignment_score float

Combined geno-glycomic alignment score in [0.0, 1.0] from BiosyntheticAssigner.

assignments tuple[GeneGlycanAssignment, ...]

Resolved gene-to-glycan component assignments (GRAPH_ALIGNED or RULE_INFERRED).

structure_raw bytes

Raw structure notation from the source database.

structure_linear bytes

Normalized linear sequence notation (CSDB linear code or IUPAC condensed).

glycan Glycan | None

Parsed Glycan core graph instance, or None if unparsed.

is_repeating bool

Whether the structure represents a repeating polymer unit.

pmid int

PubMed ID integer, or 0 if unknown.

publication_year int

Four-digit year of primary publication, or 0 if unknown.

doi bytes

Digital Object Identifier byte string.

glytoucan_id bytes

GlyTouCan accession identifier (e.g. b"G12345AB"), or empty byte string.

notes bytes

Additional curation notes or cross-reference annotations.

Methods:

Name Description
format_assignments

Compact human-readable summary string of all gene-to-glycan assignments.

Source code in src/kbank/db/models.py
@dataclass(frozen=True, slots=True)
class GlycanRecord:
    r"""Immutable record for a bacterial surface glycan entry across integrated databases.

    Consolidates taxonomic metadata, literature provenance, structural notations, parsed
    [`Glycan`][kbank.glycan.Glycan] graphs, de novo matched [`Locus`][serogen.loci.Locus]
    gene clusters from `serogen`, and resolved [`GeneGlycanAssignment`][kbank.biosynthesis.GeneGlycanAssignment]
    bipartite edges. Uses byte strings for textual fields to ensure zero-copy compatibility with
    Structure-of-Arrays (SoA) containers.

    Attributes:
        source_db: Source database identifier (`b"CSDB"`, `b"ECODAB"`, `b"EK3D"`, `b"EPSDB"`, `b"KEGG"`).
        record_id: Primary identifier within the source database (e.g. `b"70"`, `b"K1"`, `b"G00525"`).
        organism: Canonical bacterial organism name (e.g. `b"Escherichia coli"`).
        strain: Specific bacterial strain designation (e.g. `b"K12"`, `b"ATCC 43816"`).
        serotype: Serotype or antigen designation (e.g. `b"O1"`, `b"K2"`).
        antigen_type: Surface polysaccharide antigen class (`b"CPS"`, `b"O-antigen"`, `b"EPS"`, `b"WTA"`, `b"LPS"`).
        assembly_id: Linked genome assembly or nucleotide accession (e.g. `b"GCF_000005845.2"`, `b"AB811615"`).
        archetype: Biosynthetic locus archetype (`LocusArchetype`) matched or inferred for this glycan.
        locus: Matched [`Locus`][serogen.loci.Locus] biosynthetic gene cluster, or `None` if unpaired.
        alignment_score: Combined geno-glycomic alignment score in `[0.0, 1.0]` from `BiosyntheticAssigner`.
        assignments: Resolved gene-to-glycan component assignments (`GRAPH_ALIGNED` or `RULE_INFERRED`).
        structure_raw: Raw structure notation from the source database.
        structure_linear: Normalized linear sequence notation (CSDB linear code or IUPAC condensed).
        glycan: Parsed [`Glycan`][kbank.glycan.Glycan] core graph instance, or `None` if unparsed.
        is_repeating: Whether the structure represents a repeating polymer unit.
        pmid: PubMed ID integer, or `0` if unknown.
        publication_year: Four-digit year of primary publication, or `0` if unknown.
        doi: Digital Object Identifier byte string.
        glytoucan_id: GlyTouCan accession identifier (e.g. `b"G12345AB"`), or empty byte string.
        notes: Additional curation notes or cross-reference annotations.
    """

    source_db: bytes
    record_id: bytes
    organism: bytes
    strain: bytes = b""
    serotype: bytes = b""
    antigen_type: bytes = b""
    assembly_id: bytes = b""
    archetype: LocusArchetype = LocusArchetype.UNKNOWN
    locus: Locus | None = None
    alignment_score: float = 0.0
    assignments: tuple[GeneGlycanAssignment, ...] = ()
    structure_raw: bytes = b""
    structure_linear: bytes = b""
    glycan: Glycan | None = None
    is_repeating: bool = True
    pmid: int = 0
    publication_year: int = 0
    doi: bytes = b""
    glytoucan_id: bytes = b""
    notes: bytes = b""

    @property
    def compound_key(self) -> bytes:
        r"""Global unique identifier combining source database and primary record ID.

        Returns:
            bytes: Byte string formatted as `b"<source_db>:<record_id>"` (e.g. `b"ECODAB:70"`).
        """
        return b"%s:%s" % (self.source_db, self.record_id)

    @property
    def has_locus(self) -> bool:
        r"""True if this glycan record is paired with a non-empty biosynthetic `Locus`."""
        return self.locus is not None and len(self.locus.nodes) > 0

    @property
    def num_assignments(self) -> int:
        r"""Total number of gene-to-glycan component assignments on this record."""
        return len(self.assignments)

    @property
    def num_aligned_assignments(self) -> int:
        r"""Number of graph-aligned ORF-to-glycan component assignments (`gene_index >= 0`)."""
        return sum(1 for a in self.assignments if a.is_aligned)

    @property
    def num_pan_consensus_assignments(self) -> int:
        r"""Number of assignments resolved by multi-record pan-graph homology or synteny consensus."""
        return sum(1 for a in self.assignments if a.is_pan_consensus)

    @property
    def num_rule_assignments(self) -> int:
        r"""Number of stoichiometric rule-inferred `GeneRole`-to-glycan component assignments."""
        return sum(1 for a in self.assignments if not a.is_aligned)

    def format_assignments(self) -> str:
        r"""Compact human-readable summary string of all gene-to-glycan assignments."""
        return "; ".join(a.format_short() for a in self.assignments)

compound_key property

compound_key: bytes

Global unique identifier combining source database and primary record ID.

Returns:

Name Type Description
bytes bytes

Byte string formatted as b"<source_db>:<record_id>" (e.g. b"ECODAB:70").

has_locus property

has_locus: bool

True if this glycan record is paired with a non-empty biosynthetic Locus.

num_assignments property

num_assignments: int

Total number of gene-to-glycan component assignments on this record.

num_aligned_assignments property

num_aligned_assignments: int

Number of graph-aligned ORF-to-glycan component assignments (gene_index >= 0).

num_pan_consensus_assignments property

num_pan_consensus_assignments: int

Number of assignments resolved by multi-record pan-graph homology or synteny consensus.

num_rule_assignments property

num_rule_assignments: int

Number of stoichiometric rule-inferred GeneRole-to-glycan component assignments.

format_assignments

format_assignments() -> str

Compact human-readable summary string of all gene-to-glycan assignments.

Source code in src/kbank/db/models.py
def format_assignments(self) -> str:
    r"""Compact human-readable summary string of all gene-to-glycan assignments."""
    return "; ".join(a.format_short() for a in self.assignments)

Batched SoA Glycan–Locus Database (kbank.db.records)

records

Structure-of-Arrays (SoA) batch container for bacterial glycan records and paired biosynthetic loci.

Classes:

Name Description
GlycanRecords

Pure Structure-of-Arrays (SoA) batch container for consolidated bacterial glycan records.

GlycanRecords dataclass

Bases: BatchedContainer[GlycanRecord, 'GlycanRecords']

Pure Structure-of-Arrays (SoA) batch container for consolidated bacterial glycan records.

Inherits from BatchedContainer to provide vectorized operations, contiguous flat NumPy arrays, embedded Glycans, Loci, and GeneGlycanAssignments batch representations, zero-pickle .npz serialization, and seamless slice / boolean / integer mask indexing. Individual GlycanRecord scalar views are materialized on demand when indexing a single row records[i].

Attributes:

Name Type Description
source_dbs ndarray[tuple[int], dtype[bytes_]]

1D array of source database identifier byte strings ("S").

record_ids ndarray[tuple[int], dtype[bytes_]]

1D array of primary record identifiers ("S").

organisms ndarray[tuple[int], dtype[bytes_]]

1D array of canonical bacterial organism names ("S").

strains ndarray[tuple[int], dtype[bytes_]]

1D array of bacterial strain designations ("S").

serotypes ndarray[tuple[int], dtype[bytes_]]

1D array of serotype designations ("S").

antigen_types ndarray[tuple[int], dtype[bytes_]]

1D array of surface antigen categories ("S").

assembly_ids ndarray[tuple[int], dtype[bytes_]]

1D array of linked genome assembly or nucleotide accessions ("S").

compound_keys ndarray[tuple[int], dtype[bytes_]]

1D array of compound keys (b"<source_db>:<record_id>") ("S").

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

1D int8 array of LocusArchetype integer IDs (0..10).

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

1D float32 array of geno-glycomic alignment scores in [0.0, 1.0].

pmids ndarray[tuple[int], dtype[int64]]

1D int64 array of PubMed IDs.

years ndarray[tuple[int], dtype[int16]]

1D int16 array of publication years.

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

1D boolean array indicating whether the glycan represents a polymer repeat unit.

glycans Glycans

Embedded Glycans batch of all parsed glycan graphs.

loci Loci

Embedded Loci batch of all paired biosynthetic gene clusters.

assignments GeneGlycanAssignments

Embedded GeneGlycanAssignments ragged batch of cross-domain gene-to-glycan edges.

structure_raws ndarray[tuple[int], dtype[bytes_]]

1D array of raw structure strings from the source database ("S").

structure_linears ndarray[tuple[int], dtype[bytes_]]

1D array of normalized linear structure strings ("S").

dois ndarray[tuple[int], dtype[bytes_]]

1D array of Digital Object Identifier byte strings ("S").

glytoucan_ids ndarray[tuple[int], dtype[bytes_]]

1D array of GlyTouCan accession byte strings ("S").

notes ndarray[tuple[int], dtype[bytes_]]

1D array of curation notes and provenance tags ("S").

Methods:

Name Description
where_organism

Filters records by exact bacterial organism name.

where_serotype

Filters records by serotype designation.

where_source_db

Filters records by source database name (e.g. b"CSDB", b"ECODAB").

where_assembly_id

Filters records by genome assembly or nucleotide accession.

where_archetype

Filters records by LocusArchetype.

where_has_locus

Filters records to those paired with a non-empty biosynthetic Locus.

empty

Constructs an empty, zero-length GlycanRecords container.

concat

Concatenates multiple GlycanRecords batches into a single container.

build

Compiles an iterable of GlycanRecord instances into a pure SoA GlycanRecords batch.

abuild

Asynchronously compiles an async stream of GlycanRecord instances into a GlycanRecords batch.

save

Serializes the GlycanRecords instance to disk as a compressed NumPy .npz archive (zero pickle).

load

Deserializes a GlycanRecords instance from a compressed NumPy .npz archive (zero pickle, zero eager Python loop).

pretrained

Loads the bundled curated GlycanRecords database (src/kbank/data/glycans.npz).

Source code in src/kbank/db/records.py
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
@dataclass(slots=True, frozen=True)
class GlycanRecords(BatchedContainer[GlycanRecord, "GlycanRecords"]):
    r"""Pure Structure-of-Arrays (SoA) batch container for consolidated bacterial glycan records.

    Inherits from [`BatchedContainer`][serogen.core.collections.BatchedContainer] to provide
    vectorized operations, contiguous flat NumPy arrays, embedded [`Glycans`][kbank.glycan.Glycans],
    [`Loci`][serogen.loci.Loci], and [`GeneGlycanAssignments`][kbank.biosynthesis.GeneGlycanAssignments]
    batch representations, zero-pickle `.npz` serialization, and seamless slice / boolean / integer mask
    indexing. Individual [`GlycanRecord`][kbank.db.models.GlycanRecord] scalar views are materialized on
    demand when indexing a single row `records[i]`.

    Attributes:
        source_dbs: 1D array of source database identifier byte strings (`"S"`).
        record_ids: 1D array of primary record identifiers (`"S"`).
        organisms: 1D array of canonical bacterial organism names (`"S"`).
        strains: 1D array of bacterial strain designations (`"S"`).
        serotypes: 1D array of serotype designations (`"S"`).
        antigen_types: 1D array of surface antigen categories (`"S"`).
        assembly_ids: 1D array of linked genome assembly or nucleotide accessions (`"S"`).
        compound_keys: 1D array of compound keys (`b"<source_db>:<record_id>"`) (`"S"`).
        archetypes: 1D `int8` array of `LocusArchetype` integer IDs (`0..10`).
        alignment_scores: 1D `float32` array of geno-glycomic alignment scores in `[0.0, 1.0]`.
        pmids: 1D `int64` array of PubMed IDs.
        years: 1D `int16` array of publication years.
        is_repeating: 1D boolean array indicating whether the glycan represents a polymer repeat unit.
        glycans: Embedded [`Glycans`][kbank.glycan.Glycans] batch of all parsed glycan graphs.
        loci: Embedded [`Loci`][serogen.loci.Loci] batch of all paired biosynthetic gene clusters.
        assignments: Embedded [`GeneGlycanAssignments`][kbank.biosynthesis.GeneGlycanAssignments] ragged batch of cross-domain gene-to-glycan edges.
        structure_raws: 1D array of raw structure strings from the source database (`"S"`).
        structure_linears: 1D array of normalized linear structure strings (`"S"`).
        dois: 1D array of Digital Object Identifier byte strings (`"S"`).
        glytoucan_ids: 1D array of GlyTouCan accession byte strings (`"S"`).
        notes: 1D array of curation notes and provenance tags (`"S"`).
    """

    source_dbs: np.ndarray[tuple[int], np.dtype[np.bytes_]]
    record_ids: np.ndarray[tuple[int], np.dtype[np.bytes_]]
    organisms: np.ndarray[tuple[int], np.dtype[np.bytes_]]
    strains: np.ndarray[tuple[int], np.dtype[np.bytes_]]
    serotypes: np.ndarray[tuple[int], np.dtype[np.bytes_]]
    antigen_types: np.ndarray[tuple[int], np.dtype[np.bytes_]]
    assembly_ids: np.ndarray[tuple[int], np.dtype[np.bytes_]]
    compound_keys: np.ndarray[tuple[int], np.dtype[np.bytes_]]
    archetypes: np.ndarray[tuple[int], np.dtype[np.int8]]
    alignment_scores: np.ndarray[tuple[int], np.dtype[np.float32]]
    pmids: np.ndarray[tuple[int], np.dtype[np.int64]]
    years: np.ndarray[tuple[int], np.dtype[np.int16]]
    is_repeating: np.ndarray[tuple[int], np.dtype[np.bool_]]
    glycans: Glycans
    loci: Loci
    assignments: GeneGlycanAssignments
    structure_raws: np.ndarray[tuple[int], np.dtype[np.bytes_]] = field(default_factory=lambda: np.empty(0, dtype="S1"))
    structure_linears: np.ndarray[tuple[int], np.dtype[np.bytes_]] = field(
        default_factory=lambda: np.empty(0, dtype="S1")
    )
    dois: np.ndarray[tuple[int], np.dtype[np.bytes_]] = field(default_factory=lambda: np.empty(0, dtype="S1"))
    glytoucan_ids: np.ndarray[tuple[int], np.dtype[np.bytes_]] = field(default_factory=lambda: np.empty(0, dtype="S1"))
    notes: np.ndarray[tuple[int], np.dtype[np.bytes_]] = field(default_factory=lambda: np.empty(0, dtype="S1"))

    def __post_init__(self) -> None:
        n = len(self.source_dbs)
        if n > 0:
            for attr in ("structure_raws", "structure_linears", "dois", "glytoucan_ids", "notes"):
                arr = getattr(self, attr)
                if len(arr) == 0:
                    object.__setattr__(self, attr, np.zeros(n, dtype="S1"))

    def __len__(self) -> int:
        r"""Returns the total number of records in the batch container."""
        return len(self.source_dbs)

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

    def __contains__(self, item: object) -> bool:
        r"""Checks membership by `GlycanRecord`, compound key, or assembly ID."""
        if isinstance(item, GlycanRecord):
            return bool(np.any(self.compound_keys == item.compound_key))
        if isinstance(item, str):
            item = item.encode("utf-8")
        if isinstance(item, bytes):
            return bool(np.any(self.compound_keys == item) or np.any(self.assembly_ids == item))
        return False

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

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

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

    def __getitem__(
        self,
        item: int | np.integer[Any] | slice | np.ndarray[tuple[int], Any] | Sequence[Any],
    ) -> GlycanRecord | Self:
        r"""Access a materialized `GlycanRecord` by scalar index, or slice/mask the SoA container."""
        if isinstance(item, (int, np.integer)):
            idx = int(item)
            n = len(self)
            if idx < 0:
                idx += n
            if idx < 0 or idx >= n:
                raise IndexError(f"GlycanRecords index {item} out of range for batch of length {n}.")

            g_obj = self.glycans[idx]
            loc_obj = self.loci[idx]
            return GlycanRecord(
                source_db=bytes(self.source_dbs[idx]),
                record_id=bytes(self.record_ids[idx]),
                organism=bytes(self.organisms[idx]),
                strain=bytes(self.strains[idx]),
                serotype=bytes(self.serotypes[idx]),
                antigen_type=bytes(self.antigen_types[idx]),
                assembly_id=bytes(self.assembly_ids[idx]),
                archetype=LocusArchetype(int(self.archetypes[idx])),
                locus=loc_obj if len(loc_obj.nodes) > 0 else None,
                alignment_score=float(self.alignment_scores[idx]),
                assignments=self.assignments[idx],
                structure_raw=bytes(self.structure_raws[idx]),
                structure_linear=bytes(self.structure_linears[idx]),
                glycan=g_obj if len(g_obj.nodes) > 0 else None,
                is_repeating=bool(self.is_repeating[idx]),
                pmid=int(self.pmids[idx]),
                publication_year=int(self.years[idx]),
                doi=bytes(self.dois[idx]),
                glytoucan_id=bytes(self.glytoucan_ids[idx]),
                notes=bytes(self.notes[idx]),
            )

        if isinstance(item, slice):
            return type(self)(
                source_dbs=self.source_dbs[item],
                record_ids=self.record_ids[item],
                organisms=self.organisms[item],
                strains=self.strains[item],
                serotypes=self.serotypes[item],
                antigen_types=self.antigen_types[item],
                assembly_ids=self.assembly_ids[item],
                compound_keys=self.compound_keys[item],
                archetypes=self.archetypes[item],
                alignment_scores=self.alignment_scores[item],
                pmids=self.pmids[item],
                years=self.years[item],
                is_repeating=self.is_repeating[item],
                glycans=self.glycans[item],
                loci=self.loci[item],
                assignments=self.assignments[item],
                structure_raws=self.structure_raws[item],
                structure_linears=self.structure_linears[item],
                dois=self.dois[item],
                glytoucan_ids=self.glytoucan_ids[item],
                notes=self.notes[item],
            )

        arr_item = np.asarray(item)
        if arr_item.dtype == bool:
            if len(arr_item) != len(self):
                raise ValueError(f"Mask length {len(arr_item)} does not match batch length {len(self)}.")
            return type(self)(
                source_dbs=self.source_dbs[arr_item],
                record_ids=self.record_ids[arr_item],
                organisms=self.organisms[arr_item],
                strains=self.strains[arr_item],
                serotypes=self.serotypes[arr_item],
                antigen_types=self.antigen_types[arr_item],
                assembly_ids=self.assembly_ids[arr_item],
                compound_keys=self.compound_keys[arr_item],
                archetypes=self.archetypes[arr_item],
                alignment_scores=self.alignment_scores[arr_item],
                pmids=self.pmids[arr_item],
                years=self.years[arr_item],
                is_repeating=self.is_repeating[arr_item],
                glycans=self.glycans[arr_item],
                loci=self.loci[arr_item],
                assignments=self.assignments[arr_item],
                structure_raws=self.structure_raws[arr_item],
                structure_linears=self.structure_linears[arr_item],
                dois=self.dois[arr_item],
                glytoucan_ids=self.glytoucan_ids[arr_item],
                notes=self.notes[arr_item],
            )

        if np.issubdtype(arr_item.dtype, np.integer) or len(arr_item) == 0:
            indices = self._normalize_indices(arr_item)
            if len(indices) == 0:
                return self.empty()
            return type(self)(
                source_dbs=self.source_dbs[indices],
                record_ids=self.record_ids[indices],
                organisms=self.organisms[indices],
                strains=self.strains[indices],
                serotypes=self.serotypes[indices],
                antigen_types=self.antigen_types[indices],
                assembly_ids=self.assembly_ids[indices],
                compound_keys=self.compound_keys[indices],
                archetypes=self.archetypes[indices],
                alignment_scores=self.alignment_scores[indices],
                pmids=self.pmids[indices],
                years=self.years[indices],
                is_repeating=self.is_repeating[indices],
                glycans=self.glycans[indices],
                loci=self.loci[indices],
                assignments=self.assignments[indices],
                structure_raws=self.structure_raws[indices],
                structure_linears=self.structure_linears[indices],
                dois=self.dois[indices],
                glytoucan_ids=self.glytoucan_ids[indices],
                notes=self.notes[indices],
            )

        raise TypeError(f"Unsupported index type: {type(item)}")

    # Vectorized filtering methods relying directly on __getitem__ masking
    def where_organism(self, organism: bytes | str) -> Self:
        r"""Filters records by exact bacterial organism name."""
        org_b = organism.encode("utf-8") if isinstance(organism, str) else organism
        return self[self.organisms == org_b]

    def where_serotype(self, serotype: bytes | str) -> Self:
        r"""Filters records by serotype designation."""
        sero_b = serotype.encode("utf-8") if isinstance(serotype, str) else serotype
        return self[self.serotypes == sero_b]

    def where_source_db(self, source: bytes | str) -> Self:
        r"""Filters records by source database name (e.g. `b"CSDB"`, `b"ECODAB"`)."""
        src_b = source.encode("utf-8") if isinstance(source, str) else source
        return self[self.source_dbs == src_b]

    def where_assembly_id(self, assembly_id: bytes | str) -> Self:
        r"""Filters records by genome assembly or nucleotide accession."""
        asm_b = assembly_id.encode("utf-8") if isinstance(assembly_id, str) else assembly_id
        return self[self.assembly_ids == asm_b]

    def where_archetype(self, archetype: LocusArchetype | int | str | bytes) -> Self:
        r"""Filters records by `LocusArchetype`."""
        arch = LocusArchetype.from_name(archetype)
        return self[self.archetypes == int(arch)]

    def where_has_locus(self) -> Self:
        r"""Filters records to those paired with a non-empty biosynthetic `Locus`."""
        has_loc = np.diff(self.loci.node_offsets) > 0
        return self[has_loc]

    # Property aliases for convenience
    @property
    def organism(self) -> np.ndarray[tuple[int], np.dtype[np.bytes_]]:
        r"""Alias for `organisms`."""
        return self.organisms

    @property
    def serotype(self) -> np.ndarray[tuple[int], np.dtype[np.bytes_]]:
        r"""Alias for `serotypes`."""
        return self.serotypes

    @property
    def source_db(self) -> np.ndarray[tuple[int], np.dtype[np.bytes_]]:
        r"""Alias for `source_dbs`."""
        return self.source_dbs

    @property
    def assembly_id(self) -> np.ndarray[tuple[int], np.dtype[np.bytes_]]:
        r"""Alias for `assembly_ids`."""
        return self.assembly_ids

    @property
    def compound_key(self) -> np.ndarray[tuple[int], np.dtype[np.bytes_]]:
        r"""Alias for `compound_keys`."""
        return self.compound_keys

    @property
    def structure_raw(self) -> np.ndarray[tuple[int], np.dtype[np.bytes_]]:
        r"""Alias for `structure_raws`."""
        return self.structure_raws

    @property
    def structure_linear(self) -> np.ndarray[tuple[int], np.dtype[np.bytes_]]:
        r"""Alias for `structure_linears`."""
        return self.structure_linears

    @property
    def doi(self) -> np.ndarray[tuple[int], np.dtype[np.bytes_]]:
        r"""Alias for `dois`."""
        return self.dois

    @property
    def glytoucan_id(self) -> np.ndarray[tuple[int], np.dtype[np.bytes_]]:
        r"""Alias for `glytoucan_ids`."""
        return self.glytoucan_ids

    @property
    def has_locus(self) -> np.ndarray[tuple[int], np.dtype[np.bool_]]:
        r"""Boolean mask indicating which records have a non-empty paired `Locus`."""
        return np.diff(self.loci.node_offsets) > 0

    @classmethod
    def empty(cls) -> Self:
        r"""Constructs an empty, zero-length `GlycanRecords` container."""
        return cls(
            source_dbs=np.empty(0, dtype="S16"),
            record_ids=np.empty(0, dtype="S32"),
            organisms=np.empty(0, dtype="S64"),
            strains=np.empty(0, dtype="S64"),
            serotypes=np.empty(0, dtype="S32"),
            antigen_types=np.empty(0, dtype="S32"),
            assembly_ids=np.empty(0, dtype="S32"),
            compound_keys=np.empty(0, dtype="S48"),
            archetypes=np.empty(0, dtype=np.int8),
            alignment_scores=np.empty(0, dtype=np.float32),
            pmids=np.empty(0, dtype=np.int64),
            years=np.empty(0, dtype=np.int16),
            is_repeating=np.empty(0, dtype=bool),
            glycans=Glycans.empty(),
            loci=Loci.empty(),
            assignments=GeneGlycanAssignments.empty(),
            structure_raws=np.empty(0, dtype="S1"),
            structure_linears=np.empty(0, dtype="S1"),
            dois=np.empty(0, dtype="S1"),
            glytoucan_ids=np.empty(0, dtype="S1"),
            notes=np.empty(0, dtype="S1"),
        )

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

        return cls(
            source_dbs=np.concatenate([b.source_dbs for b in batch_list]),
            record_ids=np.concatenate([b.record_ids for b in batch_list]),
            organisms=np.concatenate([b.organisms for b in batch_list]),
            strains=np.concatenate([b.strains for b in batch_list]),
            serotypes=np.concatenate([b.serotypes for b in batch_list]),
            antigen_types=np.concatenate([b.antigen_types for b in batch_list]),
            assembly_ids=np.concatenate([b.assembly_ids for b in batch_list]),
            compound_keys=np.concatenate([b.compound_keys for b in batch_list]),
            archetypes=np.concatenate([b.archetypes for b in batch_list]),
            alignment_scores=np.concatenate([b.alignment_scores for b in batch_list]),
            pmids=np.concatenate([b.pmids for b in batch_list]),
            years=np.concatenate([b.years for b in batch_list]),
            is_repeating=np.concatenate([b.is_repeating for b in batch_list]),
            glycans=Glycans.concat([b.glycans for b in batch_list]),
            loci=Loci.concat([b.loci for b in batch_list]),
            assignments=GeneGlycanAssignments.concat([b.assignments for b in batch_list]),
            structure_raws=np.concatenate([b.structure_raws for b in batch_list]),
            structure_linears=np.concatenate([b.structure_linears for b in batch_list]),
            dois=np.concatenate([b.dois for b in batch_list]),
            glytoucan_ids=np.concatenate([b.glytoucan_ids for b in batch_list]),
            notes=np.concatenate([b.notes for b in batch_list]),
        )

    @classmethod
    def build(cls, records: Iterable[GlycanRecord]) -> Self:
        r"""Compiles an iterable of `GlycanRecord` instances into a pure SoA `GlycanRecords` batch."""
        raw_recs = list(records)
        n = len(raw_recs)
        if n == 0:
            return cls.empty()

        assigner = BiosyntheticAssigner()

        source_dbs = np.empty(n, dtype="S16")
        record_ids = np.empty(n, dtype="S32")
        organisms = np.empty(n, dtype="S64")
        strains = np.empty(n, dtype="S64")
        serotypes = np.empty(n, dtype="S32")
        antigen_types = np.empty(n, dtype="S32")
        assembly_ids = np.empty(n, dtype="S32")
        compound_keys = np.empty(n, dtype="S48")
        archetypes = np.empty(n, dtype=np.int8)
        alignment_scores = np.empty(n, dtype=np.float32)
        pmids = np.empty(n, dtype=np.int64)
        years = np.empty(n, dtype=np.int16)
        is_repeating = np.empty(n, dtype=bool)

        structure_raw_list: list[bytes] = []
        structure_linear_list: list[bytes] = []
        doi_list: list[bytes] = []
        glytoucan_id_list: list[bytes] = []
        notes_list: list[bytes] = []

        empty_g = Glycan(b"", (), ())
        empty_loc = Locus(nodes=(), edges=())
        glycan_objs: list[Glycan] = []
        locus_objs: list[Locus] = []
        assign_lists: list[tuple[Any, ...]] = []

        for i, r in enumerate(raw_recs):
            if r.locus is not None and len(r.locus.nodes) > 0:
                loc = r.locus.canonicalize_strand()
                if not loc.name:
                    label = (r.serotype or r.strain or r.record_id).decode("utf-8", errors="replace").strip()
                    if label:
                        loc = loc.with_name(label)
                if loc is not r.locus:
                    r = replace(r, locus=loc)
            raw_recs[i] = r

            source_dbs[i] = r.source_db
            record_ids[i] = r.record_id
            organisms[i] = r.organism
            strains[i] = r.strain
            serotypes[i] = r.serotype
            antigen_types[i] = r.antigen_type
            assembly_ids[i] = r.assembly_id
            compound_keys[i] = r.compound_key
            archetypes[i] = int(r.archetype)
            alignment_scores[i] = float(r.alignment_score)
            pmids[i] = r.pmid
            years[i] = r.publication_year
            is_repeating[i] = r.is_repeating

            structure_raw_list.append(r.structure_raw)
            structure_linear_list.append(r.structure_linear)
            doi_list.append(r.doi)
            glytoucan_id_list.append(r.glytoucan_id)
            notes_list.append(r.notes)

            glycan_objs.append(r.glycan if r.glycan is not None else empty_g)
            locus_objs.append(r.locus if r.locus is not None else empty_loc)

        # 1. Cluster genes via MCL across all paired loci FIRST so every GeneNode carries a homology cluster_id
        loci_batch = Loci.from_list(locus_objs)
        if (
            len(loci_batch.node_types) > 0
            and len(loci_batch.genes) == len(loci_batch.node_types)
            and bool(np.all(loci_batch.cluster_ids == 0))
        ):
            loci_batch = loci_batch.cluster_genes(
                mode="mcl",
                min_identity=28.0,
                min_bitscore=40.0,
                inflation=2.0,
                by_role=True,
            )

        # 2. Fit cross-record PanGraphConsensusPrior & rotate glycans to biological reducing ends
        pan_prior, rotated_glycans = PanGraphConsensusPrior.fit(
            glycan_objs,
            loci_batch,
            archetypes,
            is_repeating,
            assigner=assigner,
        )
        if len(pan_prior.rescued_loci) == len(raw_recs):
            loci_batch = Loci.from_list(pan_prior.rescued_loci)

        # 3. Complete locus modifiers and resolve pan-graph-informed GeneGlycanAssignments
        final_glycans: list[Glycan] = []
        for i, r in enumerate(raw_recs):
            gly = rotated_glycans[i]
            loc = loci_batch[i] if len(loci_batch) > i else empty_loc
            completed_mod_genes: dict[int, int] | None = None

            if len(loc.nodes) > 0 and len(gly.nodes) > 0:
                comp_gly, comp_mods, comp_map = assigner.complete_glycan_modifiers(gly, loc)
                if comp_mods:
                    gly = comp_gly
                    completed_mod_genes = comp_map
                    notes_b = notes_list[i]
                    if b"[locus_completed=" not in notes_b:
                        tag = b" [locus_completed=" + b",".join(b"+" + m for m in comp_mods) + b"]"
                        notes_list[i] = notes_b + tag

            final_glycans.append(gly)
            if not r.assignments and len(gly.nodes) > 0:
                inferred = assigner.assign_record(
                    gly,
                    loc if len(loc.nodes) > 0 else None,
                    archetype=r.archetype,
                    is_repeating=r.is_repeating,
                    completed_modifier_genes=completed_mod_genes,
                    pan_prior=pan_prior,
                )
                assign_lists.append(inferred)
            else:
                assign_lists.append(r.assignments)

        glycans_batch = Glycans.from_list(final_glycans)
        assignments_batch = GeneGlycanAssignments.from_list(assign_lists)

        return cls(
            source_dbs=source_dbs,
            record_ids=record_ids,
            organisms=organisms,
            strains=strains,
            serotypes=serotypes,
            antigen_types=antigen_types,
            assembly_ids=assembly_ids,
            compound_keys=compound_keys,
            archetypes=archetypes,
            alignment_scores=alignment_scores,
            pmids=pmids,
            years=years,
            is_repeating=is_repeating,
            glycans=glycans_batch,
            loci=loci_batch,
            assignments=assignments_batch,
            structure_raws=np.array(structure_raw_list, dtype="S"),
            structure_linears=np.array(structure_linear_list, dtype="S"),
            dois=np.array(doi_list, dtype="S"),
            glytoucan_ids=np.array(glytoucan_id_list, dtype="S"),
            notes=np.array(notes_list, dtype="S"),
        )

    @classmethod
    async def abuild(cls, records: AsyncIterable[GlycanRecord]) -> Self:
        r"""Asynchronously compiles an async stream of `GlycanRecord` instances into a `GlycanRecords` batch."""
        collected: list[GlycanRecord] = []
        async for r in records:
            collected.append(r)
        return cls.build(collected)

    def save(self, path: Path | str | None = None) -> Path:
        r"""Serializes the `GlycanRecords` instance to disk as a compressed NumPy `.npz` archive (zero pickle)."""
        p = Path(path) if path is not None else DEFAULT_DB_PATH
        p.parent.mkdir(parents=True, exist_ok=True)
        tmp_p = p.with_name(f".{p.name}.{os.getpid()}.tmp.npz")

        np.savez_compressed(
            tmp_p,
            # Record scalar columns
            source_dbs=self.source_dbs,
            record_ids=self.record_ids,
            organisms=self.organisms,
            strains=self.strains,
            serotypes=self.serotypes,
            antigen_types=self.antigen_types,
            assembly_ids=self.assembly_ids,
            compound_keys=self.compound_keys,
            archetypes=self.archetypes,
            alignment_scores=self.alignment_scores,
            pmids=self.pmids,
            years=self.years,
            is_repeating=self.is_repeating,
            structure_raws=self.structure_raws,
            structure_linears=self.structure_linears,
            dois=self.dois,
            glytoucan_ids=self.glytoucan_ids,
            notes=self.notes,
            # Glycans SoA
            glycan_node_types=self.glycans.node_types,
            glycan_node_offsets=self.glycans.node_offsets,
            glycan_edge_sources=self.glycans.edge_sources,
            glycan_edge_targets=self.glycans.edge_targets,
            glycan_edge_types=self.glycans.edge_types,
            glycan_edge_offsets=self.glycans.edge_offsets,
            glycan_canonicals=self.glycans.canonicals,
            # Loci & Genes SoA
            **_loci_to_npz_dict(self.loci, prefix="locus_"),
            # GeneGlycanAssignments SoA
            assignment_gene_indices=self.assignments.gene_indices,
            assignment_roles=self.assignments.roles,
            assignment_bipartite_indices=self.assignments.bipartite_indices,
            assignment_target_types=self.assignments.target_types,
            assignment_components=self.assignments.components,
            assignment_edge_indices=self.assignments.edge_indices,
            assignment_evidences=self.assignments.evidences,
            assignment_scores=self.assignments.scores,
            assignment_offsets=self.assignments.offsets,
            assignment_lengths=self.assignments.lengths,
        )
        os.replace(tmp_p, p)
        return p

    @classmethod
    def load(cls, path: Path | str | None = None) -> Self:
        r"""Deserializes a `GlycanRecords` instance from a compressed NumPy `.npz` archive (zero pickle, zero eager Python loop)."""
        p = Path(path) if path is not None else DEFAULT_DB_PATH
        with np.load(p, allow_pickle=False) as data:
            source_dbs = data["source_dbs"]
            n = len(source_dbs)
            if n == 0:
                return cls.empty()

            record_ids = data["record_ids"]
            organisms = data["organisms"]
            strains = data["strains"]
            serotypes = data["serotypes"]
            antigen_types = data["antigen_types"]
            assembly_ids = data["assembly_ids"]
            compound_keys = data["compound_keys"]
            archetypes = data["archetypes"]
            alignment_scores = data["alignment_scores"]
            pmids = data["pmids"]
            years = data["years"]
            is_repeating = data["is_repeating"]

            structure_raws = data["structure_raws"]
            structure_linears = data["structure_linears"]
            dois = data["dois"]
            glytoucan_ids = data["glytoucan_ids"]
            notes = data["notes"]

            glycans = Glycans(
                node_types=data["glycan_node_types"],
                node_offsets=data["glycan_node_offsets"],
                edge_sources=data["glycan_edge_sources"],
                edge_targets=data["glycan_edge_targets"],
                edge_types=data["glycan_edge_types"],
                edge_offsets=data["glycan_edge_offsets"],
                canonicals=data["glycan_canonicals"],
            )

            if "locus_gene_starts" in data.files:
                loci = _loci_from_npz_dict(data, prefix="locus_")
            else:
                gene_intervals = Intervals(
                    starts=data["gene_starts"],
                    ends=data["gene_ends"],
                    strands=data["gene_strands"],
                )
                gene_seqs = Sequences(
                    ids=data["gene_seq_ids"],
                    seqs=data["gene_seq_bytes"],
                    offsets=data["gene_seq_offsets"],
                    lengths=data["gene_seq_lengths"],
                )
                gene_indels = Indels(
                    positions=data["gene_indel_positions"],
                    kinds=data["gene_indel_kinds"],
                    offsets=data["gene_indel_offsets"],
                    lengths=data["gene_indel_lengths"],
                )
                genes = Genes(
                    intervals=gene_intervals,
                    seqs=gene_seqs if len(gene_seqs) > 0 else None,
                    contig_indices=data["gene_contig_indices"],
                    indels=gene_indels,
                )
                loci = Loci(
                    node_types=data["locus_node_types"],
                    node_offsets=data["locus_node_offsets"],
                    edge_sources=data["locus_edge_sources"],
                    edge_targets=data["locus_edge_targets"],
                    edge_types=data["locus_edge_types"],
                    edge_offsets=data["locus_edge_offsets"],
                    genes=genes,
                    intervals=Intervals(
                        starts=data["locus_starts"],
                        ends=data["locus_ends"],
                        strands=data["locus_strands"],
                    ),
                    contig_indices=data["locus_contig_indices"],
                    archetypes=data["locus_archetypes"],
                    completeness=data["locus_completeness"],
                    scores=data["locus_scores"],
                    is_tandem_split=data["locus_is_tandem_split"],
                )

            has_saved_assignments = "assignment_offsets" in data.files
            if has_saved_assignments:
                assignments = GeneGlycanAssignments(
                    gene_indices=data["assignment_gene_indices"],
                    roles=data["assignment_roles"],
                    bipartite_indices=data["assignment_bipartite_indices"],
                    target_types=data["assignment_target_types"],
                    components=data["assignment_components"],
                    edge_indices=data["assignment_edge_indices"],
                    evidences=data["assignment_evidences"],
                    scores=data["assignment_scores"],
                    offsets=data["assignment_offsets"],
                    lengths=data["assignment_lengths"],
                )
            else:
                assigner = BiosyntheticAssigner()
                fallback_lists = [
                    assigner.assign_record(
                        glycans[i] if len(glycans[i].nodes) > 0 else None,
                        loci[i] if len(loci[i].nodes) > 0 else None,
                        archetype=LocusArchetype(int(archetypes[i])),
                        is_repeating=bool(is_repeating[i]),
                    )
                    for i in range(n)
                ]
                assignments = GeneGlycanAssignments.from_list(fallback_lists)

            return cls(
                source_dbs=source_dbs,
                record_ids=record_ids,
                organisms=organisms,
                strains=strains,
                serotypes=serotypes,
                antigen_types=antigen_types,
                assembly_ids=assembly_ids,
                compound_keys=compound_keys,
                archetypes=archetypes,
                alignment_scores=alignment_scores,
                pmids=pmids,
                years=years,
                is_repeating=is_repeating,
                glycans=glycans,
                loci=loci,
                assignments=assignments,
                structure_raws=structure_raws,
                structure_linears=structure_linears,
                dois=dois,
                glytoucan_ids=glytoucan_ids,
                notes=notes,
            )

    @classmethod
    def pretrained(cls, path: Path | str | None = None) -> Self:
        r"""Loads the bundled curated `GlycanRecords` database (`src/kbank/data/glycans.npz`)."""
        return cls.load(path=path)

organism property

organism: ndarray[tuple[int], dtype[bytes_]]

Alias for organisms.

serotype property

serotype: ndarray[tuple[int], dtype[bytes_]]

Alias for serotypes.

source_db property

source_db: ndarray[tuple[int], dtype[bytes_]]

Alias for source_dbs.

assembly_id property

assembly_id: ndarray[tuple[int], dtype[bytes_]]

Alias for assembly_ids.

compound_key property

compound_key: ndarray[tuple[int], dtype[bytes_]]

Alias for compound_keys.

structure_raw property

structure_raw: ndarray[tuple[int], dtype[bytes_]]

Alias for structure_raws.

structure_linear property

structure_linear: ndarray[tuple[int], dtype[bytes_]]

Alias for structure_linears.

doi property

doi: ndarray[tuple[int], dtype[bytes_]]

Alias for dois.

glytoucan_id property

glytoucan_id: ndarray[tuple[int], dtype[bytes_]]

Alias for glytoucan_ids.

has_locus property

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

Boolean mask indicating which records have a non-empty paired Locus.

where_organism

where_organism(organism: bytes | str) -> Self

Filters records by exact bacterial organism name.

Source code in src/kbank/db/records.py
def where_organism(self, organism: bytes | str) -> Self:
    r"""Filters records by exact bacterial organism name."""
    org_b = organism.encode("utf-8") if isinstance(organism, str) else organism
    return self[self.organisms == org_b]

where_serotype

where_serotype(serotype: bytes | str) -> Self

Filters records by serotype designation.

Source code in src/kbank/db/records.py
def where_serotype(self, serotype: bytes | str) -> Self:
    r"""Filters records by serotype designation."""
    sero_b = serotype.encode("utf-8") if isinstance(serotype, str) else serotype
    return self[self.serotypes == sero_b]

where_source_db

where_source_db(source: bytes | str) -> Self

Filters records by source database name (e.g. b"CSDB", b"ECODAB").

Source code in src/kbank/db/records.py
def where_source_db(self, source: bytes | str) -> Self:
    r"""Filters records by source database name (e.g. `b"CSDB"`, `b"ECODAB"`)."""
    src_b = source.encode("utf-8") if isinstance(source, str) else source
    return self[self.source_dbs == src_b]

where_assembly_id

where_assembly_id(assembly_id: bytes | str) -> Self

Filters records by genome assembly or nucleotide accession.

Source code in src/kbank/db/records.py
def where_assembly_id(self, assembly_id: bytes | str) -> Self:
    r"""Filters records by genome assembly or nucleotide accession."""
    asm_b = assembly_id.encode("utf-8") if isinstance(assembly_id, str) else assembly_id
    return self[self.assembly_ids == asm_b]

where_archetype

where_archetype(
    archetype: LocusArchetype | int | str | bytes,
) -> Self

Filters records by LocusArchetype.

Source code in src/kbank/db/records.py
def where_archetype(self, archetype: LocusArchetype | int | str | bytes) -> Self:
    r"""Filters records by `LocusArchetype`."""
    arch = LocusArchetype.from_name(archetype)
    return self[self.archetypes == int(arch)]

where_has_locus

where_has_locus() -> Self

Filters records to those paired with a non-empty biosynthetic Locus.

Source code in src/kbank/db/records.py
def where_has_locus(self) -> Self:
    r"""Filters records to those paired with a non-empty biosynthetic `Locus`."""
    has_loc = np.diff(self.loci.node_offsets) > 0
    return self[has_loc]

empty classmethod

empty() -> Self

Constructs an empty, zero-length GlycanRecords container.

Source code in src/kbank/db/records.py
@classmethod
def empty(cls) -> Self:
    r"""Constructs an empty, zero-length `GlycanRecords` container."""
    return cls(
        source_dbs=np.empty(0, dtype="S16"),
        record_ids=np.empty(0, dtype="S32"),
        organisms=np.empty(0, dtype="S64"),
        strains=np.empty(0, dtype="S64"),
        serotypes=np.empty(0, dtype="S32"),
        antigen_types=np.empty(0, dtype="S32"),
        assembly_ids=np.empty(0, dtype="S32"),
        compound_keys=np.empty(0, dtype="S48"),
        archetypes=np.empty(0, dtype=np.int8),
        alignment_scores=np.empty(0, dtype=np.float32),
        pmids=np.empty(0, dtype=np.int64),
        years=np.empty(0, dtype=np.int16),
        is_repeating=np.empty(0, dtype=bool),
        glycans=Glycans.empty(),
        loci=Loci.empty(),
        assignments=GeneGlycanAssignments.empty(),
        structure_raws=np.empty(0, dtype="S1"),
        structure_linears=np.empty(0, dtype="S1"),
        dois=np.empty(0, dtype="S1"),
        glytoucan_ids=np.empty(0, dtype="S1"),
        notes=np.empty(0, dtype="S1"),
    )

concat classmethod

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

Concatenates multiple GlycanRecords batches into a single container.

Source code in src/kbank/db/records.py
@classmethod
def concat(cls, batches: Iterable[Self]) -> Self:
    r"""Concatenates multiple `GlycanRecords` batches into a single container."""
    batch_list = [b for b in batches if len(b) > 0]
    if not batch_list:
        return cls.empty()
    if len(batch_list) == 1:
        return batch_list[0]

    return cls(
        source_dbs=np.concatenate([b.source_dbs for b in batch_list]),
        record_ids=np.concatenate([b.record_ids for b in batch_list]),
        organisms=np.concatenate([b.organisms for b in batch_list]),
        strains=np.concatenate([b.strains for b in batch_list]),
        serotypes=np.concatenate([b.serotypes for b in batch_list]),
        antigen_types=np.concatenate([b.antigen_types for b in batch_list]),
        assembly_ids=np.concatenate([b.assembly_ids for b in batch_list]),
        compound_keys=np.concatenate([b.compound_keys for b in batch_list]),
        archetypes=np.concatenate([b.archetypes for b in batch_list]),
        alignment_scores=np.concatenate([b.alignment_scores for b in batch_list]),
        pmids=np.concatenate([b.pmids for b in batch_list]),
        years=np.concatenate([b.years for b in batch_list]),
        is_repeating=np.concatenate([b.is_repeating for b in batch_list]),
        glycans=Glycans.concat([b.glycans for b in batch_list]),
        loci=Loci.concat([b.loci for b in batch_list]),
        assignments=GeneGlycanAssignments.concat([b.assignments for b in batch_list]),
        structure_raws=np.concatenate([b.structure_raws for b in batch_list]),
        structure_linears=np.concatenate([b.structure_linears for b in batch_list]),
        dois=np.concatenate([b.dois for b in batch_list]),
        glytoucan_ids=np.concatenate([b.glytoucan_ids for b in batch_list]),
        notes=np.concatenate([b.notes for b in batch_list]),
    )

build classmethod

build(records: Iterable[GlycanRecord]) -> Self

Compiles an iterable of GlycanRecord instances into a pure SoA GlycanRecords batch.

Source code in src/kbank/db/records.py
@classmethod
def build(cls, records: Iterable[GlycanRecord]) -> Self:
    r"""Compiles an iterable of `GlycanRecord` instances into a pure SoA `GlycanRecords` batch."""
    raw_recs = list(records)
    n = len(raw_recs)
    if n == 0:
        return cls.empty()

    assigner = BiosyntheticAssigner()

    source_dbs = np.empty(n, dtype="S16")
    record_ids = np.empty(n, dtype="S32")
    organisms = np.empty(n, dtype="S64")
    strains = np.empty(n, dtype="S64")
    serotypes = np.empty(n, dtype="S32")
    antigen_types = np.empty(n, dtype="S32")
    assembly_ids = np.empty(n, dtype="S32")
    compound_keys = np.empty(n, dtype="S48")
    archetypes = np.empty(n, dtype=np.int8)
    alignment_scores = np.empty(n, dtype=np.float32)
    pmids = np.empty(n, dtype=np.int64)
    years = np.empty(n, dtype=np.int16)
    is_repeating = np.empty(n, dtype=bool)

    structure_raw_list: list[bytes] = []
    structure_linear_list: list[bytes] = []
    doi_list: list[bytes] = []
    glytoucan_id_list: list[bytes] = []
    notes_list: list[bytes] = []

    empty_g = Glycan(b"", (), ())
    empty_loc = Locus(nodes=(), edges=())
    glycan_objs: list[Glycan] = []
    locus_objs: list[Locus] = []
    assign_lists: list[tuple[Any, ...]] = []

    for i, r in enumerate(raw_recs):
        if r.locus is not None and len(r.locus.nodes) > 0:
            loc = r.locus.canonicalize_strand()
            if not loc.name:
                label = (r.serotype or r.strain or r.record_id).decode("utf-8", errors="replace").strip()
                if label:
                    loc = loc.with_name(label)
            if loc is not r.locus:
                r = replace(r, locus=loc)
        raw_recs[i] = r

        source_dbs[i] = r.source_db
        record_ids[i] = r.record_id
        organisms[i] = r.organism
        strains[i] = r.strain
        serotypes[i] = r.serotype
        antigen_types[i] = r.antigen_type
        assembly_ids[i] = r.assembly_id
        compound_keys[i] = r.compound_key
        archetypes[i] = int(r.archetype)
        alignment_scores[i] = float(r.alignment_score)
        pmids[i] = r.pmid
        years[i] = r.publication_year
        is_repeating[i] = r.is_repeating

        structure_raw_list.append(r.structure_raw)
        structure_linear_list.append(r.structure_linear)
        doi_list.append(r.doi)
        glytoucan_id_list.append(r.glytoucan_id)
        notes_list.append(r.notes)

        glycan_objs.append(r.glycan if r.glycan is not None else empty_g)
        locus_objs.append(r.locus if r.locus is not None else empty_loc)

    # 1. Cluster genes via MCL across all paired loci FIRST so every GeneNode carries a homology cluster_id
    loci_batch = Loci.from_list(locus_objs)
    if (
        len(loci_batch.node_types) > 0
        and len(loci_batch.genes) == len(loci_batch.node_types)
        and bool(np.all(loci_batch.cluster_ids == 0))
    ):
        loci_batch = loci_batch.cluster_genes(
            mode="mcl",
            min_identity=28.0,
            min_bitscore=40.0,
            inflation=2.0,
            by_role=True,
        )

    # 2. Fit cross-record PanGraphConsensusPrior & rotate glycans to biological reducing ends
    pan_prior, rotated_glycans = PanGraphConsensusPrior.fit(
        glycan_objs,
        loci_batch,
        archetypes,
        is_repeating,
        assigner=assigner,
    )
    if len(pan_prior.rescued_loci) == len(raw_recs):
        loci_batch = Loci.from_list(pan_prior.rescued_loci)

    # 3. Complete locus modifiers and resolve pan-graph-informed GeneGlycanAssignments
    final_glycans: list[Glycan] = []
    for i, r in enumerate(raw_recs):
        gly = rotated_glycans[i]
        loc = loci_batch[i] if len(loci_batch) > i else empty_loc
        completed_mod_genes: dict[int, int] | None = None

        if len(loc.nodes) > 0 and len(gly.nodes) > 0:
            comp_gly, comp_mods, comp_map = assigner.complete_glycan_modifiers(gly, loc)
            if comp_mods:
                gly = comp_gly
                completed_mod_genes = comp_map
                notes_b = notes_list[i]
                if b"[locus_completed=" not in notes_b:
                    tag = b" [locus_completed=" + b",".join(b"+" + m for m in comp_mods) + b"]"
                    notes_list[i] = notes_b + tag

        final_glycans.append(gly)
        if not r.assignments and len(gly.nodes) > 0:
            inferred = assigner.assign_record(
                gly,
                loc if len(loc.nodes) > 0 else None,
                archetype=r.archetype,
                is_repeating=r.is_repeating,
                completed_modifier_genes=completed_mod_genes,
                pan_prior=pan_prior,
            )
            assign_lists.append(inferred)
        else:
            assign_lists.append(r.assignments)

    glycans_batch = Glycans.from_list(final_glycans)
    assignments_batch = GeneGlycanAssignments.from_list(assign_lists)

    return cls(
        source_dbs=source_dbs,
        record_ids=record_ids,
        organisms=organisms,
        strains=strains,
        serotypes=serotypes,
        antigen_types=antigen_types,
        assembly_ids=assembly_ids,
        compound_keys=compound_keys,
        archetypes=archetypes,
        alignment_scores=alignment_scores,
        pmids=pmids,
        years=years,
        is_repeating=is_repeating,
        glycans=glycans_batch,
        loci=loci_batch,
        assignments=assignments_batch,
        structure_raws=np.array(structure_raw_list, dtype="S"),
        structure_linears=np.array(structure_linear_list, dtype="S"),
        dois=np.array(doi_list, dtype="S"),
        glytoucan_ids=np.array(glytoucan_id_list, dtype="S"),
        notes=np.array(notes_list, dtype="S"),
    )

abuild async classmethod

abuild(records: AsyncIterable[GlycanRecord]) -> Self

Asynchronously compiles an async stream of GlycanRecord instances into a GlycanRecords batch.

Source code in src/kbank/db/records.py
@classmethod
async def abuild(cls, records: AsyncIterable[GlycanRecord]) -> Self:
    r"""Asynchronously compiles an async stream of `GlycanRecord` instances into a `GlycanRecords` batch."""
    collected: list[GlycanRecord] = []
    async for r in records:
        collected.append(r)
    return cls.build(collected)

save

save(path: Path | str | None = None) -> Path

Serializes the GlycanRecords instance to disk as a compressed NumPy .npz archive (zero pickle).

Source code in src/kbank/db/records.py
def save(self, path: Path | str | None = None) -> Path:
    r"""Serializes the `GlycanRecords` instance to disk as a compressed NumPy `.npz` archive (zero pickle)."""
    p = Path(path) if path is not None else DEFAULT_DB_PATH
    p.parent.mkdir(parents=True, exist_ok=True)
    tmp_p = p.with_name(f".{p.name}.{os.getpid()}.tmp.npz")

    np.savez_compressed(
        tmp_p,
        # Record scalar columns
        source_dbs=self.source_dbs,
        record_ids=self.record_ids,
        organisms=self.organisms,
        strains=self.strains,
        serotypes=self.serotypes,
        antigen_types=self.antigen_types,
        assembly_ids=self.assembly_ids,
        compound_keys=self.compound_keys,
        archetypes=self.archetypes,
        alignment_scores=self.alignment_scores,
        pmids=self.pmids,
        years=self.years,
        is_repeating=self.is_repeating,
        structure_raws=self.structure_raws,
        structure_linears=self.structure_linears,
        dois=self.dois,
        glytoucan_ids=self.glytoucan_ids,
        notes=self.notes,
        # Glycans SoA
        glycan_node_types=self.glycans.node_types,
        glycan_node_offsets=self.glycans.node_offsets,
        glycan_edge_sources=self.glycans.edge_sources,
        glycan_edge_targets=self.glycans.edge_targets,
        glycan_edge_types=self.glycans.edge_types,
        glycan_edge_offsets=self.glycans.edge_offsets,
        glycan_canonicals=self.glycans.canonicals,
        # Loci & Genes SoA
        **_loci_to_npz_dict(self.loci, prefix="locus_"),
        # GeneGlycanAssignments SoA
        assignment_gene_indices=self.assignments.gene_indices,
        assignment_roles=self.assignments.roles,
        assignment_bipartite_indices=self.assignments.bipartite_indices,
        assignment_target_types=self.assignments.target_types,
        assignment_components=self.assignments.components,
        assignment_edge_indices=self.assignments.edge_indices,
        assignment_evidences=self.assignments.evidences,
        assignment_scores=self.assignments.scores,
        assignment_offsets=self.assignments.offsets,
        assignment_lengths=self.assignments.lengths,
    )
    os.replace(tmp_p, p)
    return p

load classmethod

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

Deserializes a GlycanRecords instance from a compressed NumPy .npz archive (zero pickle, zero eager Python loop).

Source code in src/kbank/db/records.py
@classmethod
def load(cls, path: Path | str | None = None) -> Self:
    r"""Deserializes a `GlycanRecords` instance from a compressed NumPy `.npz` archive (zero pickle, zero eager Python loop)."""
    p = Path(path) if path is not None else DEFAULT_DB_PATH
    with np.load(p, allow_pickle=False) as data:
        source_dbs = data["source_dbs"]
        n = len(source_dbs)
        if n == 0:
            return cls.empty()

        record_ids = data["record_ids"]
        organisms = data["organisms"]
        strains = data["strains"]
        serotypes = data["serotypes"]
        antigen_types = data["antigen_types"]
        assembly_ids = data["assembly_ids"]
        compound_keys = data["compound_keys"]
        archetypes = data["archetypes"]
        alignment_scores = data["alignment_scores"]
        pmids = data["pmids"]
        years = data["years"]
        is_repeating = data["is_repeating"]

        structure_raws = data["structure_raws"]
        structure_linears = data["structure_linears"]
        dois = data["dois"]
        glytoucan_ids = data["glytoucan_ids"]
        notes = data["notes"]

        glycans = Glycans(
            node_types=data["glycan_node_types"],
            node_offsets=data["glycan_node_offsets"],
            edge_sources=data["glycan_edge_sources"],
            edge_targets=data["glycan_edge_targets"],
            edge_types=data["glycan_edge_types"],
            edge_offsets=data["glycan_edge_offsets"],
            canonicals=data["glycan_canonicals"],
        )

        if "locus_gene_starts" in data.files:
            loci = _loci_from_npz_dict(data, prefix="locus_")
        else:
            gene_intervals = Intervals(
                starts=data["gene_starts"],
                ends=data["gene_ends"],
                strands=data["gene_strands"],
            )
            gene_seqs = Sequences(
                ids=data["gene_seq_ids"],
                seqs=data["gene_seq_bytes"],
                offsets=data["gene_seq_offsets"],
                lengths=data["gene_seq_lengths"],
            )
            gene_indels = Indels(
                positions=data["gene_indel_positions"],
                kinds=data["gene_indel_kinds"],
                offsets=data["gene_indel_offsets"],
                lengths=data["gene_indel_lengths"],
            )
            genes = Genes(
                intervals=gene_intervals,
                seqs=gene_seqs if len(gene_seqs) > 0 else None,
                contig_indices=data["gene_contig_indices"],
                indels=gene_indels,
            )
            loci = Loci(
                node_types=data["locus_node_types"],
                node_offsets=data["locus_node_offsets"],
                edge_sources=data["locus_edge_sources"],
                edge_targets=data["locus_edge_targets"],
                edge_types=data["locus_edge_types"],
                edge_offsets=data["locus_edge_offsets"],
                genes=genes,
                intervals=Intervals(
                    starts=data["locus_starts"],
                    ends=data["locus_ends"],
                    strands=data["locus_strands"],
                ),
                contig_indices=data["locus_contig_indices"],
                archetypes=data["locus_archetypes"],
                completeness=data["locus_completeness"],
                scores=data["locus_scores"],
                is_tandem_split=data["locus_is_tandem_split"],
            )

        has_saved_assignments = "assignment_offsets" in data.files
        if has_saved_assignments:
            assignments = GeneGlycanAssignments(
                gene_indices=data["assignment_gene_indices"],
                roles=data["assignment_roles"],
                bipartite_indices=data["assignment_bipartite_indices"],
                target_types=data["assignment_target_types"],
                components=data["assignment_components"],
                edge_indices=data["assignment_edge_indices"],
                evidences=data["assignment_evidences"],
                scores=data["assignment_scores"],
                offsets=data["assignment_offsets"],
                lengths=data["assignment_lengths"],
            )
        else:
            assigner = BiosyntheticAssigner()
            fallback_lists = [
                assigner.assign_record(
                    glycans[i] if len(glycans[i].nodes) > 0 else None,
                    loci[i] if len(loci[i].nodes) > 0 else None,
                    archetype=LocusArchetype(int(archetypes[i])),
                    is_repeating=bool(is_repeating[i]),
                )
                for i in range(n)
            ]
            assignments = GeneGlycanAssignments.from_list(fallback_lists)

        return cls(
            source_dbs=source_dbs,
            record_ids=record_ids,
            organisms=organisms,
            strains=strains,
            serotypes=serotypes,
            antigen_types=antigen_types,
            assembly_ids=assembly_ids,
            compound_keys=compound_keys,
            archetypes=archetypes,
            alignment_scores=alignment_scores,
            pmids=pmids,
            years=years,
            is_repeating=is_repeating,
            glycans=glycans,
            loci=loci,
            assignments=assignments,
            structure_raws=structure_raws,
            structure_linears=structure_linears,
            dois=dois,
            glytoucan_ids=glytoucan_ids,
            notes=notes,
        )

pretrained classmethod

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

Loads the bundled curated GlycanRecords database (src/kbank/data/glycans.npz).

Source code in src/kbank/db/records.py
@classmethod
def pretrained(cls, path: Path | str | None = None) -> Self:
    r"""Loads the bundled curated `GlycanRecords` database (`src/kbank/data/glycans.npz`)."""
    return cls.load(path=path)

Multi-Database Curation & Locus Pairing (kbank.db.curation)

curation

Taxonomy normalization, topological glycan consolidation, and 4-tier SeroGen locus pairing.

Functions:

Name Description
antigen_family

Normalize antigen_type labels into canonical family buckets (CPS, O-antigen, WTA, EPS).

normalize_raw_record_taxonomy

Unify <Genus> sp. organism labels with specific species when serotype or strain matches.

record_rank_key

Rank candidate records so intact 1x repeating units with loci and modifiers win.

merge_record_cluster

Merge a cluster of duplicate/fragment records into the single highest-ranked canonical GlycanRecord.

consolidate_curated_records

Consolidate duplicate, cyclic-shift, oligomer, and fragment GlycanRecord entries.

load_predicted_loci_cache

Load zero-pickle cached (asm_id, Loci) entries from .cache/predicted_loci.npz.

save_predicted_loci_cache

Persist (asm_id, Loci) entries (plus optional strain/DOI maps) to .cache/predicted_loci.npz without pickle.

resolve_and_predict_loci

Fetch in-memory GenomeAssembly FASTAs from NCBI and predict candidate Loci via LocusPredictor.

build_curated_glycan_records

Consolidate duplicate/fragment records, graft modifiers, pair with Loci, and compile GlycanRecords.

antigen_family

antigen_family(antigen_type: bytes) -> bytes

Normalize antigen_type labels into canonical family buckets (CPS, O-antigen, WTA, EPS).

Source code in src/kbank/db/curation.py
def antigen_family(antigen_type: bytes) -> bytes:
    """Normalize `antigen_type` labels into canonical family buckets (`CPS`, `O-antigen`, `WTA`, `EPS`)."""
    if antigen_type.startswith(b"CPS"):
        return b"CPS"
    if antigen_type in (b"O-antigen", b"LPS", b"LOS"):
        return b"O-antigen"
    if antigen_type == b"WTA":
        return b"WTA"
    if antigen_type == b"EPS":
        return b"EPS"
    return antigen_type

normalize_raw_record_taxonomy

normalize_raw_record_taxonomy(
    raw_records: list[dict[str, Any]],
) -> list[dict[str, Any]]

Unify <Genus> sp. organism labels with specific species when serotype or strain matches.

Source code in src/kbank/db/curation.py
def normalize_raw_record_taxonomy(raw_records: list[dict[str, Any]]) -> list[dict[str, Any]]:
    """Unify `<Genus> sp.` organism labels with specific species when serotype or strain matches."""
    genus_canonical_species: dict[bytes, bytes] = {
        b"Klebsiella": b"Klebsiella pneumoniae",
        b"Salmonella": b"Salmonella enterica",
        b"Escherichia": b"Escherichia coli",
        b"Shigella": b"Shigella flexneri",
        b"Streptococcus": b"Streptococcus pneumoniae",
        b"Acinetobacter": b"Acinetobacter baumannii",
        b"Pseudomonas": b"Pseudomonas aeruginosa",
        b"Proteus": b"Proteus mirabilis",
        b"Yersinia": b"Yersinia pseudotuberculosis",
        b"Vibrio": b"Vibrio cholerae",
        b"Campylobacter": b"Campylobacter jejuni",
        b"Helicobacter": b"Helicobacter pylori",
        b"Haemophilus": b"Haemophilus influenzae",
        b"Neisseria": b"Neisseria meningitidis",
        b"Staphylococcus": b"Staphylococcus aureus",
        b"Listeria": b"Listeria monocytogenes",
        b"Enterococcus": b"Enterococcus faecalis",
        b"Cronobacter": b"Cronobacter sakazakii",
        b"Serratia": b"Serratia marcescens",
        b"Citrobacter": b"Citrobacter freundii",
        b"Aeromonas": b"Aeromonas hydrophila",
        b"Providencia": b"Providencia alcalifaciens",
    }
    genus_sero_to_species: dict[tuple[bytes, bytes], bytes] = {}
    genus_strain_to_species: dict[tuple[bytes, bytes], bytes] = {}

    for r in raw_records:
        org: bytes = r["organism"]
        if not org.endswith(b" sp.") and b" " in org:
            genus = org.split(b" ", 1)[0]
            sero: bytes = r.get("serotype", b"")
            strain: bytes = r.get("strain", b"")
            if sero:
                genus_sero_to_species.setdefault((genus, sero), org)
            if strain:
                genus_strain_to_species.setdefault((genus, strain), org)

    for r in raw_records:
        org = r["organism"]
        if org.endswith(b" sp."):
            genus = org.split(b" ", 1)[0]
            sero = r.get("serotype", b"")
            strain = r.get("strain", b"")
            if sero and (genus, sero) in genus_sero_to_species:
                r["organism"] = genus_sero_to_species[(genus, sero)]
            elif strain and (genus, strain) in genus_strain_to_species:
                r["organism"] = genus_strain_to_species[(genus, strain)]
            elif sero and genus in genus_canonical_species:
                r["organism"] = genus_canonical_species[genus]

        # Ensure Salmonella records with serovar strain names also populate canonical O-group serotype
        if r["organism"] == b"Salmonella enterica" and not r.get("serotype") and r.get("strain"):
            st_low = r["strain"].decode("utf-8", errors="replace").strip().lower()
            if st_low in SALMONELLA_SEROVAR_TO_OGROUP:
                r["serotype"] = SALMONELLA_SEROVAR_TO_OGROUP[st_low].encode("utf-8")

    return raw_records

record_rank_key

record_rank_key(
    rec: GlycanRecord, is_oligomer_duplicate: bool = False
) -> tuple[Any, ...]

Rank candidate records so intact 1x repeating units with loci and modifiers win.

Source code in src/kbank/db/curation.py
def record_rank_key(rec: GlycanRecord, is_oligomer_duplicate: bool = False) -> tuple[Any, ...]:
    """Rank candidate records so intact 1x repeating units with loci and modifiers win."""
    db_priority: dict[bytes, int] = {
        b"ECODAB": 5,
        b"EK3D": 5,
        b"EPSDB": 4,
        b"CSDB": 3,
        b"KEGG": 1,
    }
    gly = rec.glycan
    assert gly is not None
    intact_rep = int(rec.is_repeating and not gly.is_alditol and not is_oligomer_duplicate)
    backbone_len = len(gly.without_modifiers().nodes)
    return (
        intact_rep,
        int(not is_oligomer_duplicate),
        int(rec.is_repeating),
        int(not gly.is_alditol),
        int(rec.locus is not None),
        round(float(rec.alignment_score), 3),
        backbone_len if intact_rep else -backbone_len,
        gly.num_modifiers,
        db_priority.get(rec.source_db, 0),
        int(rec.pmid > 0),
        int(rec.publication_year),
    )

merge_record_cluster

merge_record_cluster(
    cluster: list[GlycanRecord],
) -> GlycanRecord

Merge a cluster of duplicate/fragment records into the single highest-ranked canonical GlycanRecord.

Source code in src/kbank/db/curation.py
def merge_record_cluster(cluster: list[GlycanRecord]) -> GlycanRecord:
    """Merge a cluster of duplicate/fragment records into the single highest-ranked canonical `GlycanRecord`."""
    if len(cluster) == 1:
        return cluster[0]

    intact_backbones = [r for r in cluster if r.is_repeating and r.glycan is not None and not r.glycan.is_alditol]
    oligomer_flags: dict[int, bool] = {}
    for idx_a, rec_a in enumerate(cluster):
        is_olig = False
        gly_a = rec_a.glycan
        assert gly_a is not None
        for rec_b in intact_backbones:
            gly_b = rec_b.glycan
            assert gly_b is not None
            if rec_a is not rec_b and len(gly_a.without_modifiers().nodes) > len(gly_b.without_modifiers().nodes):
                if gly_a.is_oligomer_of(gly_b):
                    is_olig = True
                    break
        oligomer_flags[idx_a] = is_olig

    ranked_indices = sorted(
        range(len(cluster)),
        key=lambda i: record_rank_key(cluster[i], is_oligomer_duplicate=oligomer_flags[i]),
        reverse=True,
    )
    winner = cluster[ranked_indices[0]]
    others = [cluster[i] for i in ranked_indices[1:]]

    # Graft modifiers (OAc, Pyr, OMe, Fo, P, PEtn, GroP, DAla, Lac) from fragments/variants onto winner
    winner_gly = winner.glycan
    assert winner_gly is not None
    all_grafted: list[bytes] = []
    for other in others:
        if other.glycan is not None and other.glycan.num_modifiers > 0:
            winner_gly, grafted = winner_gly.graft_modifiers_from(other.glycan)
            for g_name in grafted:
                if g_name not in all_grafted:
                    all_grafted.append(g_name)

    strain = winner.strain
    serotype = winner.serotype
    antigen_type = winner.antigen_type
    assembly_id = winner.assembly_id
    archetype = winner.archetype
    locus = winner.locus
    alignment_score = winner.alignment_score
    pmid = winner.pmid
    publication_year = winner.publication_year
    doi = winner.doi
    glytoucan_id = winner.glytoucan_id

    for other in others:
        if not strain and other.strain:
            strain = other.strain
        if not serotype and other.serotype:
            serotype = other.serotype
        if antigen_type in (b"CPS", b"O-antigen") and other.antigen_type.startswith(b"CPS (Group"):
            antigen_type = other.antigen_type
        if locus is None and other.locus is not None:
            locus = other.locus
            alignment_score = other.alignment_score
            if other.assembly_id:
                assembly_id = other.assembly_id
            if other.archetype != LocusArchetype.UNKNOWN:
                archetype = other.archetype
        elif not assembly_id and other.assembly_id:
            assembly_id = other.assembly_id
        if archetype == LocusArchetype.UNKNOWN and other.archetype != LocusArchetype.UNKNOWN:
            archetype = other.archetype
        if pmid <= 0 and other.pmid > 0:
            pmid = other.pmid
        if publication_year <= 0 and other.publication_year > 0:
            publication_year = other.publication_year
        if not doi and other.doi:
            doi = other.doi
        if not glytoucan_id and other.glytoucan_id:
            glytoucan_id = other.glytoucan_id

    notes = winner.notes
    merged_keys = [
        b"%s:%s" % (o.source_db, o.record_id)
        for o in others[:6]
        if (o.source_db, o.record_id) != (winner.source_db, winner.record_id)
    ]
    if merged_keys:
        merged_tag = b" [merged_ids=" + b",".join(merged_keys) + b"]"
        if b"[merged_ids=" not in notes:
            notes = (notes + merged_tag)[:256]
    if all_grafted:
        graft_tag = b" [grafted_modifiers=+" + b",+".join(all_grafted) + b"]"
        if b"[grafted_modifiers=" not in notes:
            notes = (notes + graft_tag)[:256]

    return GlycanRecord(
        source_db=winner.source_db,
        record_id=winner.record_id,
        organism=winner.organism,
        strain=strain,
        serotype=serotype,
        antigen_type=antigen_type,
        assembly_id=assembly_id,
        archetype=archetype,
        locus=locus,
        alignment_score=alignment_score,
        structure_raw=winner.structure_raw,
        structure_linear=winner.structure_linear,
        glycan=winner_gly,
        is_repeating=winner.is_repeating,
        pmid=pmid,
        publication_year=publication_year,
        doi=doi,
        glytoucan_id=glytoucan_id,
        notes=notes,
    )

consolidate_curated_records

consolidate_curated_records(
    records: list[GlycanRecord],
) -> list[GlycanRecord]

Consolidate duplicate, cyclic-shift, oligomer, and fragment GlycanRecord entries.

Source code in src/kbank/db/curation.py
def consolidate_curated_records(records: list[GlycanRecord]) -> list[GlycanRecord]:
    """Consolidate duplicate, cyclic-shift, oligomer, and fragment `GlycanRecord` entries."""
    typed_buckets: dict[tuple[bytes, bytes, bytes], list[GlycanRecord]] = defaultdict(list)
    untyped_records: list[GlycanRecord] = []

    for rec in records:
        if rec.serotype:
            fam = antigen_family(rec.antigen_type)
            typed_buckets[(rec.organism, rec.serotype, fam)].append(rec)
        else:
            untyped_records.append(rec)

    typed_clusters: dict[tuple[bytes, bytes, bytes], list[GlycanRecord]] = {
        k: list(v) for k, v in typed_buckets.items()
    }
    typed_reps: dict[tuple[bytes, bytes, bytes], GlycanRecord] = {
        k: merge_record_cluster(v) for k, v in typed_clusters.items()
    }

    typed_by_org: dict[bytes, list[tuple[bytes, bytes, bytes]]] = defaultdict(list)
    for k in typed_reps:
        typed_by_org[k[0]].append(k)

    remaining_untyped: list[GlycanRecord] = []
    for u_rec in untyped_records:
        absorbed = False
        u_fam = antigen_family(u_rec.antigen_type)
        u_gly = u_rec.glycan
        assert u_gly is not None
        for k in typed_by_org.get(u_rec.organism, []):
            rep = typed_reps[k]
            rep_gly = rep.glycan
            assert rep_gly is not None
            same_strain = bool(u_rec.strain and rep.strain and u_rec.strain == rep.strain)
            same_pmid = bool(u_rec.pmid > 0 and rep.pmid > 0 and u_rec.pmid == rep.pmid)
            same_fam = k[2] == u_fam

            if same_fam and u_gly.is_isomorphic(rep_gly, allow_wildcards=True):
                typed_clusters[k].append(u_rec)
                typed_reps[k] = merge_record_cluster(typed_clusters[k])
                absorbed = True
                break
            if (same_strain or same_pmid) and (
                u_gly.is_isomorphic(rep_gly, allow_wildcards=True)
                or u_gly.is_subgraph_of(rep_gly)
                or (not u_rec.is_repeating and u_gly.is_oligomer_of(rep_gly))
            ):
                typed_clusters[k].append(u_rec)
                typed_reps[k] = merge_record_cluster(typed_clusters[k])
                absorbed = True
                break
        if not absorbed:
            remaining_untyped.append(u_rec)

    untyped_by_org_fam: dict[tuple[bytes, bytes], list[GlycanRecord]] = defaultdict(list)
    for u_rec in remaining_untyped:
        untyped_by_org_fam[(u_rec.organism, antigen_family(u_rec.antigen_type))].append(u_rec)

    def _untyped_sort_key(r: GlycanRecord) -> tuple[int, int, int, tuple[Any, ...]]:
        g = r.glycan
        assert g is not None
        return (
            int(r.is_repeating and not g.is_alditol),
            len(g.without_modifiers().nodes),
            g.num_modifiers,
            record_rank_key(r),
        )

    consolidated_untyped: list[GlycanRecord] = []
    for _, grp in untyped_by_org_fam.items():
        grp_sorted = sorted(grp, key=_untyped_sort_key, reverse=True)
        clusters: list[list[GlycanRecord]] = []
        for cand in grp_sorted:
            placed = False
            cand_gly = cand.glycan
            assert cand_gly is not None
            for cl in clusters:
                lead = cl[0]
                lead_gly = lead.glycan
                assert lead_gly is not None
                same_context = bool(
                    (cand.strain and lead.strain and cand.strain == lead.strain)
                    or (cand.pmid > 0 and lead.pmid > 0 and cand.pmid == lead.pmid)
                )
                if cand_gly.is_isomorphic(lead_gly, allow_wildcards=True):
                    cl.append(cand)
                    placed = True
                    break
                if same_context and (
                    cand_gly.is_subgraph_of(lead_gly)
                    or (not cand.is_repeating and cand_gly.is_oligomer_of(lead_gly))
                    or (lead.is_repeating and not cand.is_repeating and lead_gly.is_oligomer_of(cand_gly))
                ):
                    cl.append(cand)
                    placed = True
                    break
            if not placed:
                clusters.append([cand])

        for cl in clusters:
            consolidated_untyped.append(merge_record_cluster(cl))

    return list(typed_reps.values()) + consolidated_untyped

load_predicted_loci_cache

load_predicted_loci_cache(
    path: Path = DEFAULT_LOCI_CACHE_PATH,
) -> dict[bytes, tuple[bytes, Loci]]

Load zero-pickle cached (asm_id, Loci) entries from .cache/predicted_loci.npz.

Source code in src/kbank/db/curation.py
def load_predicted_loci_cache(path: Path = DEFAULT_LOCI_CACHE_PATH) -> dict[bytes, tuple[bytes, Loci]]:
    """Load zero-pickle cached `(asm_id, Loci)` entries from `.cache/predicted_loci.npz`."""
    out, _, _ = _load_predicted_loci_cache_bundle(path)
    return out

save_predicted_loci_cache

save_predicted_loci_cache(
    path: Path,
    cache: dict[bytes, tuple[bytes, Loci]],
    *,
    strain_to_assembly: dict[tuple[bytes, bytes], bytes]
    | None = None,
    pmid_to_doi: dict[int, bytes] | None = None,
) -> None

Persist (asm_id, Loci) entries (plus optional strain/DOI maps) to .cache/predicted_loci.npz without pickle.

Source code in src/kbank/db/curation.py
def save_predicted_loci_cache(
    path: Path,
    cache: dict[bytes, tuple[bytes, Loci]],
    *,
    strain_to_assembly: dict[tuple[bytes, bytes], bytes] | None = None,
    pmid_to_doi: dict[int, bytes] | None = None,
) -> None:
    """Persist `(asm_id, Loci)` entries (plus optional strain/DOI maps) to `.cache/predicted_loci.npz` without pickle."""
    persist_items = list(cache.items())
    if not persist_items:
        return
    try:
        path.parent.mkdir(parents=True, exist_ok=True)
        n = len(persist_items)
        keys_arr = np.zeros(n, dtype="S96")
        asm_arr = np.zeros(n, dtype="S32")
        offsets = np.zeros(n + 1, dtype=np.int32)
        all_loci_list: list[Locus] = []
        curr = 0
        for i, (k_b, (asm_b, loci_obj)) in enumerate(persist_items):
            keys_arr[i] = k_b[:96]
            asm_arr[i] = asm_b[:32]
            for j in range(len(loci_obj)):
                all_loci_list.append(loci_obj[j])
            curr += len(loci_obj)
            offsets[i + 1] = curr
        flat_loci = Loci.from_list(all_loci_list) if all_loci_list else Loci.empty()
        extra_arrays: dict[str, Any] = {}
        if strain_to_assembly:
            s_items = sorted(strain_to_assembly.items())
            extra_arrays["strain_orgs"] = np.array([o[:64] for (o, _), _ in s_items], dtype="S64")
            extra_arrays["strain_toks"] = np.array([t[:64] for (_, t), _ in s_items], dtype="S64")
            extra_arrays["strain_keys"] = np.array([ck[:96] for _, ck in s_items], dtype="S96")
        if pmid_to_doi:
            d_items = sorted(pmid_to_doi.items())
            extra_arrays["doi_pmids"] = np.array([p for p, _ in d_items], dtype=np.int64)
            extra_arrays["doi_vals"] = np.array([d[:96] for _, d in d_items], dtype="S96")
        tmp_path = path.with_name(f".{path.name}.{os.getpid()}.tmp.npz")
        np.savez_compressed(
            tmp_path,
            cache_keys=keys_arr,
            asm_ids=asm_arr,
            key_offsets=offsets,
            **extra_arrays,
            **_loci_to_npz_dict(flat_loci, prefix="loci_"),
        )
        os.replace(tmp_path, path)
    except Exception:
        pass

resolve_and_predict_loci async

resolve_and_predict_loci(
    raw_records: list[dict[str, Any]],
    max_assemblies: int = 100,
    *,
    recorder: Any | None = None,
    loci_cache_path: Path = DEFAULT_LOCI_CACHE_PATH,
) -> tuple[
    dict[bytes, tuple[bytes, Loci]],
    dict[tuple[bytes, bytes], bytes],
    dict[int, bytes],
]

Fetch in-memory GenomeAssembly FASTAs from NCBI and predict candidate Loci via LocusPredictor.

Source code in src/kbank/db/curation.py
 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
async def resolve_and_predict_loci(
    raw_records: list[dict[str, Any]],
    max_assemblies: int = 100,
    *,
    recorder: Any | None = None,
    loci_cache_path: Path = DEFAULT_LOCI_CACHE_PATH,
) -> tuple[dict[bytes, tuple[bytes, Loci]], dict[tuple[bytes, bytes], bytes], dict[int, bytes]]:
    """Fetch in-memory `GenomeAssembly` FASTAs from NCBI and predict candidate `Loci` via `LocusPredictor`."""
    import httpx2

    t_stage0 = time.perf_counter()
    batch_predictor = LocusPredictor(min_completeness=0.65, stitch_contig_splits=False)
    whole_genome_predictor = LocusPredictor(min_completeness=0.75)
    word_split_re = re.compile(r"[A-Za-z0-9]+(?::[A-Za-z0-9]+)?")
    num_sero_re = re.compile(
        r"(?:serotype|serogroup|type|capsule|b-band|osa|iats)\s+O?([0-9]{1,3}[A-Za-z]?)\b",
        re.IGNORECASE,
    )

    def _clean_fasta_acc(raw_header: bytes) -> str:
        first_tok = raw_header.split()[0].decode("utf-8", errors="replace").lstrip(">")
        if "|" in first_tok:
            parts_pipe = [
                p for p in first_tok.split("|") if p and not p.isdigit() and p not in ("gb", "emb", "dbj", "ref", "gi")
            ]
            if parts_pipe:
                first_tok = parts_pipe[-1]
        return first_tok[:32]

    def _predict_multi_fasta_batch(fasta_blob: bytes) -> dict[str, tuple[bytes, list[Locus]]]:
        grouped: dict[str, tuple[bytes, list[Locus]]] = {}
        stripped = fasta_blob.lstrip()
        if not stripped.startswith(b">"):
            return grouped
        try:
            raw_entries = stripped.split(b"\n>")
            bgc_entries: list[bytes] = []
            for idx_e, chunk in enumerate(raw_entries):
                entry = chunk if idx_e == 0 else b">" + chunk
                nl_pos = entry.find(b"\n")
                if nl_pos <= 1:
                    continue
                acc_tok = _clean_fasta_acc(entry[1:nl_pos])
                if not acc_tok:
                    continue
                acc_b = acc_tok.encode("utf-8")[:32]
                base_tok = acc_tok.split(".")[0]
                grouped.setdefault(base_tok, (acc_b, []))
                seq_bytes_len = len(entry) - nl_pos
                if 1_500 <= seq_bytes_len <= 95_000:
                    bgc_entries.append(entry)

            if not bgc_entries:
                return grouped

            filtered_blob = b"\n".join(bgc_entries)
            asm = GenomeAssembly.from_fasta(filtered_blob, id_=b"batch", truncate_id=True)
            if len(asm) == 0:
                return grouped
            pred_loci = batch_predictor.predict(asm)
            if len(pred_loci) == 0:
                return grouped
            by_cidx: dict[int, list[Locus]] = defaultdict(list)
            for j in range(len(pred_loci)):
                loc = pred_loci[j].canonicalize_strand()
                by_cidx[int(loc.contig_index)].append(loc)
            for c_idx, loc_list in by_cidx.items():
                if 0 <= c_idx < len(asm.ids):
                    raw_id = bytes(asm.ids[c_idx]).strip(b"\x00").strip()
                    acc_tok = _clean_fasta_acc(raw_id)
                    acc_b = acc_tok.encode("utf-8")[:32]
                    base_tok = acc_tok.split(".")[0]
                    tagged = [replace(loc.with_name(acc_tok), contig_index=0) for loc in loc_list]
                    grouped[base_tok] = (acc_b, tagged)
        except Exception:
            pass
        return grouped

    def _is_true_strain_isolate(org_b: bytes, str_b: bytes, sero_b: bytes) -> bool:
        if (
            not str_b
            or len(str_b) < 2
            or b"sp." in org_b
            or org_b in (b"Unknown bacterium", b"Bacteria (KEGG pathway)")
        ):
            return False
        if str_b == sero_b:
            return False
        str_s = str_b.decode("utf-8", errors="replace").strip()
        if re.fullmatch(
            r"(?:type\s+|serotype\s+|serogroup\s+)?(?:K|KL|O|OL|OC|OCL|VI|\d{1,3}[A-Za-z]?|[IVX]{1,4}[abAB]?)",
            str_s,
            re.IGNORECASE,
        ):
            return False
        return True

    def _catalog_taxon(org_b: bytes) -> str:
        if org_b in (b"Unknown bacterium", b"Bacteria (KEGG pathway)"):
            return ""
        org_s = org_b.decode("utf-8", errors="replace").strip()
        if org_s.endswith(" sp."):
            return org_s[:-4].strip()
        parts = org_s.split()
        if not parts:
            return ""
        genus = parts[0]
        if genus in (
            "Klebsiella",
            "Acinetobacter",
            "Salmonella",
            "Shigella",
            "Proteus",
            "Yersinia",
            "Campylobacter",
            "Cronobacter",
            "Burkholderia",
            "Providencia",
            "Raoultella",
            "Serratia",
            "Citrobacter",
            "Enterobacter",
            "Morganella",
            "Hafnia",
            "Edwardsiella",
            "Vibrio",
            "Aeromonas",
            "Shewanella",
            "Pseudoalteromonas",
            "Rhodococcus",
            "Rhizobium",
            "Sinorhizobium",
            "Bradyrhizobium",
            "Agrobacterium",
            "Azospirillum",
            "Ochrobactrum",
            "Xanthomonas",
            "Pantoea",
            "Erwinia",
            "Pectobacterium",
            "Dickeya",
            "Bacteroides",
            "Clostridium",
            "Bacillus",
            "Lactobacillus",
            "Bifidobacterium",
        ):
            return genus
        return " ".join(parts[:2])

    def _build_token_index(
        targets: set[str],
    ) -> tuple[dict[str, list[str]], dict[str, list[str]], dict[str, list[tuple[str, re.Pattern[str]]]]]:
        num_map: dict[str, list[str]] = defaultdict(list)
        word_map: dict[str, list[str]] = defaultdict(list)
        multi_map: dict[str, list[tuple[str, re.Pattern[str]]]] = defaultdict(list)

        for tok_s in targets:
            if not tok_s:
                continue
            if tok_s.isdigit():
                num_map[tok_s.upper()].append(tok_s)
                continue
            words = word_split_re.findall(tok_s)
            if not words:
                continue
            if len(words) == 1 and words[0] == tok_s:
                u_tok = tok_s.upper()
                word_map[u_tok].append(tok_s)
                if u_tok.startswith("K") and u_tok[1:].isdigit():
                    word_map["KL" + u_tok[1:]].append(tok_s)
                elif u_tok.startswith("KL") and u_tok[2:].isdigit():
                    word_map["K" + u_tok[2:]].append(tok_s)
                elif u_tok.startswith("O:") and u_tok[2:].isdigit():
                    word_map["O" + u_tok[2:]].append(tok_s)
                    num_map[u_tok[2:]].append(tok_s)
                elif u_tok.startswith("O") and u_tok[1:].isdigit():
                    word_map["O:" + u_tok[1:]].append(tok_s)
                    # Also index bare number for 'serotype 6' / 'B-band O6' / 'serogroup 11' (e.g. P. aeruginosa OSA)
                    num_map[u_tok[1:]].append(tok_s)
                # Map Salmonella O-serogroups (e.g. O4, O9) to Kauffmann-White serovar words in NCBI titles
                if u_tok in OGROUP_TO_SALMONELLA_SEROVARS:
                    for sv_alias in OGROUP_TO_SALMONELLA_SEROVARS[u_tok]:
                        sv_words = word_split_re.findall(sv_alias)
                        if len(sv_words) == 1:
                            word_map[sv_words[0].upper()].append(tok_s)
            else:
                anchor = max(words, key=len).upper()
                pat = re.compile(rf"(?<![A-Za-z0-9]){re.escape(tok_s)}(?![A-Za-z0-9])", re.IGNORECASE)
                multi_map[anchor].append((tok_s, pat))
        return num_map, word_map, multi_map

    def _find_matching_tokens(
        title: str,
        blob_txt: str,
        sub_pairs: list[tuple[str, str]],
        num_map: dict[str, list[str]],
        word_map: dict[str, list[str]],
        multi_map: dict[str, list[tuple[str, re.Pattern[str]]]],
    ) -> set[str]:
        matched: set[str] = set()
        words_upper = {w.upper() for w in word_split_re.findall(blob_txt)}
        for w in words_upper:
            if w in word_map:
                matched.update(word_map[w])
            if w in multi_map:
                for orig_tok, pat in multi_map[w]:
                    if orig_tok not in matched and pat.search(blob_txt):
                        matched.add(orig_tok)
        if num_map:
            for m_num in num_sero_re.findall(title):
                u_num = m_num.upper()
                if u_num in num_map:
                    matched.update(num_map[u_num])
            for st_k, sn_v in sub_pairs:
                if st_k.strip().lower() in ("serotype", "serogroup", "serovar"):
                    u_sn = sn_v.strip().upper()
                    if u_sn in num_map:
                        matched.update(num_map[u_sn])
                    elif u_sn.startswith("O") and u_sn[1:] in num_map:
                        matched.update(num_map[u_sn[1:]])
        return matched

    assembly_loci_cache, strain_to_assembly, pmid_to_doi = _load_predicted_loci_cache_bundle(loci_cache_path)
    if (
        assembly_loci_cache
        and strain_to_assembly
        and pmid_to_doi
        and os.environ.get("KBANK_FORCE_LOCI_REFRESH", "0") != "1"
    ):
        print(
            f"[KBank] Loaded warm predicted_loci bundle ({len(assembly_loci_cache)} keys, {len(strain_to_assembly)} strain mappings, {len(pmid_to_doi)} DOIs) in {time.perf_counter() - t_stage0:.2f}s",
            flush=True,
        )
        return assembly_loci_cache, strain_to_assembly, pmid_to_doi

    base_acc_in_cache: dict[str, bytes] = {
        k.decode("utf-8", errors="replace").split(".")[0]: k
        for k in assembly_loci_cache
        if not k.startswith((b"nuccore:", b"pmid:", b"uid:"))
    }
    empty_loci_singleton = Loci.empty()

    explicit_nuccore: set[str] = set()
    for r in raw_records:
        acc_b = r.get("accession_hint", b"")
        if acc_b:
            acc_s = acc_b.decode("utf-8", errors="replace").strip()
            if not acc_s.startswith(("GCF_", "GCA_", "NC_", "NZ_")) and len(acc_s) >= 6:
                explicit_nuccore.add(acc_s)

    strain_counts: Counter[tuple[bytes, bytes]] = Counter()
    for r in raw_records:
        org_b = r["organism"]
        str_b = r["strain"]
        sero_b = r["serotype"]
        if _is_true_strain_isolate(org_b, str_b, sero_b):
            strain_counts[(org_b, str_b)] += 1

    top_strain_queries = [
        pair for pair, _ in sorted(strain_counts.items(), key=lambda kv: (-kv[1], kv[0]))[:max_assemblies]
    ]

    api_key = os.environ.get("NCBI_API_KEY", "")
    base_params: dict[str, str] = {"retmode": "json"}
    if api_key:
        base_params["api_key"] = api_key

    sem = asyncio.Semaphore(5 if api_key else 3)

    async def _polite_sleep(t_req: float, delay: float = 0.22) -> None:
        elapsed = time.perf_counter() - t_req
        if recorder is not None and elapsed < 0.25:
            return
        if elapsed > 0.05:
            await asyncio.sleep(delay)

    with _cassette_ctx(recorder, "ncbi_eutils.toml"):
        async with httpx2.AsyncClient(timeout=35.0, follow_redirects=True) as client:
            uncached_tier1 = [
                acc for acc in sorted(explicit_nuccore)[:300] if acc.split(".")[0] not in base_acc_in_cache
            ]
            tier1_uids: set[str] = set()
            for i in range(0, len(uncached_tier1), 35):
                batch_accs = uncached_tier1[i : i + 35]
                acc_clause = " OR ".join(f"{acc}[ACCN]" for acc in batch_accs)
                term_t1 = f"({acc_clause}) AND 2000:85000[SLEN]"
                async with sem:
                    t_req = time.perf_counter()
                    try:
                        r_t1 = await client.get(
                            f"{NCBI_EUTILS_URL}/esearch.fcgi",
                            params={**base_params, "db": "nuccore", "term": term_t1, "retmax": "100"},
                        )
                        if r_t1.status_code == 200:
                            for u in r_t1.json().get("esearchresult", {}).get("idlist", []):
                                tier1_uids.add(str(u))
                    except Exception:
                        pass
                    await _polite_sleep(t_req)

            print(
                f"[KBank] Tier 1 explicit accessions resolved ({len(tier1_uids)} nuccore UIDs, {len(base_acc_in_cache)} pre-cached, {time.perf_counter() - t_stage0:.1f}s)",
                flush=True,
            )

            pmid_weights: Counter[int] = Counter()
            for r in raw_records:
                p_val = int(r.get("pmid", 0))
                if p_val <= 0:
                    continue
                acc_b = r.get("accession_hint", b"")
                if acc_b and acc_b.decode("utf-8", errors="replace").split(".")[0] in base_acc_in_cache:
                    continue
                pmid_weights[p_val] += 1

            top_pmids = [p for p, _ in pmid_weights.most_common(600)]
            pmid_to_nuccore_uids: dict[int, list[str]] = {}

            async def _fetch_elink_batch(batch_pmids: list[int]) -> None:
                form_data: dict[str, Any] = {
                    **base_params,
                    "dbfrom": "pubmed",
                    "db": "nuccore",
                    "linkname": "pubmed_nuccore",
                    "id": [str(p) for p in batch_pmids],
                }
                async with sem:
                    t_req = time.perf_counter()
                    try:
                        r_el = await client.post(f"{NCBI_EUTILS_URL}/elink.fcgi", data=form_data)
                        if r_el.status_code == 200:
                            for ls in r_el.json().get("linksets", []):
                                ids_l = ls.get("ids", [])
                                if not ids_l:
                                    continue
                                p_val = int(ids_l[0])
                                for db_l in ls.get("linksetdbs", []):
                                    if db_l.get("linkname") == "pubmed_nuccore":
                                        links = [str(u) for u in db_l.get("links", [])]
                                        if 1 <= len(links) <= 45:
                                            pmid_to_nuccore_uids[p_val] = links[:20]
                    except Exception:
                        pass
                    await _polite_sleep(t_req)

            elink_batches = [top_pmids[i : i + 60] for i in range(0, len(top_pmids), 60)]
            await asyncio.gather(*[_fetch_elink_batch(b) for b in elink_batches])

            elink_uids = sorted(tier1_uids | {u for u_list in pmid_to_nuccore_uids.values() for u in u_list})
            uid_to_meta: dict[str, tuple[str, str, str, str, bool]] = {}
            uid_to_accver: dict[str, str] = {}

            async def _fetch_elink_summary(batch_u: list[str]) -> None:
                async with sem:
                    t_req = time.perf_counter()
                    try:
                        r_sum = await client.post(
                            f"{NCBI_EUTILS_URL}/esummary.fcgi",
                            data={**base_params, "db": "nuccore", "id": ",".join(batch_u)},
                        )
                        if r_sum.status_code == 200:
                            sum_res = r_sum.json().get("result", {})
                            for u in batch_u:
                                info = sum_res.get(u, {})
                                if not isinstance(info, dict):
                                    continue
                                slen = int(info.get("slen") or 0)
                                if not (2500 <= slen <= 65000):
                                    continue
                                title = info.get("title", "")
                                title_low = title.lower()
                                if any(
                                    bad in title_low
                                    for bad in ("16s", "23s", "rrna", "gyrb", "rpob", "reca", "phage", "integron")
                                ):
                                    continue
                                acc_ver = (info.get("accessionversion") or info.get("caption") or "").strip()
                                if not acc_ver:
                                    continue
                                subtype = info.get("subtype", "")
                                subname = info.get("subname", "")
                                is_cps_t = any(
                                    w in title_low for w in ("capsul", "cps", "kps", "k-antigen", "k antigen")
                                )
                                uid_to_meta[u] = (acc_ver, title, subname, subtype, is_cps_t)
                                uid_to_accver[u] = acc_ver
                    except Exception:
                        pass
                    await _polite_sleep(t_req)

            esum_batches = [elink_uids[i : i + 200] for i in range(0, len(elink_uids), 200)]
            await asyncio.gather(*[_fetch_elink_summary(b) for b in esum_batches])
            print(
                f"[KBank] Tier 1.5 PubMed elink complete ({len(pmid_to_nuccore_uids)} PMIDs -> {len(uid_to_meta)} BGC UIDs, {time.perf_counter() - t_stage0:.1f}s)",
                flush=True,
            )

            uid_to_strain: dict[str, tuple[bytes, bytes]] = {}
            unresolved_collection_strains: list[tuple[bytes, bytes]] = []
            collection_re = re.compile(r"\b(PCM|CCM|IMET|IAM|CIP|JCM|DSM|LMG|CECT|NCIMB|VKM)\b", re.IGNORECASE)

            async def _search_strain_asm(org_b: bytes, str_b: bytes) -> None:
                org_s = org_b.decode("utf-8", errors="replace")
                str_s = str_b.decode("utf-8", errors="replace")
                term = f'"{org_s}"[Organism] AND "{str_s}"[Infraspecies] AND "latest refseq"[Filter]'
                found_asm = False
                async with sem:
                    t_req = time.perf_counter()
                    try:
                        r = await client.get(
                            f"{NCBI_EUTILS_URL}/esearch.fcgi",
                            params={**base_params, "db": "assembly", "term": term, "retmax": "1"},
                        )
                        if r.status_code == 200:
                            idlist = r.json().get("esearchresult", {}).get("idlist", [])
                            if idlist:
                                uid_to_strain[str(idlist[0])] = (org_b, str_b)
                                found_asm = True
                    except Exception:
                        pass
                    await _polite_sleep(t_req)
                if not found_asm and collection_re.search(str_s):
                    unresolved_collection_strains.append((org_b, str_b))

            await asyncio.gather(*[_search_strain_asm(o_b, s_b) for o_b, s_b in top_strain_queries])

            biosample_uid_to_strain: dict[str, tuple[bytes, bytes]] = {}

            async def _search_biosample(org_b: bytes, str_b: bytes) -> None:
                org_s = org_b.decode("utf-8", errors="replace")
                str_s = str_b.decode("utf-8", errors="replace")
                bs_term = f'"{org_s}"[Organism] AND "{str_s}"'
                async with sem:
                    t_req = time.perf_counter()
                    try:
                        r_bs = await client.get(
                            f"{NCBI_EUTILS_URL}/esearch.fcgi",
                            params={**base_params, "db": "biosample", "term": bs_term, "retmax": "1"},
                        )
                        if r_bs.status_code == 200:
                            bs_ids = r_bs.json().get("esearchresult", {}).get("idlist", [])
                            if bs_ids:
                                biosample_uid_to_strain[str(bs_ids[0])] = (org_b, str_b)
                    except Exception:
                        pass
                    await _polite_sleep(t_req)

            await asyncio.gather(*[_search_biosample(o_b, s_b) for o_b, s_b in unresolved_collection_strains[:30]])

            if biosample_uid_to_strain:
                bs_uids = sorted(biosample_uid_to_strain.keys())
                async with sem:
                    t_req = time.perf_counter()
                    try:
                        r_bss = await client.post(
                            f"{NCBI_EUTILS_URL}/esummary.fcgi",
                            data={**base_params, "db": "biosample", "id": ",".join(bs_uids)},
                        )
                        if r_bss.status_code == 200:
                            bs_res = r_bss.json().get("result", {})
                            for b_uid in bs_uids:
                                b_info = bs_res.get(b_uid, {})
                                if not isinstance(b_info, dict):
                                    continue
                                b_blob = f"{b_info.get('title', '')} {b_info.get('infraspecies', '')} {b_info.get('sampledata', '')}"
                                m_syn = re.search(r"\b((?:ATCC|NCTC|DSM|CCUG)\s*\d+)\b", b_blob, re.IGNORECASE)
                                if m_syn:
                                    syn_s = re.sub(r"\s+", " ", m_syn.group(1).strip())
                                    org_b, orig_str_b = biosample_uid_to_strain[b_uid]
                                    org_s = org_b.decode("utf-8", errors="replace")
                                    term_syn = (
                                        f'"{org_s}"[Organism] AND "{syn_s}"[Infraspecies] AND "latest refseq"[Filter]'
                                    )
                                    r_as = await client.get(
                                        f"{NCBI_EUTILS_URL}/esearch.fcgi",
                                        params={**base_params, "db": "assembly", "term": term_syn, "retmax": "1"},
                                    )
                                    if r_as.status_code == 200:
                                        a_ids = r_as.json().get("esearchresult", {}).get("idlist", [])
                                        if a_ids:
                                            uid_to_strain[str(a_ids[0])] = (org_b, orig_str_b)
                    except Exception:
                        pass
                    await _polite_sleep(t_req)

            if uid_to_strain:
                uids = sorted(uid_to_strain.keys())
                try:
                    t_req = time.perf_counter()
                    r_sum = await client.post(
                        f"{NCBI_EUTILS_URL}/esummary.fcgi",
                        data={**base_params, "db": "assembly", "id": ",".join(uids)},
                    )
                    await _polite_sleep(t_req)
                    if r_sum.status_code == 200:
                        res_dict = r_sum.json().get("result", {})
                        for uid in uids:
                            info = res_dict.get(uid, {})
                            if not isinstance(info, dict):
                                continue
                            acc_str = info.get("assemblyaccession", "")
                            ftp_url = info.get("ftppath_refseq") or info.get("ftppath_genbank") or ""
                            if acc_str and ftp_url:
                                acc_b = acc_str.encode("utf-8")[:32]
                                strain_pair = uid_to_strain[uid]
                                if acc_b not in assembly_loci_cache:
                                    https_base = ftp_url.replace("ftp://", "https://")
                                    asm_name = https_base.rstrip("/").split("/")[-1]
                                    fna_url = f"{https_base}/{asm_name}_genomic.fna.gz"
                                    async with sem:
                                        try:
                                            f_resp = await client.get(fna_url, timeout=45.0)
                                            if f_resp.status_code == 200 and len(f_resp.content) > 100:
                                                raw_fna = gzip.decompress(f_resp.content)
                                                asm = GenomeAssembly.from_fasta(raw_fna, id_=acc_b)
                                                pred_loci = whole_genome_predictor.predict(asm)
                                                if len(pred_loci) > 0:
                                                    tagged = Loci.from_list(
                                                        [
                                                            loc.canonicalize_strand().with_name(acc_str[:32])
                                                            for loc in pred_loci
                                                        ]
                                                    )
                                                    assembly_loci_cache[acc_b] = (acc_b, tagged)
                                                else:
                                                    assembly_loci_cache[acc_b] = (acc_b, empty_loci_singleton)
                                        except Exception:
                                            pass
                                if acc_b in assembly_loci_cache and len(assembly_loci_cache[acc_b][1]) > 0:
                                    strain_to_assembly[strain_pair] = acc_b
                except Exception:
                    pass

            save_predicted_loci_cache(
                loci_cache_path,
                assembly_loci_cache,
                strain_to_assembly=strain_to_assembly,
                pmid_to_doi=pmid_to_doi,
            )
            print(
                f"[KBank] Tier 2 whole-genome assemblies complete ({len(strain_to_assembly)} strain assemblies, {time.perf_counter() - t_stage0:.1f}s)",
                flush=True,
            )

            taxon_record_counts: Counter[str] = Counter()
            taxon_target_seros: dict[str, set[str]] = defaultdict(set)
            taxon_target_strains: dict[str, set[str]] = defaultdict(set)
            key_record_weight: Counter[tuple[str, str]] = Counter()

            for r in raw_records:
                acc_b = r.get("accession_hint", b"")
                if acc_b and acc_b.decode("utf-8", errors="replace").split(".")[0] in base_acc_in_cache:
                    continue
                org_b = r["organism"]
                str_b = r["strain"]
                sero_b = r["serotype"]
                if (org_b, str_b) in strain_to_assembly:
                    continue
                taxon_q = _catalog_taxon(org_b)
                if not taxon_q:
                    continue
                taxon_record_counts[taxon_q] += 1
                if sero_b:
                    sero_s = sero_b.decode("utf-8", errors="replace").strip()
                    taxon_target_seros[taxon_q].add(sero_s)
                    key_record_weight[(taxon_q, sero_s)] += 1
                    m_base = re.match(r"^([KO]\d+)[a-z].*$", sero_s)
                    if m_base:
                        base_s = m_base.group(1)
                        taxon_target_seros[taxon_q].add(base_s)
                        key_record_weight[(taxon_q, base_s)] += 1
                if str_b and len(str_b) >= 3 and str_b != sero_b:
                    str_s = str_b.decode("utf-8", errors="replace").strip()
                    if not str_s.isdigit():
                        taxon_target_strains[taxon_q].add(str_s)
                        key_record_weight[(taxon_q, str_s)] += 1

            bgc_title_filter = (
                "AND 3000:60000[SLEN] AND ("
                '"capsular"[Title] OR "capsule"[Title] OR "cps"[Title] OR "kps"[Title] '
                'OR "K-antigen"[Title] OR "O-antigen"[Title] OR "lipopolysaccharide"[Title] '
                'OR "lipooligosaccharide"[Title] OR "polysaccharide"[Title] OR "exopolysaccharide"[Title] '
                'OR "teichoic"[Title] OR "outer core"[Title] OR "B-band"[Title] '
                'OR "O antigen"[Title] OR "K antigen"[Title] OR "wb"[Title] OR "waa"[Title] OR "wba"[Title]'
                ') NOT "whole genome shotgun"[Title] NOT "patent"[Filter] NOT "targeted locus study"[Title] '
                'NOT "pyocin"[Title] NOT "16S"[Title] NOT "plasmid"[Title] NOT "phage"[Title]'
            )

            landmark_sweeps: dict[str, str] = {
                "Streptococcus pneumoniae": "CR931632:CR931722[ACCN]",
                "Pseudomonas aeruginosa": "AF498400:AF498420[ACCN]",
                "Klebsiella": "AB924547:AB924624[ACCN]",
            }

            key_to_candidates: dict[tuple[str, str], list[tuple[str, str, bool]]] = defaultdict(list)
            top_taxa = [tq for tq, _ in taxon_record_counts.most_common()]

            async def _catalog_single_taxon(taxon_q: str) -> None:
                term = f'"{taxon_q}"[Organism] {bgc_title_filter}'
                idlist: list[str] = []
                async with sem:
                    t_req = time.perf_counter()
                    try:
                        r_es = await client.get(
                            f"{NCBI_EUTILS_URL}/esearch.fcgi",
                            params={**base_params, "db": "nuccore", "term": term, "retmax": "1000"},
                        )
                        if r_es.status_code == 200:
                            idlist = [str(x) for x in r_es.json().get("esearchresult", {}).get("idlist", [])]
                    except Exception:
                        pass
                    await _polite_sleep(t_req)

                if taxon_q in landmark_sweeps:
                    async with sem:
                        t_req = time.perf_counter()
                        try:
                            r_lm = await client.get(
                                f"{NCBI_EUTILS_URL}/esearch.fcgi",
                                params={
                                    **base_params,
                                    "db": "nuccore",
                                    "term": landmark_sweeps[taxon_q],
                                    "retmax": "200",
                                },
                            )
                            if r_lm.status_code == 200:
                                lm_ids = [str(x) for x in r_lm.json().get("esearchresult", {}).get("idlist", [])]
                                seen_id_set = set(idlist)
                                for lid in lm_ids:
                                    if lid not in seen_id_set:
                                        idlist.append(lid)
                                        seen_id_set.add(lid)
                        except Exception:
                            pass
                        await _polite_sleep(t_req)

                if not idlist:
                    return

                sum_dict: dict[str, Any] = {}
                for s_i in range(0, len(idlist), 350):
                    chunk_ids = idlist[s_i : s_i + 350]
                    async with sem:
                        t_req = time.perf_counter()
                        try:
                            r_sum = await client.post(
                                f"{NCBI_EUTILS_URL}/esummary.fcgi",
                                data={**base_params, "db": "nuccore", "id": ",".join(chunk_ids)},
                            )
                            if r_sum.status_code == 200:
                                sum_dict.update(r_sum.json().get("result", {}))
                        except Exception:
                            pass
                        await _polite_sleep(t_req)

                t_all_targets = taxon_target_seros[taxon_q] | taxon_target_strains[taxon_q]
                num_map, word_map, multi_map = _build_token_index(t_all_targets)

                for uid in idlist:
                    info = sum_dict.get(uid, {})
                    if not isinstance(info, dict):
                        continue
                    acc_ver = (info.get("accessionversion") or info.get("caption") or "").strip()
                    if not acc_ver:
                        continue
                    uid_to_accver[uid] = acc_ver
                    title = info.get("title", "")
                    subtype = info.get("subtype", "")
                    subname = info.get("subname", "")
                    blob_txt = f"{title} | {subname}"
                    title_low = title.lower()
                    is_cps_t = any(w in title_low for w in ("capsul", "cps", "kps", "k-antigen", "k antigen"))
                    sub_pairs = list(zip(subtype.split("|"), subname.split("|"), strict=False))

                    matched_toks = _find_matching_tokens(title, blob_txt, sub_pairs, num_map, word_map, multi_map)
                    for m_tok in matched_toks:
                        cand_list = key_to_candidates[(taxon_q, m_tok)]
                        if not any(u == uid for u, _, _ in cand_list):
                            cand_list.append((uid, acc_ver, is_cps_t))

            await asyncio.gather(*[_catalog_single_taxon(tq) for tq in top_taxa])
            print(
                f"[KBank] Tier 3 nuccore catalog indexed ({len(key_to_candidates)} taxon/token candidate pools, {time.perf_counter() - t_stage0:.1f}s)",
                flush=True,
            )

            selected_key_uids: dict[tuple[str, str], list[str]] = {}
            uid_priority: Counter[str] = Counter()

            for u in tier1_uids:
                if u in uid_to_meta:
                    uid_priority[u] += 50

            for _p_val, u_list in sorted(pmid_to_nuccore_uids.items()):
                valid_u = [u for u in u_list if u in uid_to_meta]
                for idx_u, u in enumerate(valid_u[:8]):
                    uid_priority[u] += 25 - idx_u

            for key_pair, cands in sorted(key_to_candidates.items()):
                cps_u = [u for u, _, is_c in cands if is_c][:2]
                ops_u = [u for u, _, is_c in cands if not is_c][:2]
                merged: list[str] = []
                for u in cps_u + ops_u:
                    if u not in merged and len(merged) < 3:
                        merged.append(u)
                if merged:
                    selected_key_uids[key_pair] = merged
                    w = key_record_weight.get(key_pair, 1)
                    for idx_u, u in enumerate(merged):
                        uid_priority[u] += w * (3 - idx_u)

            top_nuccore_uids = [u for u, _ in sorted(uid_priority.items(), key=lambda kv: (-kv[1], kv[0]))[:1200]]
            uid_to_predicted: dict[str, tuple[bytes, list[Locus]]] = {}
            accbase_to_uids: dict[str, list[str]] = defaultdict(list)
            uids_to_fetch: list[str] = []

            for u in top_nuccore_uids:
                av = uid_to_accver.get(u, "")
                base_av = av.split(".")[0] if av else ""
                if base_av:
                    accbase_to_uids[base_av].append(u)
                uid_cache_key = b"uid:" + u.encode("utf-8")
                if base_av and base_av in base_acc_in_cache:
                    cached_key = base_acc_in_cache[base_av]
                    c_id, c_loci = assembly_loci_cache[cached_key]
                    if len(c_loci) > 0:
                        uid_to_predicted[u] = (c_id, [c_loci[j] for j in range(len(c_loci))])
                elif uid_cache_key in assembly_loci_cache:
                    c_id, c_loci = assembly_loci_cache[uid_cache_key]
                    if len(c_loci) > 0:
                        uid_to_predicted[u] = (c_id, [c_loci[j] for j in range(len(c_loci))])
                else:
                    uids_to_fetch.append(u)

            for i in range(0, len(uids_to_fetch), 40):
                batch_uids = uids_to_fetch[i : i + 40]
                async with sem:
                    t_req = time.perf_counter()
                    try:
                        params = {
                            "db": "nuccore",
                            "id": ",".join(batch_uids),
                            "rettype": "fasta",
                            "retmode": "text",
                        }
                        if api_key:
                            params["api_key"] = api_key
                        resp = await client.get(f"{NCBI_EUTILS_URL}/efetch.fcgi", params=params, timeout=40.0)
                        if resp.status_code == 200 and resp.content.startswith(b">"):
                            grouped = _predict_multi_fasta_batch(resp.content)
                            for base_tok, (acc_b, tagged_list) in grouped.items():
                                loci_obj = Loci.from_list(tagged_list) if tagged_list else empty_loci_singleton
                                assembly_loci_cache[acc_b] = (acc_b, loci_obj)
                                base_acc_in_cache[base_tok] = acc_b
                                for matched_u in accbase_to_uids.get(base_tok, []):
                                    assembly_loci_cache[b"uid:" + matched_u.encode("utf-8")] = (acc_b, loci_obj)
                                    if tagged_list:
                                        uid_to_predicted[matched_u] = (acc_b, tagged_list)
                            for u_b in batch_uids:
                                u_key = b"uid:" + u_b.encode("utf-8")
                                if u_key not in assembly_loci_cache:
                                    av_u = uid_to_accver.get(u_b, "")
                                    base_u = av_u.split(".")[0] if av_u else ""
                                    acc_u_b = base_u.encode("utf-8")[:32] if base_u else b"empty"
                                    assembly_loci_cache[u_key] = (acc_u_b, empty_loci_singleton)
                                    if base_u and base_u not in base_acc_in_cache:
                                        assembly_loci_cache[acc_u_b] = (acc_u_b, empty_loci_singleton)
                                        base_acc_in_cache[base_u] = acc_u_b
                    except Exception:
                        pass
                    await _polite_sleep(t_req)

            save_predicted_loci_cache(
                loci_cache_path,
                assembly_loci_cache,
                strain_to_assembly=strain_to_assembly,
                pmid_to_doi=pmid_to_doi,
            )
            print(
                f"[KBank] Tier 1.5 + Tier 3 BGC FASTA segmentation complete ({len(uid_to_predicted)} BGCs predicted, {time.perf_counter() - t_stage0:.1f}s)",
                flush=True,
            )

            for p_val, u_list in pmid_to_nuccore_uids.items():
                pred_u = [u for u in u_list if u in uid_to_predicted]
                if not pred_u:
                    continue
                paper_pool: list[Locus] = []
                first_p_acc = b""
                for u in pred_u[:8]:
                    acc_b, l_list = uid_to_predicted[u]
                    if not first_p_acc:
                        first_p_acc = acc_b
                    paper_pool.extend(l_list)
                if paper_pool and first_p_acc:
                    pmid_key = b"pmid:%d" % p_val
                    assembly_loci_cache[pmid_key] = (first_p_acc, Loci.from_list(paper_pool))

            for r in raw_records:
                p_val = int(r.get("pmid", 0))
                if p_val <= 0 or p_val not in pmid_to_nuccore_uids:
                    continue
                pred_u = [u for u in pmid_to_nuccore_uids[p_val] if u in uid_to_predicted and u in uid_to_meta]
                if not pred_u:
                    continue
                r_toks = {
                    tok_b.decode("utf-8", errors="replace").strip(): tok_b
                    for tok_b in (r["serotype"], r["strain"])
                    if tok_b
                }
                if not r_toks:
                    continue
                p_num_map, p_word_map, p_multi_map = _build_token_index(set(r_toks.keys()))
                tok_to_loci: dict[bytes, tuple[bytes, list[Locus]]] = {}
                for u in pred_u:
                    acc_ver, title, subname, subtype, _ = uid_to_meta[u]
                    blob_txt = f"{title} | {subname}"
                    sub_pairs = list(zip(subtype.split("|"), subname.split("|"), strict=False))
                    m_toks = _find_matching_tokens(title, blob_txt, sub_pairs, p_num_map, p_word_map, p_multi_map)
                    for m_s in m_toks:
                        tok_b = r_toks[m_s]
                        acc_b, l_list = uid_to_predicted[u]
                        if tok_b not in tok_to_loci:
                            tok_to_loci[tok_b] = (acc_b, list(l_list))
                        else:
                            tok_to_loci[tok_b][1].extend(l_list)
                for tok_b, (first_m_acc, matched_loci) in tok_to_loci.items():
                    if matched_loci and first_m_acc:
                        spec_key = b"pmid:%d:%s" % (p_val, tok_b)
                        if spec_key not in assembly_loci_cache:
                            assembly_loci_cache[spec_key] = (first_m_acc, Loci.from_list(matched_loci))

            for r in raw_records:
                org_b = r["organism"]
                taxon_q = _catalog_taxon(org_b)
                if not taxon_q:
                    continue
                for tok_b in (r["strain"], r["serotype"]):
                    if not tok_b or (org_b, tok_b) in strain_to_assembly:
                        continue
                    tok_s = tok_b.decode("utf-8", errors="replace").strip()
                    uids_for_tok = selected_key_uids.get((taxon_q, tok_s), [])
                    if not uids_for_tok:
                        m_base = re.match(r"^([KO]\d+)[a-z].*$", tok_s)
                        if m_base:
                            uids_for_tok = selected_key_uids.get((taxon_q, m_base.group(1)), [])
                    if not uids_for_tok:
                        continue
                    pool_loci: list[Locus] = []
                    first_acc_b = b""
                    for u in uids_for_tok:
                        if u in uid_to_predicted:
                            acc_b, l_list = uid_to_predicted[u]
                            if not first_acc_b:
                                first_acc_b = acc_b
                            pool_loci.extend(l_list)
                    if pool_loci and first_acc_b:
                        cache_key = b"nuccore:%s:%s" % (taxon_q.encode("utf-8"), tok_b)
                        if cache_key not in assembly_loci_cache:
                            assembly_loci_cache[cache_key] = (first_acc_b, Loci.from_list(pool_loci))
                        strain_to_assembly[(org_b, tok_b)] = cache_key

            unique_pmids = sorted(
                {int(r["pmid"]) for r in raw_records if int(r.get("pmid", 0)) > 0 and not r.get("doi")}
            )

            async def _fetch_doi_batch(batch_pmids: list[str]) -> None:
                async with sem:
                    t_req = time.perf_counter()
                    try:
                        r_pm = await client.post(
                            f"{NCBI_EUTILS_URL}/esummary.fcgi",
                            data={**base_params, "db": "pubmed", "id": ",".join(batch_pmids)},
                        )
                        if r_pm.status_code == 200:
                            pm_res = r_pm.json().get("result", {})
                            for pid_s in batch_pmids:
                                art_ids = pm_res.get(pid_s, {}).get("articleids", [])
                                for aid in art_ids:
                                    if aid.get("idtype") == "doi" and aid.get("value"):
                                        pmid_to_doi[int(pid_s)] = aid["value"].strip().encode("utf-8")
                                        break
                    except Exception:
                        pass
                    await _polite_sleep(t_req)

            doi_batches = [
                [str(p) for p in unique_pmids[i : i + 150]] for i in range(0, min(len(unique_pmids), 800), 150)
            ]
            await asyncio.gather(*[_fetch_doi_batch(b) for b in doi_batches])

    save_predicted_loci_cache(
        loci_cache_path,
        assembly_loci_cache,
        strain_to_assembly=strain_to_assembly,
        pmid_to_doi=pmid_to_doi,
    )
    print(
        f"[KBank] resolve_and_predict_loci finished in {time.perf_counter() - t_stage0:.1f}s",
        flush=True,
    )
    return assembly_loci_cache, strain_to_assembly, pmid_to_doi

build_curated_glycan_records

build_curated_glycan_records(
    raw_records: list[dict[str, Any]],
    assembly_loci_cache: dict[bytes, tuple[bytes, Loci]],
    strain_to_assembly: dict[tuple[bytes, bytes], bytes],
    pmid_to_doi: dict[int, bytes],
    canonical_to_gtc: dict[bytes, bytes] | None = None,
) -> GlycanRecords

Consolidate duplicate/fragment records, graft modifiers, pair with Loci, and compile GlycanRecords.

Source code in src/kbank/db/curation.py
def build_curated_glycan_records(
    raw_records: list[dict[str, Any]],
    assembly_loci_cache: dict[bytes, tuple[bytes, Loci]],
    strain_to_assembly: dict[tuple[bytes, bytes], bytes],
    pmid_to_doi: dict[int, bytes],
    canonical_to_gtc: dict[bytes, bytes] | None = None,
) -> GlycanRecords:
    """Consolidate duplicate/fragment records, graft modifiers, pair with `Loci`, and compile `GlycanRecords`."""
    normalize_raw_record_taxonomy(raw_records)
    assigner = BiosyntheticAssigner()
    gtc_lookup = canonical_to_gtc or {}
    seen_keys: set[bytes] = set()
    initial_records: list[GlycanRecord] = []

    base_acc_lookup: dict[bytes, tuple[bytes, Loci]] = {
        k_acc.split(b".")[0]: val
        for k_acc, val in assembly_loci_cache.items()
        if not k_acc.startswith((b"nuccore:", b"pmid:"))
    }
    match_memo: dict[tuple[bytes, int, int, ArchetypeCategory | None, LocusArchetype | None], Any] = {}

    sero_acc_hints: dict[tuple[bytes, bytes], list[bytes]] = defaultdict(list)
    sero_pmids: dict[tuple[bytes, bytes], list[int]] = defaultdict(list)
    sero_strains: dict[tuple[bytes, bytes], list[bytes]] = defaultdict(list)
    comp_key_hints: dict[bytes, tuple[bytes, int, bytes]] = {}

    for r in raw_records:
        s_b = r.get("serotype", b"")
        acc_h = r.get("accession_hint", b"")
        pm_i = int(r.get("pmid", 0))
        st_b = r.get("strain", b"")
        ck = b"%s:%s" % (r["source_db"], r["record_id"])
        comp_key_hints[ck] = (acc_h, pm_i, st_b)

        if s_b:
            sk = (r["organism"], s_b)
            if acc_h and acc_h not in sero_acc_hints[sk]:
                sero_acc_hints[sk].append(acc_h)
            if pm_i > 0 and pm_i not in sero_pmids[sk]:
                sero_pmids[sk].append(pm_i)
            if st_b and st_b not in sero_strains[sk]:
                sero_strains[sk].append(st_b)

    for r in raw_records:
        comp_key = b"%s:%s" % (r["source_db"], r["record_id"])
        if comp_key in seen_keys:
            continue
        seen_keys.add(comp_key)

        gly: Glycan = r["glycan"]
        ag_type: bytes = r["antigen_type"]
        notes_b: bytes = r["notes"]
        hint_text = b"%s %s" % (ag_type, notes_b)

        expected_cat, expected_arch = assigner.infer_expected_archetype(hint_text)
        default_arch = (
            expected_arch
            if expected_arch is not None
            else (
                LocusArchetype.CPS_WZY
                if expected_cat == ArchetypeCategory.CAPSULE
                else (
                    LocusArchetype.LPS_WZY
                    if expected_cat == ArchetypeCategory.O_ANTIGEN
                    else (
                        LocusArchetype.EPS_PGA
                        if expected_cat == ArchetypeCategory.EXOPOLYSACCHARIDE
                        else LocusArchetype.UNKNOWN
                    )
                )
            )
        )

        pmid = int(r.get("pmid", 0))
        doi_b = r.get("doi", b"") or pmid_to_doi.get(pmid, b"")
        gtc_b = r.get("glytoucan_id", b"") or gtc_lookup.get(gly.canonical, b"")

        initial_records.append(
            GlycanRecord(
                source_db=r["source_db"],
                record_id=r["record_id"],
                organism=r["organism"],
                strain=r["strain"],
                serotype=r["serotype"],
                antigen_type=ag_type,
                assembly_id=r.get("accession_hint", b""),
                archetype=default_arch,
                locus=None,
                alignment_score=0.0,
                structure_raw=r["structure_raw"],
                structure_linear=r["structure_linear"],
                glycan=gly,
                is_repeating=bool(r["is_repeating"]),
                pmid=pmid,
                publication_year=int(r.get("publication_year", 0)),
                doi=doi_b,
                glytoucan_id=gtc_b,
                notes=notes_b,
            )
        )

    # 1. Consolidate duplicates/fragments & graft fragment modifiers BEFORE locus matching
    pre_consolidated = consolidate_curated_records(initial_records)

    # 2. Match consolidated canonical records against 4-tier candidate Loci pools
    paired_records: list[GlycanRecord] = []
    merged_ids_re = re.compile(rb"\[merged_ids=([^\]]+)\]")

    for rec in pre_consolidated:
        assert rec.glycan is not None
        gly = rec.glycan
        hint_text = b"%s %s" % (rec.antigen_type, rec.notes)
        expected_cat, expected_arch = assigner.infer_expected_archetype(hint_text)
        default_arch = rec.archetype

        sero_key = (rec.organism, rec.serotype)
        acc_candidates: list[bytes] = ([rec.assembly_id] if rec.assembly_id else []) + (
            list(sero_acc_hints.get(sero_key, [])) if rec.serotype else []
        )
        pmid_candidates: list[int] = ([rec.pmid] if rec.pmid > 0 else []) + (
            list(sero_pmids.get(sero_key, [])) if rec.serotype else []
        )
        strain_candidates: list[bytes] = ([rec.strain] if rec.strain else []) + (
            list(sero_strains.get(sero_key, [])) if rec.serotype else []
        )

        # Also harvest any accession/PMID/strain hints from absorbed cluster members
        m_merged = merged_ids_re.search(rec.notes)
        if m_merged:
            for mk in m_merged.group(1).split(b","):
                if mk in comp_key_hints:
                    m_acc, m_pmid, m_str = comp_key_hints[mk]
                    if m_acc and m_acc not in acc_candidates:
                        acc_candidates.append(m_acc)
                    if m_pmid > 0 and m_pmid not in pmid_candidates:
                        pmid_candidates.append(m_pmid)
                    if m_str and m_str not in strain_candidates:
                        strain_candidates.append(m_str)

        resolved_asm_id = b""
        cand_loci: Loci | None = None
        from_untagged_pmid_pool = False

        for acc_hint in acc_candidates:
            if acc_hint in assembly_loci_cache:
                resolved_asm_id, cand_loci = assembly_loci_cache[acc_hint]
                break
            base_acc = acc_hint.split(b".")[0]
            if base_acc in base_acc_lookup:
                resolved_asm_id, cand_loci = base_acc_lookup[base_acc]
                break
            if not resolved_asm_id:
                resolved_asm_id = acc_hint

        if cand_loci is None:
            for pmid_cand in pmid_candidates:
                for tok_b in [rec.serotype, *strain_candidates]:
                    if tok_b:
                        spec_key = b"pmid:%d:%s" % (pmid_cand, tok_b)
                        if spec_key in assembly_loci_cache:
                            resolved_asm_id, cand_loci = assembly_loci_cache[spec_key]
                            break
                if cand_loci is not None:
                    break

        if cand_loci is None:
            for st_cand in strain_candidates:
                strain_key = (rec.organism, st_cand)
                if strain_key in strain_to_assembly:
                    cache_key = strain_to_assembly[strain_key]
                    if cache_key in assembly_loci_cache:
                        resolved_asm_id, cand_loci = assembly_loci_cache[cache_key]
                        break

        if cand_loci is None and rec.serotype and sero_key in strain_to_assembly:
            cache_key = strain_to_assembly[sero_key]
            if cache_key in assembly_loci_cache:
                resolved_asm_id, cand_loci = assembly_loci_cache[cache_key]

        if cand_loci is None:
            for pmid_cand in pmid_candidates:
                pmid_key = b"pmid:%d" % pmid_cand
                if pmid_key in assembly_loci_cache:
                    resolved_asm_id, cand_loci = assembly_loci_cache[pmid_key]
                    from_untagged_pmid_pool = True
                    break

        matched_locus: Locus | None = None
        matched_arch: LocusArchetype = default_arch
        align_score: float = 0.0

        if cand_loci is not None and len(cand_loci) > 0:
            memo_key = (gly.canonical, len(gly.nodes), id(cand_loci), expected_cat, expected_arch)
            if memo_key in match_memo:
                match_res = match_memo[memo_key]
            else:
                match_res = assigner.match_locus(
                    gly,
                    cand_loci,
                    antigen_hint=hint_text,
                    expected_category=expected_cat,
                    expected_archetype=expected_arch,
                )
                match_memo[memo_key] = match_res

            if match_res is not None and (not from_untagged_pmid_pool or match_res.bio_score >= 0.30):
                matched_locus = match_res.locus
                if matched_locus.name:
                    resolved_asm_id = (
                        matched_locus.name.encode("utf-8")[:32]
                        if isinstance(matched_locus.name, str)
                        else bytes(matched_locus.name)[:32]
                    )
                matched_arch = match_res.archetype if match_res.archetype != LocusArchetype.UNKNOWN else default_arch
                align_score = float(match_res.combined_score)
            elif from_untagged_pmid_pool:
                resolved_asm_id = b""

        paired_records.append(
            replace(
                rec,
                assembly_id=resolved_asm_id,
                archetype=matched_arch,
                locus=matched_locus,
                alignment_score=align_score,
            )
        )

    return GlycanRecords.build(paired_records)

CSDB Linear Parser (kbank.io.csdb)

csdb


IUPAC Condensed/Linear Parser (kbank.io.iupac)

iupac


KEGG KCF Parser (kbank.io.kegg)

kegg

KEGG Glycan KCF (NODE/EDGE and COMPOSITION) flatfile parser.

Classes:

Name Description
KEGGParser

Parser for KEGG Glycan KCF flatfiles (NODE/EDGE connection tables and COMPOSITION blocks).

Functions:

Name Description
parse_kegg_glycan_flatfile

Parse a KEGG Glycan flatfile into a Glycan graph, returning None if unparseable or edgeless.

KEGGParser

Bases: GlycanParser

Parser for KEGG Glycan KCF flatfiles (NODE/EDGE connection tables and COMPOSITION blocks).

Methods:

Name Description
resolve_residue

Resolve a KEGG KCF monosaccharide or modifier label into a ResidueType.

parse

Parse a KEGG Glycan flatfile (NODE/EDGE or optional COMPOSITION) into a Glycan graph.

Source code in src/kbank/io/kegg.py
class KEGGParser(GlycanParser):
    """Parser for KEGG Glycan KCF flatfiles (`NODE`/`EDGE` connection tables and `COMPOSITION` blocks)."""

    _RE_ENTRY = re.compile(r"^ENTRY\s+(G\d{5})", re.MULTILINE)
    _RE_COMP_TOKENS = re.compile(r"\(([^)]+)\)")
    _RE_DIGITS = re.compile(r"\d")

    def resolve_residue(self, token: str, strict: bool = False) -> ResidueType | None:
        """Resolve a KEGG KCF monosaccharide or modifier label into a `ResidueType`."""
        clean = token.strip().replace("(", "").replace(")", "")
        if not clean:
            return None

        chi = Chirality.UNKNOWN
        if clean.startswith(("D-", "d-")):
            chi = Chirality.D
            clean = clean[2:]
        elif clean.startswith(("L-", "l-")):
            chi = Chirality.L
            clean = clean[2:]
        elif len(clean) > 3 and clean[0] in ("a", "b") and clean[1] == "-":
            clean = clean[2:]
            if clean.startswith(("D-", "d-")):
                chi = Chirality.D
                clean = clean[2:]
            elif clean.startswith(("L-", "l-")):
                chi = Chirality.L
                clean = clean[2:]

        b_clean = clean.encode("utf-8")
        is_fur = b_clean.endswith(b"f")
        s_idx = self.mono_map.get(b_clean)
        if s_idx is None and b_clean.endswith((b"p", b"f")):
            s_idx = self.mono_map.get(b_clean[:-1])
        if s_idx is None:
            if strict:
                raise GlycanParseError(f"Unknown KEGG sugar token: {token!r}")
            return None
        if chi == Chirality.UNKNOWN:
            chi = Chirality(Sugars.natural_chirality[s_idx])
        return ResidueType.from_sugar(s_idx, chi, is_fur)

    def parse(
        self,
        sequence: bytes | str,
        strict: bool = False,
        *,
        record_id: bytes | str = b"",
        allow_composition: bool = False,
    ) -> Glycan:
        """Parse a KEGG Glycan flatfile (`NODE`/`EDGE` or optional `COMPOSITION`) into a `Glycan` graph."""
        text = sequence.decode("utf-8", errors="replace") if isinstance(sequence, (bytes, bytearray)) else str(sequence)
        rec_b = record_id.encode("utf-8") if isinstance(record_id, str) else bytes(record_id)
        if not rec_b:
            m_entry = self._RE_ENTRY.search(text)
            if m_entry:
                rec_b = m_entry.group(1).encode("utf-8")

        in_node = False
        in_edge = False
        raw_nodes: list[tuple[int, str]] = []
        raw_edges: list[tuple[int, int, str]] = []
        composition = ""

        for line in text.splitlines():
            if line.startswith("COMPOSITION"):
                composition = line[len("COMPOSITION") :].strip()
                in_node = False
                in_edge = False
            elif line.startswith("NODE"):
                in_node = True
                in_edge = False
            elif line.startswith("EDGE"):
                in_edge = True
                in_node = False
            elif line and not line[0].isspace():
                in_node = False
                in_edge = False
            elif in_node:
                parts = line.strip().split()
                if len(parts) >= 2 and parts[0].isdigit():
                    raw_nodes.append((int(parts[0]), parts[1]))
            elif in_edge:
                parts = line.strip().split()
                if len(parts) >= 3 and parts[0].isdigit():
                    donor_part = parts[1]
                    target_part = parts[2]
                    s_str, d_link = donor_part.split(":", 1) if ":" in donor_part else (donor_part, "")
                    t_str, a_link = target_part.split(":", 1) if ":" in target_part else (target_part, "")
                    if s_str.isdigit() and t_str.isdigit():
                        raw_edges.append((int(s_str), int(t_str), f"{d_link}:{a_link}"))

        if raw_nodes:
            id_map: dict[int, int] = {}
            nodes: list[ResidueType] = []
            for kegg_idx, label in raw_nodes:
                res = self.resolve_residue(label, strict=strict)
                if res is not None:
                    if len(nodes) == 0:
                        res = res.with_reducing_end()
                    id_map[kegg_idx] = len(nodes)
                    nodes.append(res)
            if not nodes:
                raise GlycanParseError("KEGG NODE block contained no resolvable monosaccharides.")

            edges: list[LinkageEdge] = []
            for s_kegg, t_kegg, link_str in raw_edges:
                if s_kegg in id_map and t_kegg in id_map:
                    d_part, a_part = link_str.split(":", 1) if ":" in link_str else (link_str, "")
                    anom = Anomer.ALPHA if "a" in d_part else (Anomer.BETA if "b" in d_part else Anomer.UNKNOWN)
                    d_nums = [int(d) for d in self._RE_DIGITS.findall(d_part) if 1 <= int(d) <= 9]
                    a_nums = [int(d) for d in self._RE_DIGITS.findall(a_part) if 1 <= int(d) <= 9]
                    donor_c = d_nums[0] if d_nums else 1
                    acc_c = a_nums[0] if a_nums else (d_nums[1] if len(d_nums) >= 2 else 0)
                    edges.append(
                        LinkageEdge(
                            source=id_map[s_kegg],
                            target=id_map[t_kegg],
                            edge_type=LinkageType(anom, donor=donor_c, acceptor=acc_c),
                        )
                    )
            if not edges and not allow_composition:
                raise GlycanParseError("KEGG entry has NODE definitions but 0 connected glycosidic edges.")
            return Glycan(canonical=rec_b or b"KEGG", nodes=tuple(nodes), edges=tuple(edges))

        if composition and allow_composition:
            tokens = self._RE_COMP_TOKENS.findall(composition)
            nodes = []
            for tok in tokens:
                res = self.resolve_residue(tok, strict=strict)
                if res is not None:
                    if len(nodes) == 0:
                        res = res.with_reducing_end()
                    nodes.append(res)
            if nodes:
                return Glycan(canonical=composition.encode("utf-8"), nodes=tuple(nodes), edges=())

        raise GlycanParseError("KEGG flatfile contains no connected NODE/EDGE structure.")

resolve_residue

resolve_residue(
    token: str, strict: bool = False
) -> ResidueType | None

Resolve a KEGG KCF monosaccharide or modifier label into a ResidueType.

Source code in src/kbank/io/kegg.py
def resolve_residue(self, token: str, strict: bool = False) -> ResidueType | None:
    """Resolve a KEGG KCF monosaccharide or modifier label into a `ResidueType`."""
    clean = token.strip().replace("(", "").replace(")", "")
    if not clean:
        return None

    chi = Chirality.UNKNOWN
    if clean.startswith(("D-", "d-")):
        chi = Chirality.D
        clean = clean[2:]
    elif clean.startswith(("L-", "l-")):
        chi = Chirality.L
        clean = clean[2:]
    elif len(clean) > 3 and clean[0] in ("a", "b") and clean[1] == "-":
        clean = clean[2:]
        if clean.startswith(("D-", "d-")):
            chi = Chirality.D
            clean = clean[2:]
        elif clean.startswith(("L-", "l-")):
            chi = Chirality.L
            clean = clean[2:]

    b_clean = clean.encode("utf-8")
    is_fur = b_clean.endswith(b"f")
    s_idx = self.mono_map.get(b_clean)
    if s_idx is None and b_clean.endswith((b"p", b"f")):
        s_idx = self.mono_map.get(b_clean[:-1])
    if s_idx is None:
        if strict:
            raise GlycanParseError(f"Unknown KEGG sugar token: {token!r}")
        return None
    if chi == Chirality.UNKNOWN:
        chi = Chirality(Sugars.natural_chirality[s_idx])
    return ResidueType.from_sugar(s_idx, chi, is_fur)

parse

parse(
    sequence: bytes | str,
    strict: bool = False,
    *,
    record_id: bytes | str = b"",
    allow_composition: bool = False,
) -> Glycan

Parse a KEGG Glycan flatfile (NODE/EDGE or optional COMPOSITION) into a Glycan graph.

Source code in src/kbank/io/kegg.py
def parse(
    self,
    sequence: bytes | str,
    strict: bool = False,
    *,
    record_id: bytes | str = b"",
    allow_composition: bool = False,
) -> Glycan:
    """Parse a KEGG Glycan flatfile (`NODE`/`EDGE` or optional `COMPOSITION`) into a `Glycan` graph."""
    text = sequence.decode("utf-8", errors="replace") if isinstance(sequence, (bytes, bytearray)) else str(sequence)
    rec_b = record_id.encode("utf-8") if isinstance(record_id, str) else bytes(record_id)
    if not rec_b:
        m_entry = self._RE_ENTRY.search(text)
        if m_entry:
            rec_b = m_entry.group(1).encode("utf-8")

    in_node = False
    in_edge = False
    raw_nodes: list[tuple[int, str]] = []
    raw_edges: list[tuple[int, int, str]] = []
    composition = ""

    for line in text.splitlines():
        if line.startswith("COMPOSITION"):
            composition = line[len("COMPOSITION") :].strip()
            in_node = False
            in_edge = False
        elif line.startswith("NODE"):
            in_node = True
            in_edge = False
        elif line.startswith("EDGE"):
            in_edge = True
            in_node = False
        elif line and not line[0].isspace():
            in_node = False
            in_edge = False
        elif in_node:
            parts = line.strip().split()
            if len(parts) >= 2 and parts[0].isdigit():
                raw_nodes.append((int(parts[0]), parts[1]))
        elif in_edge:
            parts = line.strip().split()
            if len(parts) >= 3 and parts[0].isdigit():
                donor_part = parts[1]
                target_part = parts[2]
                s_str, d_link = donor_part.split(":", 1) if ":" in donor_part else (donor_part, "")
                t_str, a_link = target_part.split(":", 1) if ":" in target_part else (target_part, "")
                if s_str.isdigit() and t_str.isdigit():
                    raw_edges.append((int(s_str), int(t_str), f"{d_link}:{a_link}"))

    if raw_nodes:
        id_map: dict[int, int] = {}
        nodes: list[ResidueType] = []
        for kegg_idx, label in raw_nodes:
            res = self.resolve_residue(label, strict=strict)
            if res is not None:
                if len(nodes) == 0:
                    res = res.with_reducing_end()
                id_map[kegg_idx] = len(nodes)
                nodes.append(res)
        if not nodes:
            raise GlycanParseError("KEGG NODE block contained no resolvable monosaccharides.")

        edges: list[LinkageEdge] = []
        for s_kegg, t_kegg, link_str in raw_edges:
            if s_kegg in id_map and t_kegg in id_map:
                d_part, a_part = link_str.split(":", 1) if ":" in link_str else (link_str, "")
                anom = Anomer.ALPHA if "a" in d_part else (Anomer.BETA if "b" in d_part else Anomer.UNKNOWN)
                d_nums = [int(d) for d in self._RE_DIGITS.findall(d_part) if 1 <= int(d) <= 9]
                a_nums = [int(d) for d in self._RE_DIGITS.findall(a_part) if 1 <= int(d) <= 9]
                donor_c = d_nums[0] if d_nums else 1
                acc_c = a_nums[0] if a_nums else (d_nums[1] if len(d_nums) >= 2 else 0)
                edges.append(
                    LinkageEdge(
                        source=id_map[s_kegg],
                        target=id_map[t_kegg],
                        edge_type=LinkageType(anom, donor=donor_c, acceptor=acc_c),
                    )
                )
        if not edges and not allow_composition:
            raise GlycanParseError("KEGG entry has NODE definitions but 0 connected glycosidic edges.")
        return Glycan(canonical=rec_b or b"KEGG", nodes=tuple(nodes), edges=tuple(edges))

    if composition and allow_composition:
        tokens = self._RE_COMP_TOKENS.findall(composition)
        nodes = []
        for tok in tokens:
            res = self.resolve_residue(tok, strict=strict)
            if res is not None:
                if len(nodes) == 0:
                    res = res.with_reducing_end()
                nodes.append(res)
        if nodes:
            return Glycan(canonical=composition.encode("utf-8"), nodes=tuple(nodes), edges=())

    raise GlycanParseError("KEGG flatfile contains no connected NODE/EDGE structure.")

parse_kegg_glycan_flatfile

parse_kegg_glycan_flatfile(
    text: str | bytes,
    record_id: str | bytes = b"",
    *,
    allow_composition: bool = False,
) -> Glycan | None

Parse a KEGG Glycan flatfile into a Glycan graph, returning None if unparseable or edgeless.

Source code in src/kbank/io/kegg.py
def parse_kegg_glycan_flatfile(
    text: str | bytes,
    record_id: str | bytes = b"",
    *,
    allow_composition: bool = False,
) -> Glycan | None:
    """Parse a KEGG Glycan flatfile into a `Glycan` graph, returning `None` if unparseable or edgeless."""
    parser = KEGGParser()
    try:
        return parser.parse(text, strict=False, record_id=record_id, allow_composition=allow_composition)
    except GlycanParseError, ValueError, KeyError, IndexError:
        return None

Public Database Harvesters (kbank.io.sources)

sources

External carbohydrate database normalizers, parsers, and async/streaming fetchers.

Functions:

Name Description
clean_html_text

Strip HTML tags and unescape entities into normalized whitespace text.

classify_antigen_label

Classify raw compound class / description text into a canonical antigen type label.

extract_serotype_antigen_aware

Extract and canonicalize serotype designation with awareness of antigen_type (CPS vs O-antigen).

parse_organism_and_strain

Extract canonical (organism, strain, serotype_hint) from a biological source string.

normalize_ecodab_structure

Convert ECODAB multi-line ASCII sugar diagram into CSDB linear sequence notation.

normalize_ek3d_iupac_to_csdb

Convert EK3D condensed IUPAC repeat notation into CSDB linear sequence syntax.

normalize_epsdb_backbone

Convert CERMAV EPS-DB backbone { 4)bDGlcp(1-4)... }n into CSDB linear -4)bDGlcp(1-4)....

parse_glycan_safe

Attempt to parse seq_bytes with CSDBParser and fallback to IUPACParser.

stream_csdb_database

Stream CSDB from OSF (or load from compressed local cache), indexing cross-references and surface antigens.

fetch_ecodab_records

Asynchronously scrape ECODAB E. coli O-antigen records from list.php & showDetails.php.

fetch_ek3d_records

Fetch EK3D E. coli K-antigen records (Groups 1-4) and cross-link with CSDB K-antigen structures.

fetch_epsdb_records

Asynchronously scrape CERMAV EPS-DB bacterial exopolysaccharide entries from database.html.

fetch_kegg_glycan_records

Asynchronously fetch connected bacterial surface glycans from KEGG (map00540, map00552, keywords).

fetch_glytoucan_records

Query GlyTouCan / GlyCosmos SPARQL to build a canonical structure -> glytoucan_id cross-reference map.

clean_html_text

clean_html_text(raw_html: str) -> str

Strip HTML tags and unescape entities into normalized whitespace text.

Source code in src/kbank/io/sources.py
def clean_html_text(raw_html: str) -> str:
    """Strip HTML tags and unescape entities into normalized whitespace text."""
    text = re.sub(r"<[^>]+>", " ", raw_html)
    text = html.unescape(text)
    return re.sub(r"\s+", " ", text).strip()

classify_antigen_label

classify_antigen_label(
    text: str, bio_src: str = ""
) -> bytes

Classify raw compound class / description text into a canonical antigen type label.

Source code in src/kbank/io/sources.py
def classify_antigen_label(text: str, bio_src: str = "") -> bytes:
    """Classify raw compound class / description text into a canonical antigen type label."""
    low = text.lower()
    if any(k in low for k in ("k-antigen", "k antigen", "cps", "capsul", "vi-antigen", "kps")):
        return b"CPS"
    if any(k in low for k in ("o-antigen", "o antigen", "ops", "lps", "lipopolysaccharide", "b-band", "osa")):
        return b"O-antigen"
    if any(k in low for k in ("los", "lipooligosaccharide", "outer core", "ocl")):
        return b"LOS"
    if any(k in low for k in ("teichoic", "wta", "lta")):
        return b"WTA"
    if any(k in low for k in ("eps", "exopolysaccharide", "biofilm", "alginate", "cellulose")):
        return b"EPS"

    src_low = f"{bio_src} {text}".lower()
    if re.search(r"\b(kl\d+|k\d+[a-z]?|capsular)\b", src_low):
        return b"CPS"
    if any(
        g in src_low
        for g in (
            "streptococcus",
            "klebsiella",
            "neisseria meningitidis",
            "haemophilus influenzae",
            "staphylococcus aureus",
            "cryptococcus",
        )
    ):
        return b"CPS"
    if any(
        g in src_low
        for g in (
            "staphylococcus",
            "bacillus",
            "listeria",
            "enterococcus",
            "lactobacillus",
            "lactococcus",
            "bifidobacterium",
            "clostrid",
            "streptomyces",
            "actinomyces",
            "micrococcus",
            "rathayibacter",
            "arthrobacter",
        )
    ):
        return b"WTA"
    if any(
        g in src_low
        for g in (
            "rhizobium",
            "sinorhizobium",
            "bradyrhizobium",
            "agrobacterium",
            "mesorhizobium",
            "azospirillum",
            "xanthomonas",
            "sphingomonas",
            "leuconostoc",
            "pediococcus",
            "acetobacter",
            "gluconacetobacter",
        )
    ):
        return b"EPS"
    return b"O-antigen"

extract_serotype_antigen_aware

extract_serotype_antigen_aware(
    org: str,
    strain_rest: str,
    bio_source: str,
    antigen_type: bytes = b"",
    extra_text: str = "",
) -> str

Extract and canonicalize serotype designation with awareness of antigen_type (CPS vs O-antigen).

Source code in src/kbank/io/sources.py
def extract_serotype_antigen_aware(
    org: str,
    strain_rest: str,
    bio_source: str,
    antigen_type: bytes = b"",
    extra_text: str = "",
) -> str:
    """Extract and canonicalize serotype designation with awareness of `antigen_type` (`CPS` vs `O-antigen`)."""
    ag_upper = antigen_type.decode("utf-8", errors="replace").upper()
    is_cps = "CPS" in ag_upper or "CAPSUL" in ag_upper
    is_ops = "O-ANTIGEN" in ag_upper or "LPS" in ag_upper or "LOS" in ag_upper
    org_low = org.lower()

    def _clean_sero_token(tok: str) -> str:
        t = re.sub(
            r"^(?:serotype|serogroup|capsular\s+type|iats|type)\s+",
            "",
            tok.strip(),
            flags=re.IGNORECASE,
        ).strip()
        if re.match(r"^[ko]\d+", t, re.IGNORECASE):
            t = t[0].upper() + t[1:]
        elif re.match(r"^(?:kl|ol|ocl)\d+", t, re.IGNORECASE):
            m_p = re.match(r"^([A-Za-z]+)(.+)$", t)
            if m_p:
                t = m_p.group(1).upper() + m_p.group(2)
        return t

    search_fields = [strain_rest, bio_source, extra_text]
    if is_cps:
        for fld in search_fields:
            if not fld:
                continue
            for m in CPS_SEROTYPE_RE.finditer(fld):
                cand = _clean_sero_token(m.group(1))
                if cand:
                    if cand.isdigit() and any(g in org_low for g in ("klebsiella", "acinetobacter", "escherichia")):
                        if "klebsiella" in org_low and int(cand) > 82:
                            continue
                        return f"K{cand}"
                    return cand
    elif is_ops:
        for fld in search_fields:
            if not fld:
                continue
            for m in OPS_SEROTYPE_RE.finditer(fld):
                cand = _clean_sero_token(m.group(1))
                if cand and cand.upper() not in ("K12", "K-12"):
                    if cand.isdigit() and any(
                        g in org_low
                        for g in (
                            "klebsiella",
                            "acinetobacter",
                            "escherichia",
                            "pseudomonas",
                            "salmonella",
                            "vibrio",
                            "proteus",
                            "serratia",
                            "yersinia",
                        )
                    ):
                        if "klebsiella" in org_low and int(cand) > 20:
                            continue
                        return f"O{cand}"
                    return cand
        if "klebsiella" in org_low:
            extra_low = f"{bio_source} {extra_text}".lower()
            if any(g_tok in extra_low for g_tok in ("galactan-i", "galactan-iii", "d-galactan")):
                return "O2"
        if "salmonella" in org_low:
            combined_low = f"{strain_rest} {bio_source}".lower()
            for sv_name, o_grp in SALMONELLA_SEROVAR_TO_OGROUP.items():
                if re.search(rf"\b{re.escape(sv_name)}\b", combined_low):
                    return o_grp

    # General fallback across fields
    for fld in search_fields:
        if not fld:
            continue
        m_any = SEROTYPE_EXTRACT_RE.search(fld)
        if m_any:
            cand = _clean_sero_token(m_any.group(1))
            if is_ops and cand.upper() in ("K12", "K-12"):
                continue
            if cand.isdigit() and "klebsiella" in org_low and int(cand) > 82:
                continue
            return cand

    bare = strain_rest.strip()
    if re.fullmatch(r"\d{1,3}[A-Fa-f]?|[IVX]{1,4}[abAB]?", bare):
        if is_cps and bare.isdigit() and any(g in org_low for g in ("klebsiella", "acinetobacter", "escherichia")):
            if "klebsiella" in org_low and int(bare) > 82:
                return ""
            return f"K{bare}"
        if (
            is_ops
            and bare.isdigit()
            and any(
                g in org_low
                for g in (
                    "klebsiella",
                    "acinetobacter",
                    "escherichia",
                    "pseudomonas",
                    "salmonella",
                    "vibrio",
                    "proteus",
                    "serratia",
                )
            )
        ):
            return f"O{bare}"
        return bare

    return ""

parse_organism_and_strain

parse_organism_and_strain(
    bio_source: str,
    antigen_type: bytes = b"",
    extra_text: str = "",
) -> tuple[bytes, bytes, bytes]

Extract canonical (organism, strain, serotype_hint) from a biological source string.

Source code in src/kbank/io/sources.py
def parse_organism_and_strain(
    bio_source: str,
    antigen_type: bytes = b"",
    extra_text: str = "",
) -> tuple[bytes, bytes, bytes]:
    """Extract canonical `(organism, strain, serotype_hint)` from a biological source string."""
    src = bio_source.split(";")[0].strip()
    m_new = re.search(r"\{NEW:\s*([^}]+)\}", src, re.IGNORECASE)
    if m_new:
        src = m_new.group(1).strip()
    src = re.sub(r"\{[^}]*\}", "", src)
    src = re.sub(r"\s*\([^)]*\)", "", src).strip()

    tokens = src.split()
    if len(tokens) >= 2:
        t0_low = tokens[0].lower()
        t1_low = tokens[1].lower()
        if t1_low in ("sp.", "spp."):
            org = f"{tokens[0]} sp."
            rest = " ".join(tokens[2:])
        elif t0_low == "salmonella" and t1_low not in ("enterica", "bongori", "sp.", "spp."):
            # Classic Kauffmann-White binomen e.g. 'Salmonella typhimurium' -> Salmonella enterica + serovar
            org = "Salmonella enterica"
            sv_cap = tokens[1][0].upper() + tokens[1][1:]
            rest = " ".join([sv_cap, *tokens[2:]])
        elif not tokens[1].isalpha() or not tokens[1].islower():
            org = f"{tokens[0]} sp."
            rest = " ".join(tokens[1:])
        elif len(tokens) >= 4 and tokens[2].lower() in ("subsp.", "ssp.", "pv.", "serovar", "sv.", "biovar"):
            org = f"{tokens[0]} {tokens[1]}"
            if tokens[2].lower() in ("subsp.", "ssp."):
                rest = " ".join(tokens[4:])
            else:
                rest = " ".join(tokens[2:])
        else:
            org = f"{tokens[0]} {tokens[1]}"
            rest = " ".join(tokens[2:])
    elif len(tokens) == 1:
        org = tokens[0]
        rest = ""
    else:
        return b"Unknown bacterium", b"", b""

    strain = re.sub(
        r"^(?:strain|str\.|serovar|sv\.|serotype|serogroup|type)\s+",
        "",
        rest,
        flags=re.IGNORECASE,
    ).strip(" ,;:-")
    sero_hint = extract_serotype_antigen_aware(
        org, strain, bio_source, antigen_type=antigen_type, extra_text=extra_text
    )

    return (
        org.encode("utf-8")[:64],
        strain.encode("utf-8")[:64],
        sero_hint.encode("utf-8")[:32],
    )

normalize_ecodab_structure

normalize_ecodab_structure(raw_struct: str) -> bytes

Convert ECODAB multi-line ASCII sugar diagram into CSDB linear sequence notation.

Source code in src/kbank/io/sources.py
def normalize_ecodab_structure(raw_struct: str) -> bytes:
    """Convert ECODAB multi-line ASCII sugar diagram into CSDB linear sequence notation."""
    clean = re.sub(r"<[^>]+>", "", html.unescape(raw_struct))
    lines = [ln.rstrip() for ln in clean.splitlines() if ln.strip()]
    if not lines:
        return b""

    backbone_idx = -1
    for idx, ln in enumerate(lines):
        if ln.lstrip().startswith(("->", "-")):
            backbone_idx = idx
            break
    if backbone_idx < 0:
        backbone_idx = max(range(len(lines)), key=lambda i: len(lines[i].strip()))

    backbone = lines[backbone_idx].strip()

    def _clean_token(tok: str) -> str:
        t = tok.strip()
        t = re.sub(r"\((\d+)->(\d+)\)", r"(\1-\2)", t)
        t = re.sub(r"\((\d+)-P-(\d+)\)", r"(\1-\2)", t)
        t = re.sub(r"^->(\d+)\)", r"-\1)", t)
        t = re.sub(r"\((\d+)->$", r"(\1-", t)
        return t

    s = _clean_token(backbone)
    branch_sugars: list[str] = []
    for idx, ln in enumerate(lines):
        if idx == backbone_idx:
            continue
        stripped = ln.strip()
        if stripped in ("|", "/", "\\") or re.fullmatch(r"[|/\\\s]+", stripped):
            continue
        cleaned = _clean_token(stripped)
        if any(c.isalpha() for c in cleaned):
            branch_sugars.append(cleaned)

    for br in branch_sugars:
        if re.search(r"\(\d+-\d+\)$", br):
            s = re.sub(r"([a-zA-Z0-9]+)(\(\d+-\d+\))", rf"[{br}]\1\2", s, count=1)

    return s.encode("utf-8")

normalize_ek3d_iupac_to_csdb

normalize_ek3d_iupac_to_csdb(raw_iupac: str) -> bytes

Convert EK3D condensed IUPAC repeat notation into CSDB linear sequence syntax.

Source code in src/kbank/io/sources.py
def normalize_ek3d_iupac_to_csdb(raw_iupac: str) -> bytes:
    """Convert EK3D condensed IUPAC repeat notation into CSDB linear sequence syntax."""
    s = raw_iupac.strip()
    if not s:
        return b""

    s = s.replace("\u03b1", "a").replace("\u03b2", "b").replace("\u2192", "->")
    s = re.sub(r"^\[?", "", s)
    s = re.sub(r"\]?[nN]?\s*$", "", s)
    s = re.sub(r"^->(\d+)\)-?", r"-\1)", s)
    s = re.sub(r"-\((\d+)->$", r"(\1-", s)
    s = re.sub(r"\((\d+)->$", r"(\1-", s)
    s = re.sub(r"-\((\d+)->(\d+)\)-", r"(\1-\2)", s)
    s = re.sub(r"\((\d+)->(\d+)\)", r"(\1-\2)", s)
    return s.encode("utf-8")

normalize_epsdb_backbone

normalize_epsdb_backbone(backbone: str) -> bytes

Convert CERMAV EPS-DB backbone { 4)bDGlcp(1-4)... }n into CSDB linear -4)bDGlcp(1-4)....

Source code in src/kbank/io/sources.py
def normalize_epsdb_backbone(backbone: str) -> bytes:
    """Convert CERMAV EPS-DB backbone `{ 4)bDGlcp(1-4)... }n` into CSDB linear `-4)bDGlcp(1-4)...`."""
    bb_clean = re.sub(r"^\{?\s*", "", backbone.strip())
    bb_clean = re.sub(r"\s*\}n?\s*$", "", bb_clean)
    if re.match(r"^\d\)", bb_clean):
        bb_clean = "-" + bb_clean
    bb_clean = re.sub(r"\d+Ac[0-9.]*", "", bb_clean)
    bb_clean = re.sub(r"\(\d+-P-\d+\)", "(1-4)", bb_clean)
    return bb_clean.encode("utf-8")

parse_glycan_safe

parse_glycan_safe(
    seq_bytes: bytes,
    csdb_parser: CSDBParser | None = None,
    iupac_parser: IUPACParser | None = None,
) -> Glycan | None

Attempt to parse seq_bytes with CSDBParser and fallback to IUPACParser.

Source code in src/kbank/io/sources.py
def parse_glycan_safe(
    seq_bytes: bytes,
    csdb_parser: CSDBParser | None = None,
    iupac_parser: IUPACParser | None = None,
) -> Glycan | None:
    """Attempt to parse `seq_bytes` with `CSDBParser` and fallback to `IUPACParser`."""
    if not seq_bytes or not seq_bytes.strip():
        return None
    cp = csdb_parser if csdb_parser is not None else CSDBParser()
    ip = iupac_parser if iupac_parser is not None else IUPACParser()
    with warnings.catch_warnings():
        warnings.simplefilter("ignore", GlycanParseWarning)
        for parser in (cp, ip):
            try:
                g = parser.parse(seq_bytes, strict=False)
                if len(g.nodes) > 0:
                    return g
            except GlycanParseError, ValueError, KeyError, IndexError:
                continue
    return None

stream_csdb_database

stream_csdb_database(
    csdb_parser: CSDBParser | None = None,
    iupac_parser: IUPACParser | None = None,
    url: str = CSDB_OSF_URL,
    cache_path: Path | None = DEFAULT_CSDB_CACHE_PATH,
) -> tuple[list[dict[str, Any]], dict[str, dict[str, Any]]]

Stream CSDB from OSF (or load from compressed local cache), indexing cross-references and surface antigens.

Source code in src/kbank/io/sources.py
def stream_csdb_database(
    csdb_parser: CSDBParser | None = None,
    iupac_parser: IUPACParser | None = None,
    url: str = CSDB_OSF_URL,
    cache_path: Path | None = DEFAULT_CSDB_CACHE_PATH,
) -> tuple[list[dict[str, Any]], dict[str, dict[str, Any]]]:
    """Stream CSDB from OSF (or load from compressed local cache), indexing cross-references and surface antigens."""
    cp = csdb_parser if csdb_parser is not None else CSDBParser()
    ip = iupac_parser if iupac_parser is not None else IUPACParser()
    csdb_by_id: dict[str, dict[str, Any]] = {}
    curated_raw: list[dict[str, Any]] = []

    def _process_entry(current: dict[str, str]) -> bool:
        raw_id = current.get("record_id", "").strip()
        rec_id = raw_id.split()[0] if raw_id else ""
        st1 = current.get("structure_linear", "").strip()
        if not (rec_id and st1):
            return False
        csdb_by_id[rec_id] = dict(current)

        th_raw = current.get("is_elucidated", "").strip().lower()
        is_eluc = th_raw not in ("0", "o", "false", "f", "no", "n", "")
        domain = current.get("taxonomic_domain", "")
        bio_src = current.get("biological_source", "")
        comp_cls = current.get("compound_class", "")
        comp_name = current.get("compound_name", "")
        title = current.get("title", "")
        loc = current.get("structure_location", "")
        st2 = current.get("structure_type", "")

        is_bacteria = ("Prokaryotes" in domain) or ("Bacteria" in domain) or bool(BACTERIAL_GENERA_RE.search(bio_src))
        combined_meta = f"{comp_cls} {comp_name} {loc} {title}"
        is_surface = bool(SURFACE_ANTIGEN_RE.search(combined_meta))
        is_chem = st2 in ("CHEM", "REP", "")

        if is_eluc and is_bacteria and is_surface and is_chem:
            gly = parse_glycan_safe(st1.encode("utf-8"), cp, ip)
            if gly is not None and len(gly.nodes) >= 2:
                ag_label = classify_antigen_label(combined_meta, bio_src=bio_src)
                abstract_txt = current.get("abstract", "")
                org_b, strain_b, sero_b = parse_organism_and_strain(
                    bio_src,
                    antigen_type=ag_label,
                    extra_text=f"{comp_name} {title} {abstract_txt}",
                )

                db_xref = current.get("cross_references", "")
                bg_data = current.get("biosynthetic_genetic_data", "")
                notes_txt = current.get("comments", "")
                rl_txt = current.get("references", "")
                all_xref_text = f"{db_xref} {bg_data} {notes_txt} {rl_txt}"

                pmid = 0
                m_pmid = PMID_EXTRACT_RE.search(all_xref_text)
                if m_pmid:
                    pmid = int(m_pmid.group(1))

                doi_b = b""
                m_doi = DOI_EXTRACT_RE.search(all_xref_text)
                if m_doi:
                    doi_b = m_doi.group(1).rstrip(".,;)").encode("utf-8")

                acc_b = b""
                m_acc = GENBANK_ACC_RE.search(f"{bg_data} {db_xref} {notes_txt}")
                if m_acc:
                    acc_b = m_acc.group(1).encode("utf-8")[:32]

                py_str = current.get("publication_year", "")
                pub_year = int(py_str) if py_str.isdigit() and len(py_str) == 4 else 0

                curated_raw.append(
                    {
                        "source_db": b"CSDB",
                        "record_id": rec_id.encode("utf-8")[:32],
                        "organism": org_b,
                        "strain": strain_b,
                        "serotype": sero_b,
                        "antigen_type": ag_label,
                        "accession_hint": acc_b,
                        "structure_raw": st1.encode("utf-8"),
                        "structure_linear": st1.encode("utf-8"),
                        "glycan": gly,
                        "is_repeating": (gly.has_polymer_repeat or st1.lstrip().startswith("-")) and not gly.is_alditol,
                        "pmid": pmid,
                        "publication_year": pub_year,
                        "doi": doi_b,
                        "notes": f"{comp_cls}; {comp_name}".strip("; ").encode("utf-8"),
                    }
                )
                return True
        return False

    # Fast path: load pre-filtered bacterial entries from compressed local cache if present
    if cache_path is not None and cache_path.is_file():
        try:
            with gzip.open(cache_path, "rt", encoding="utf-8") as f_in:
                cached_entries: list[dict[str, str]] = json.load(f_in)
            if cached_entries:
                for entry_dict in cached_entries:
                    _process_entry(entry_dict)
                return curated_raw, csdb_by_id
        except Exception:
            csdb_by_id.clear()
            curated_raw.clear()

    kept_entries: list[dict[str, str]] = []

    def _ingest_entry(current: dict[str, str]) -> None:
        matched = _process_entry(current)
        bio_src = current.get("biological_source", "")
        if matched or "Escherichia coli" in bio_src:
            kept_entries.append(dict(current))

    try:
        req = urllib.request.Request(url, headers={"User-Agent": "KBank-Curator/1.0"})
        with urllib.request.urlopen(req, timeout=30) as resp:
            current: dict[str, str] = {}
            for raw_line in resp:
                line = raw_line.decode("utf-8", errors="replace").strip()
                if not line or line.startswith("#"):
                    if not line and current:
                        _ingest_entry(current)
                        current = {}
                    continue

                parts = line.split(":", 1)
                if len(parts) == 2:
                    tag = CSDB_TAG_MAP.get(parts[0].strip())
                    if tag:
                        current[tag] = parts[1].strip()
            if current:
                _ingest_entry(current)
    except Exception:
        pass

    if cache_path is not None and kept_entries:
        try:
            cache_path.parent.mkdir(parents=True, exist_ok=True)
            with gzip.open(cache_path, "wt", encoding="utf-8") as f_out:
                json.dump(kept_entries, f_out)
        except Exception:
            pass

    return curated_raw, csdb_by_id

fetch_ecodab_records async

fetch_ecodab_records(
    csdb_by_id: dict[str, dict[str, Any]],
    csdb_parser: CSDBParser | None = None,
    iupac_parser: IUPACParser | None = None,
    *,
    recorder: Any | None = None,
) -> list[dict[str, Any]]

Asynchronously scrape ECODAB E. coli O-antigen records from list.php & showDetails.php.

Source code in src/kbank/io/sources.py
async def fetch_ecodab_records(
    csdb_by_id: dict[str, dict[str, Any]],
    csdb_parser: CSDBParser | None = None,
    iupac_parser: IUPACParser | None = None,
    *,
    recorder: Any | None = None,
) -> list[dict[str, Any]]:
    """Asynchronously scrape ECODAB *E. coli* O-antigen records from `list.php` & `showDetails.php`."""
    import httpx2

    cp = csdb_parser if csdb_parser is not None else CSDBParser()
    ip = iupac_parser if iupac_parser is not None else IUPACParser()
    records: list[dict[str, Any]] = []
    sem = asyncio.Semaphore(10)

    with _cassette_ctx(recorder, "ecodab.toml"):
        async with httpx2.AsyncClient(verify=False, follow_redirects=True, timeout=25.0) as client:
            try:
                list_resp = await client.get(f"{ECODAB_BASE_URL}list.php")
                list_html = list_resp.text if list_resp.status_code == 200 else ""
            except Exception:
                list_html = ""

            entries = re.findall(
                r"<a href='../showDetails\.php\?idSerogroup=(\d+)'>([^<]+)</a>.*?<td[^>]*>.*?</td>\s*<td class='structura'><pre>(.*?)</pre>",
                list_html,
                re.DOTALL,
            )
            if not entries:
                entries = [(str(sg_id), f"O{sg_id}", "") for sg_id in range(1, 189)]

            async def _fetch_entry(rec_id: str, fallback_sg: str, fallback_struct: str) -> dict[str, Any] | None:
                url = f"{ECODAB_BASE_URL}showDetails.php?idSerogroup={rec_id}"
                page = ""
                async with sem:
                    try:
                        resp = await client.get(url, timeout=15.0)
                        if resp.status_code == 200:
                            page = resp.text
                    except Exception:
                        page = ""

                sg_clean = re.sub(r"\s+", "", fallback_sg.strip())
                if page:
                    m_sg = re.search(
                        r"<th[^>]*>Serogroup:?</th>\s*<td[^>]*>(.*?)</td>",
                        page,
                        re.IGNORECASE | re.DOTALL,
                    )
                    if m_sg:
                        cand_sg = re.sub(r"\s+", "", clean_html_text(m_sg.group(1)))
                        if cand_sg:
                            sg_clean = cand_sg
                if sg_clean and not sg_clean.upper().startswith("O"):
                    sg_clean = f"O{sg_clean}"

                gb_acc = b""
                if page:
                    m_gb = re.search(r"nuccore/([A-Za-z0-9_.]+)", page) or GENBANK_ACC_RE.search(page)
                    if m_gb:
                        gb_acc = m_gb.group(1).encode("utf-8")[:32]

                csdb_id = ""
                if page:
                    m_csdb = re.search(r"csdb\.glycoscience\.ru[^\"]*id=(\d+)", page, re.IGNORECASE)
                    if not m_csdb:
                        m_csdb = re.search(r"CSDB\s*(?:ID)?\s*[:#]?\s*(\d+)", clean_html_text(page), re.IGNORECASE)
                    if m_csdb:
                        csdb_id = m_csdb.group(1)

                pmid = 0
                pub_year = 0
                strain_b = b""
                if page:
                    m_pmid = re.search(r"pubmed/(\d+)", page, re.IGNORECASE)
                    if m_pmid:
                        pmid = int(m_pmid.group(1))
                    m_yr = re.search(r"\((\d{4})\),\s*pp", page)
                    if m_yr:
                        pub_year = int(m_yr.group(1))
                    m_str = re.search(
                        r"<th[^>]*>Strain:?</th>\s*<td[^>]*>(.*?)</td>",
                        page,
                        re.IGNORECASE | re.DOTALL,
                    )
                    if m_str:
                        strain_b = clean_html_text(m_str.group(1)).encode("utf-8")[:64]

                raw_struct = fallback_struct.strip()
                if page and not raw_struct:
                    pre_blocks = re.findall(r"<pre[^>]*>(.*?)</pre>", page, re.DOTALL | re.IGNORECASE)
                    if pre_blocks:
                        raw_struct = pre_blocks[0].strip()

                linear_b = b""
                if csdb_id and csdb_id in csdb_by_id:
                    csdb_entry = csdb_by_id[csdb_id]
                    linear_b = csdb_entry.get("structure_linear", "").encode("utf-8")
                    py_s = csdb_entry.get("publication_year", "")
                    if not pub_year and py_s.isdigit() and len(py_s) == 4:
                        pub_year = int(py_s)
                    if not strain_b:
                        _, strain_b, _ = parse_organism_and_strain(
                            csdb_entry.get("biological_source", ""),
                            antigen_type=b"O-antigen",
                        )

                if not linear_b and raw_struct:
                    linear_b = normalize_ecodab_structure(raw_struct)

                gly = parse_glycan_safe(linear_b, cp, ip)
                if gly is None or len(gly.nodes) == 0:
                    return None

                return {
                    "source_db": b"ECODAB",
                    "record_id": str(rec_id).encode("utf-8")[:32],
                    "organism": b"Escherichia coli",
                    "strain": strain_b,
                    "serotype": sg_clean.encode("utf-8")[:32],
                    "antigen_type": b"O-antigen",
                    "accession_hint": gb_acc,
                    "structure_raw": re.sub(r"<[^>]+>", "", html.unescape(raw_struct)).encode("utf-8")
                    if raw_struct
                    else linear_b,
                    "structure_linear": linear_b,
                    "glycan": gly,
                    "is_repeating": True,
                    "pmid": pmid,
                    "publication_year": pub_year,
                    "doi": b"",
                    "notes": (f"ECODAB {sg_clean}" + (f" (CSDB:{csdb_id})" if csdb_id else "")).encode("utf-8"),
                }

            results = await asyncio.gather(*[_fetch_entry(eid, sg, st) for eid, sg, st in entries])
            for item in results:
                if item is not None:
                    records.append(item)

    return records

fetch_ek3d_records async

fetch_ek3d_records(
    csdb_by_id: dict[str, dict[str, Any]],
    csdb_parser: CSDBParser | None = None,
    iupac_parser: IUPACParser | None = None,
    *,
    recorder: Any | None = None,
) -> list[dict[str, Any]]

Fetch EK3D E. coli K-antigen records (Groups 1-4) and cross-link with CSDB K-antigen structures.

Source code in src/kbank/io/sources.py
async def fetch_ek3d_records(
    csdb_by_id: dict[str, dict[str, Any]],
    csdb_parser: CSDBParser | None = None,
    iupac_parser: IUPACParser | None = None,
    *,
    recorder: Any | None = None,
) -> list[dict[str, Any]]:
    """Fetch EK3D *E. coli* K-antigen records (Groups 1-4) and cross-link with CSDB K-antigen structures."""
    import httpx2

    cp = csdb_parser if csdb_parser is not None else CSDBParser()
    ip = iupac_parser if iupac_parser is not None else IUPACParser()

    group_1_k = {
        "K26",
        "K27",
        "K28",
        "K29",
        "K30",
        "K31",
        "K32",
        "K33",
        "K34",
        "K35",
        "K36",
        "K37",
        "K38",
        "K39",
        "K41",
        "K42",
        "K55",
        "K82",
        "K88",
        "K89",
        "K102",
        "K103",
    }
    group_2_k = {
        "K1",
        "K4",
        "K5",
        "K7",
        "K8",
        "K9",
        "K12",
        "K13",
        "K14",
        "K15",
        "K16",
        "K18",
        "K20",
        "K22",
        "K23",
        "K24",
        "K43",
        "K44",
        "K45",
        "K46",
        "K47",
        "K50",
        "K51",
        "K52",
        "K53",
        "K56",
        "K57",
        "K62",
        "K63",
        "K74",
        "K80",
        "K92",
        "K93",
        "K94",
        "K95",
        "K97",
        "K100",
        "K101",
    }
    group_3_k = {"K2", "K3", "K10", "K11", "K19", "K54", "K96", "K98"}
    group_4_k = {"K6", "K40", "K49", "K84", "K85", "K87"}

    def _classify_k_group(k_sero: str) -> str:
        k_base = re.match(r"^(K\d+)", k_sero.upper())
        key = k_base.group(1) if k_base else k_sero.upper()
        if key in group_1_k:
            return "Group 1"
        if key in group_2_k:
            return "Group 2"
        if key in group_3_k:
            return "Group 3"
        if key in group_4_k:
            return "Group 4"
        return "Group 2"

    ecoli_k_csdb: dict[str, dict[str, Any]] = {}
    for entry in csdb_by_id.values():
        bio_src = entry.get("biological_source", "")
        if "Escherichia coli" not in bio_src:
            continue
        m_k = re.search(r"\b(K\d+[A-Za-z]?)\b", f"{bio_src} {entry.get('compound_name', '')} {entry.get('title', '')}")
        if not m_k:
            continue
        k_name = m_k.group(1).upper()
        st1 = entry.get("structure_linear", "").strip()
        if st1 and k_name not in ecoli_k_csdb:
            ecoli_k_csdb[k_name] = entry

    live_ek_map: dict[str, tuple[str, str]] = {}
    try:
        with _cassette_ctx(recorder, "ek3d.toml"):
            async with httpx2.AsyncClient(verify=False, follow_redirects=True, timeout=6.0) as client:
                resp = await client.get(EK3D_URL)
                if resp.status_code == 200:
                    tds = re.findall(r"<td\b[^>]*>(.*?)</td>", resp.text, re.DOTALL)
                    grp_names = ["Group 1", "Group 2", "Group 3", "Group 4", "Group 2"]
                    for idx, td in enumerate(tds):
                        grp = grp_names[idx] if idx < len(grp_names) else "Group 2"
                        for _, ek_id in re.findall(r'<a href="../(ek\d+\.php)"[^>]*>(EK\d+)</a>', td):
                            k_label = ek_id.replace("EK", "K")
                            live_ek_map[k_label] = (ek_id, grp)
    except Exception:
        pass

    records: list[dict[str, Any]] = []
    sorted_k = sorted(
        ecoli_k_csdb.keys(),
        key=lambda k: (int(m.group(0)) if (m := re.search(r"\d+", k)) else 999, k),
    )
    for idx_k, k_sero in enumerate(sorted_k, start=1):
        entry = ecoli_k_csdb[k_sero]
        st1 = entry.get("structure_linear", "").strip()
        linear_b = st1.encode("utf-8")
        gly = parse_glycan_safe(linear_b, cp, ip)
        if gly is None or len(gly.nodes) < 2:
            continue

        ek_id, grp = live_ek_map.get(k_sero, (f"EK{idx_k}", _classify_k_group(k_sero)))
        _, strain_b, _ = parse_organism_and_strain(entry.get("biological_source", ""))
        py_s = entry.get("publication_year", "")
        pub_year = int(py_s) if py_s.isdigit() and len(py_s) == 4 else 0

        all_xref = f"{entry.get('cross_references', '')} {entry.get('biosynthetic_genetic_data', '')} {entry.get('references', '')}"
        pmid = 0
        m_pmid = PMID_EXTRACT_RE.search(all_xref)
        if m_pmid:
            pmid = int(m_pmid.group(1))
        acc_b = b""
        m_acc = GENBANK_ACC_RE.search(all_xref)
        if m_acc:
            acc_b = m_acc.group(1).encode("utf-8")[:32]

        records.append(
            {
                "source_db": b"EK3D",
                "record_id": ek_id.encode("utf-8")[:32],
                "organism": b"Escherichia coli",
                "strain": strain_b,
                "serotype": k_sero.encode("utf-8")[:32],
                "antigen_type": f"CPS ({grp})".encode()[:32],
                "accession_hint": acc_b,
                "structure_raw": linear_b,
                "structure_linear": linear_b,
                "glycan": gly,
                "is_repeating": True,
                "pmid": pmid,
                "publication_year": pub_year,
                "doi": b"",
                "notes": f"EK3D {ek_id} {k_sero} {grp} (CSDB:{entry.get('record_id', '')})".encode(),
            }
        )

    return records

fetch_epsdb_records async

fetch_epsdb_records(
    csdb_by_id: dict[str, dict[str, Any]] | None = None,
    csdb_parser: CSDBParser | None = None,
    iupac_parser: IUPACParser | None = None,
    *,
    recorder: Any | None = None,
) -> list[dict[str, Any]]

Asynchronously scrape CERMAV EPS-DB bacterial exopolysaccharide entries from database.html.

Source code in src/kbank/io/sources.py
async def fetch_epsdb_records(
    csdb_by_id: dict[str, dict[str, Any]] | None = None,
    csdb_parser: CSDBParser | None = None,
    iupac_parser: IUPACParser | None = None,
    *,
    recorder: Any | None = None,
) -> list[dict[str, Any]]:
    """Asynchronously scrape CERMAV EPS-DB bacterial exopolysaccharide entries from `database.html`."""
    import httpx2

    cp = csdb_parser if csdb_parser is not None else CSDBParser()
    ip = iupac_parser if iupac_parser is not None else IUPACParser()
    records: list[dict[str, Any]] = []
    try:
        with _cassette_ctx(recorder, "epsdb.toml"):
            async with httpx2.AsyncClient(verify=False, follow_redirects=True, timeout=25.0) as client:
                resp = await client.get(f"{EPSDB_BASE_URL}database.html")
                if resp.status_code != 200:
                    return []
                index_html = resp.text
    except Exception:
        return []

    tbody_m = re.search(r"<tbody[^>]*>(.*?)</tbody>", index_html, re.DOTALL | re.IGNORECASE)
    if not tbody_m:
        return []

    rows = re.findall(r"<tr\b[^>]*>(.*?)</tr>", tbody_m.group(1), re.DOTALL | re.IGNORECASE)
    for row_html in rows:
        tds = re.findall(r"<td\b[^>]*>(.*?)</td>", row_html, re.DOTALL | re.IGNORECASE)
        if len(tds) < 8:
            continue

        eps_id = clean_html_text(tds[0])
        organism = clean_html_text(tds[1]) or "Unknown bacterium"
        strain = re.sub(r"\{[^}]*\}", "", clean_html_text(tds[2])).strip()
        backbone = clean_html_text(tds[3])
        branches = clean_html_text(tds[4])
        year_s = clean_html_text(tds[6])
        func_s = clean_html_text(tds[7])

        if not eps_id or not backbone:
            continue

        linear_b = normalize_epsdb_backbone(backbone)
        gly = parse_glycan_safe(linear_b, cp, ip)
        if gly is None or len(gly.nodes) == 0:
            continue

        raw_str = f"{backbone} {branches}".strip() if branches and branches != "–" else backbone
        pub_year = int(year_s) if year_s.isdigit() and len(year_s) == 4 else 0

        st_match = re.search(r"\b([KO]\d+[A-Za-z0-9\-_.]*)\b", f"{strain} {organism}")
        sero_b = st_match.group(1).encode("utf-8")[:32] if st_match else b""

        records.append(
            {
                "source_db": b"EPSDB",
                "record_id": f"EPS{eps_id}".encode()[:32],
                "organism": organism.encode("utf-8")[:64],
                "strain": strain.encode("utf-8")[:64],
                "serotype": sero_b,
                "antigen_type": b"EPS",
                "accession_hint": b"",
                "structure_raw": raw_str.encode("utf-8"),
                "structure_linear": linear_b,
                "glycan": gly,
                "is_repeating": True,
                "pmid": 0,
                "publication_year": pub_year,
                "doi": b"",
                "notes": (f"CERMAV EPS-DB #{eps_id}" + (f" ({func_s})" if func_s and func_s != "–" else "")).encode(
                    "utf-8"
                ),
            }
        )

    return records

fetch_kegg_glycan_records async

fetch_kegg_glycan_records(
    *,
    recorder: Any | None = None,
    allow_composition: bool = False,
) -> list[dict[str, Any]]

Asynchronously fetch connected bacterial surface glycans from KEGG (map00540, map00552, keywords).

Source code in src/kbank/io/sources.py
async def fetch_kegg_glycan_records(
    *,
    recorder: Any | None = None,
    allow_composition: bool = False,
) -> list[dict[str, Any]]:
    """Asynchronously fetch connected bacterial surface glycans from KEGG (`map00540`, `map00552`, keywords)."""
    import httpx2

    records: list[dict[str, Any]] = []
    pathway_ids: dict[str, bytes] = {}

    with _cassette_ctx(recorder, "kegg.toml"):
        async with httpx2.AsyncClient(follow_redirects=True) as client:
            for pw, label in (("map00540", b"LPS"), ("map00552", b"WTA")):
                try:
                    r = await client.get(f"{KEGG_REST_URL}/link/glycan/{pw}", timeout=20.0)
                    if r.status_code == 200:
                        for line in r.text.splitlines():
                            if "\tgl:" in line:
                                gid = line.split("\tgl:")[-1].strip()
                                pathway_ids[gid] = label
                except Exception:
                    continue

            for kw, default_lbl in (
                ("lipopolysaccharide", b"LPS"),
                ("capsular", b"CPS"),
                ("teichoic", b"WTA"),
                ("O-antigen", b"O-antigen"),
            ):
                try:
                    r_find = await client.get(f"{KEGG_REST_URL}/find/glycan/{kw}", timeout=12.0)
                    if r_find.status_code == 200:
                        for line in r_find.text.splitlines():
                            parts = line.split("\t", 1)
                            if len(parts) == 2:
                                gid = parts[0].replace("gl:", "").strip()
                                desc = parts[1]
                                if BACTERIAL_GENERA_RE.search(desc) or SURFACE_ANTIGEN_RE.search(desc):
                                    pathway_ids.setdefault(gid, classify_antigen_label(desc) or default_lbl)
                except Exception:
                    pass

            gids = sorted(pathway_ids.keys())
            sem = asyncio.Semaphore(4)

            async def _fetch_batch(batch_ids: list[str]) -> list[dict[str, Any]]:
                joined = "+".join(batch_ids)
                async with sem:
                    try:
                        resp = await client.get(f"{KEGG_REST_URL}/get/{joined}", timeout=30.0)
                        if resp.status_code != 200:
                            return []
                        raw_text = resp.text
                    except Exception:
                        return []

                out: list[dict[str, Any]] = []
                for entry_txt in raw_text.split("///"):
                    entry_txt = entry_txt.strip()
                    if not entry_txt:
                        continue
                    m_id = re.search(r"^ENTRY\s+(G\d{5})", entry_txt, re.MULTILINE)
                    if not m_id:
                        continue
                    gid = m_id.group(1)
                    gly = parse_kegg_glycan_flatfile(entry_txt, gid, allow_composition=allow_composition)
                    if gly is None or len(gly.nodes) < 2:
                        continue

                    m_name = re.search(r"^NAME\s+(.+)$", entry_txt, re.MULTILINE)
                    name_str = m_name.group(1).strip() if m_name else ""
                    m_comm = re.search(r"^COMMENT\s+(.+)$", entry_txt, re.MULTILINE)
                    comm_str = m_comm.group(1).strip() if m_comm else ""

                    org_b = b"Bacteria (KEGG pathway)"
                    m_org = BACTERIAL_GENERA_RE.search(f"{name_str} {comm_str}")
                    if m_org:
                        org_b, _, _ = parse_organism_and_strain(f"{name_str} {comm_str}"[m_org.start() :])

                    pmid = 0
                    m_pmid = re.search(r"PMID:\s*(\d+)", entry_txt)
                    if m_pmid:
                        pmid = int(m_pmid.group(1))

                    ag_type = pathway_ids.get(gid, b"LPS")
                    out.append(
                        {
                            "source_db": b"KEGG",
                            "record_id": gid.encode("utf-8"),
                            "organism": org_b,
                            "strain": b"",
                            "serotype": b"",
                            "antigen_type": ag_type,
                            "accession_hint": b"",
                            "structure_raw": gly.canonical,
                            "structure_linear": gly.canonical,
                            "glycan": gly,
                            "is_repeating": False,
                            "pmid": pmid,
                            "publication_year": 0,
                            "doi": b"",
                            "notes": (name_str or comm_str or f"KEGG {ag_type.decode()}").encode("utf-8")[:120],
                        }
                    )
                return out

            batches = [gids[i : i + 10] for i in range(0, len(gids), 10)]
            results = await asyncio.gather(*[_fetch_batch(b) for b in batches])
            for b_res in results:
                records.extend(b_res)

    return records

fetch_glytoucan_records async

fetch_glytoucan_records(
    csdb_parser: CSDBParser | None = None,
    iupac_parser: IUPACParser | None = None,
    *,
    recorder: Any | None = None,
) -> dict[bytes, bytes]

Query GlyTouCan / GlyCosmos SPARQL to build a canonical structure -> glytoucan_id cross-reference map.

Source code in src/kbank/io/sources.py
async def fetch_glytoucan_records(
    csdb_parser: CSDBParser | None = None,
    iupac_parser: IUPACParser | None = None,
    *,
    recorder: Any | None = None,
) -> dict[bytes, bytes]:
    """Query GlyTouCan / GlyCosmos SPARQL to build a canonical structure -> `glytoucan_id` cross-reference map."""
    import httpx2

    cp = csdb_parser if csdb_parser is not None else CSDBParser()
    ip = iupac_parser if iupac_parser is not None else IUPACParser()
    canonical_to_gtc: dict[bytes, bytes] = {}

    genera_pattern = (
        "^(Escherichia|Klebsiella|Acinetobacter|Streptococcus|Salmonella|Pseudomonas|"
        "Staphylococcus|Shigella|Vibrio|Yersinia|Campylobacter|Burkholderia|"
        "Haemophilus|Neisseria|Bacillus|Listeria|Enterococcus|Clostrid|"
        "Bacteroides|Helicobacter|Mycobacterium|Proteus|Serratia|Citrobacter|"
        "Enterobacter|Cronobacter|Aeromonas|Francisella|Brucella|Legionella|"
        "Providencia|Morganella|Hafnia|Edwardsiella|Plesiomonas)"
    )

    with _cassette_ctx(recorder, "glytoucan.toml"):
        async with httpx2.AsyncClient(timeout=35.0, follow_redirects=True) as client:
            for offset in (0, 2500):
                sparql = f"""
PREFIX glycan: <http://purl.jp/bio/12/glyco/glycan#>
PREFIX glytoucan: <http://www.glytoucan.org/glyco/owl/glytoucan#>
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
PREFIX dcterms: <http://purl.org/dc/terms/>
SELECT DISTINCT ?acc ?iupac ?taxon_label ?pub
WHERE {{
  ?s glytoucan:has_primary_id ?acc .
  ?s glycan:has_glycosequence ?gseq .
  ?gseq glycan:in_carbohydrate_format glycan:carbohydrate_format_iupac_condensed .
  ?gseq glycan:has_sequence ?iupac .
  ?s glycan:is_from_source ?source .
  ?source rdfs:label ?taxon_label .
  OPTIONAL {{ ?s dcterms:references ?pub . }}
  FILTER(REGEX(?taxon_label, "{genera_pattern}", "i"))
}}
LIMIT 2500 OFFSET {offset}
"""
                try:
                    resp = await client.post(
                        GLYCOSMOS_SPARQL_URL,
                        data={"query": sparql},
                        headers={"Accept": "application/sparql-results+json"},
                    )
                    if resp.status_code != 200:
                        break
                    bindings = resp.json().get("results", {}).get("bindings", [])
                except Exception:
                    break

                if not bindings:
                    break

                for b in bindings:
                    acc = b.get("acc", {}).get("value", "").strip()
                    iupac_str = b.get("iupac", {}).get("value", "").strip()
                    if not acc or not iupac_str:
                        continue

                    iupac_b = iupac_str.encode("utf-8")
                    gly = parse_glycan_safe(iupac_b, cp, ip)
                    if gly is None or len(gly.nodes) < 2:
                        continue

                    acc_b = acc.encode("utf-8")[:32]
                    canonical_to_gtc.setdefault(gly.canonical, acc_b)

                if len(bindings) < 2500:
                    break

    return canonical_to_gtc

Base Parser Protocol (kbank.io.base)

base

Classes:

Name Description
GlycanParseError

Raised when a glycan string violates structural syntax and cannot be parsed.

GlycanParseWarning

Raised when the parser employs heuristics or makes assumptions about an ambiguous structure.

GlycanParser

GlycanParseError

Bases: ValueError

Raised when a glycan string violates structural syntax and cannot be parsed.

Source code in src/kbank/io/base.py
class GlycanParseError(ValueError):
    """Raised when a glycan string violates structural syntax and cannot be parsed."""

    pass

GlycanParseWarning

Bases: UserWarning

Raised when the parser employs heuristics or makes assumptions about an ambiguous structure.

Source code in src/kbank/io/base.py
class GlycanParseWarning(UserWarning):
    """Raised when the parser employs heuristics or makes assumptions about an ambiguous structure."""

    pass

GlycanParser

Bases: ABC

Methods:

Name Description
parse

Parse a canonical glycan sequence into a Glycan core model.

Source code in src/kbank/io/base.py
class GlycanParser(ABC):
    _RE_LEXER = re.compile(rb"\]|\[|\([^)]+\)|[^\[\]()]+")

    def __init__(self) -> None:
        self.mono_map = dict(Sugars.name_to_index)
        for k, v in Sugars.alias_map.items():
            if v in self.mono_map:
                self.mono_map[k] = self.mono_map[v]

        self.mod_map = {
            b"Ac": self.mono_map[b"OAc"],
            b"OAc": self.mono_map[b"OAc"],
            b"Pyr": self.mono_map[b"Pyr"],
            b"P": self.mono_map[b"P"],
            b"PEtn": self.mono_map[b"PEtn"],
            b"OMe": self.mono_map[b"OMe"],
            b"Me": self.mono_map[b"OMe"],
            b"N": self.mono_map[b"N"],
            b"LAla": self.mono_map[b"LAla"],
            b"DAla": self.mono_map[b"DAla"],
            b"dAla": self.mono_map[b"DAla"],
            b"Ala": self.mono_map[b"Ala"],
            b"Fo": self.mono_map[b"Fo"],
            b"Lac": self.mono_map[b"Lac"],
            b"Gro": self.mono_map[b"Gro"],
            b"ol": self.mono_map[b"ol"],
        }

        mono_patterns = sorted(self.mono_map.keys(), key=len, reverse=True)
        mod_patterns = sorted(self.mod_map.keys(), key=len, reverse=True)

        self._RE_MOD = re.compile(rb"^(" + b"|".join(mod_patterns) + rb")")
        self._RE_MOD_SEARCH = re.compile(rb"(" + b"|".join(mod_patterns) + rb")")
        self._RE_MONO = re.compile(rb"^(" + b"|".join(mono_patterns) + rb")")

    def _lex(self, seq: bytes) -> Iterator[Token]:
        tokens = []
        for match in self._RE_LEXER.finditer(seq):
            val = match.group(0)
            pos = match.start()
            if val == b"]":
                tokens.append(Token(TokenType.BRANCH_START, val, pos))
            elif val == b"[":
                tokens.append(Token(TokenType.BRANCH_END, val, pos))
            elif val.startswith(b"("):
                tokens.append(Token(TokenType.LINKAGE, val[1:-1], pos))
            else:
                tokens.append(Token(TokenType.SUGAR, val.strip(), pos))
        yield from reversed(tokens)

    @abstractmethod
    def parse(self, sequence: bytes, strict: bool = False) -> Glycan:
        """Parse a canonical glycan sequence into a Glycan core model."""
        raise NotImplementedError

parse abstractmethod

parse(sequence: bytes, strict: bool = False) -> Glycan

Parse a canonical glycan sequence into a Glycan core model.

Source code in src/kbank/io/base.py
@abstractmethod
def parse(self, sequence: bytes, strict: bool = False) -> Glycan:
    """Parse a canonical glycan sequence into a Glycan core model."""
    raise NotImplementedError

Comments