Repository: cogent3/cogent3
Pull request #1903 (2024-06-18 23:49:20)

Mods to alphabets to incorporate missing characters


Patch:
diff --git a/src/cogent3/core/new_alphabet.py b/src/cogent3/core/new_alphabet.py
index 44a891d8a..00ec94fd8 100644
--- a/src/cogent3/core/new_alphabet.py
+++ b/src/cogent3/core/new_alphabet.py
@@ -103,16 +103,32 @@ class AlphabetABC(ABC):
     @abstractmethod
     def with_gap_motif(self): ...
 
+    @abstractmethod
+    def to_indices(self, seq: str) -> numpy.ndarray: ...
 
-class MonomerAlphabetABC(ABC):
     @abstractmethod
-    def get_kmer_alphabet(self, size: int): ...
+    def from_indices(self, seq: numpy.ndarray) -> str: ...
+
+    @property
+    @abstractmethod
+    def gap_char(self) -> OptStr: ...
 
+    @property
     @abstractmethod
-    def to_indices(self, seq: str) -> numpy.ndarray: ...
+    def gap_index(self) -> OptInt: ...
 
+    @property
     @abstractmethod
-    def from_indices(self, seq: numpy.ndarray) -> str: ...
+    def missing_char(self) -> OptStr: ...
+
+    @property
+    @abstractmethod
+    def missing_index(self) -> OptInt: ...
+
+
+class MonomerAlphabetABC(ABC):
+    @abstractmethod
+    def get_kmer_alphabet(self, size: int): ...
 
     @abstractmethod
     def as_bytes(self) -> bytes: ...
@@ -198,19 +214,45 @@ class array_to_bytes:
 
 
 class CharAlphabet(tuple, AlphabetABC, MonomerAlphabetABC):
-    def __new__(cls, chars: typing.Sequence[StrORBytes], gap: OptStr = None):
+    def __new__(
+        cls,
+        chars: typing.Sequence[StrORBytes],
+        gap: OptStr = None,
+        missing: OptStr = None,
+    ):
         if not chars:
             raise ValueError(f"cannot create empty {cls.__name__!r}")
 
         if gap is not None:
             assert _coerce_to_type(chars[0], gap) in chars
 
+        if missing is not None:
+            # if a bytes alphabet and missing provided this will fail
+            # since individual elements of a bytes object are integers
+            # leaving as is because we're considering a full bytes
+            # alphabet only, in which case the missing character is already
+            # present
+            assert _coerce_to_type(chars[0], missing) in chars
+
         consistent_words(chars, length=1)
-        return tuple.__new__(cls, chars, gap=gap)
+        return tuple.__new__(cls, chars, gap=gap, missing=missing)
 
-    def __init__(self, chars: typing.Sequence[StrORBytes], gap: str = None):
+    def __init__(
+        self,
+        chars: typing.Sequence[StrORBytes],
+        gap: OptStr = None,
+        missing: OptStr = None,
+    ):
         self._gap_char = gap
         self._gap_index = self.index(gap) if gap else None
+        self._missing_char = missing
+        self._missing_index = self.index(missing) if missing else None
+
+        # the number of canonical states are non-gap, non-missing states
+        adj = (1 if gap else 0) + (1 if missing else 0)
+
+        self._num_canonical = len(self) - adj
+
         self.dtype = get_array_type(len(self))
         self._chars = set(self)  # for quick lookup
         byte_chars = self.as_bytes()
@@ -225,6 +267,14 @@ class CharAlphabet(tuple, AlphabetABC, MonomerAlphabetABC):
     def gap_index(self) -> OptInt:
         return self._gap_index
 
+    @property
+    def missing_char(self) -> OptStr:
+        return self._missing_char
+
+    @property
+    def missing_index(self) -> OptInt:
+        return self._missing_index
+
     @property
     def motif_len(self) -> int:
         # this is enforced by consistent_words(length=1) in the constructor
@@ -236,7 +286,10 @@ class CharAlphabet(tuple, AlphabetABC, MonomerAlphabetABC):
 
     @to_indices.register
     def _(self, seq: bytes) -> numpy.ndarray:
-        return self._bytes2arr(seq)
+        result = self._bytes2arr(seq)
+        # any non-canonical characters should lie outside the range
+        # we replace these with a single value
+        return result
 
     @to_indices.register
     def _(self, seq: str) -> numpy.ndarray:
@@ -254,7 +307,20 @@ class CharAlphabet(tuple, AlphabetABC, MonomerAlphabetABC):
     def _(self, seq: numpy.ndarray) -> str:
         return self._arr2bytes(seq).decode("utf8")
 
-    def with_gap_motif(self): ...
+    def with_gap_motif(self, gap_char="-", missing_char="?"):
+        """returns new monomer alphabet with gap and missing characters added
+
+        Parameters
+        ----------
+        gap_char
+            the IUPAC gap character "-"
+        missing_char
+            the IUPAC missing character "?"
+        """
+        if self.gap_char and self.missing_char:
+            return self
+        chars = tuple(self[: self._num_canonical]) + (gap_char, missing_char)
+        return self.__class__(chars, gap=gap_char, missing=missing_char)
 
     def get_kmer_alphabet(self, k: int, include_gap: bool = True) -> "KmerAlphabet":
         """returns kmer alphabet with words of size k
@@ -296,6 +362,8 @@ class CharAlphabet(tuple, AlphabetABC, MonomerAlphabetABC):
 
     @is_valid.register
     def _(self, seq: numpy.ndarray) -> bool:
+        if not len(seq):
+            return True
         return seq.min() >= 0 and seq.max() < len(self)
 
     def as_bytes(self) -> bytes:
@@ -374,11 +442,10 @@ def seq_to_kmer_indices(
         raise ValueError(f"size of result {len(result)} <= {size}")
 
     for result_idx, i in enumerate(range(0, len(seq) - k + 1, step)):
-        for j in range(i, i + k):
-            if seq[j] < num_states:
-                result[result_idx] = coord_to_index(seq[i : i + k], coeffs)
-            else:
-                continue
+        if (num_states > seq[i : i + k]).all():
+            result[result_idx] = coord_to_index(seq[i : i + k], coeffs)
+        else:
+            continue
     return result
 
 
@@ -405,7 +472,15 @@ def kmer_indices_to_seq(
     return result
 
 
-class KmerAlphabet(tuple, AlphabetABC):
+class KmerAlphabetABC(ABC):
+    @abstractmethod
+    def to_index(self, seq) -> int: ...
+
+    @abstractmethod
+    def from_index(self, kmer_index: int) -> str: ...
+
+
+class KmerAlphabet(tuple, AlphabetABC, KmerAlphabetABC):
     def __new__(
         cls,
         words: tuple[StrORBytes, ...],
@@ -547,7 +622,7 @@ class KmerAlphabet(tuple, AlphabetABC):
 _alphabet_moltype_map = {}
 
 
-def make_alphabet(*, chars, gap, moltype):
+def make_alphabet(*, chars, gap, missing, moltype):
     """constructs an alphabet and registers the associated moltype
 
     Notes
@@ -555,6 +630,6 @@ def make_alphabet(*, chars, gap, moltype):
     The moltype is associated with the alphabet, available as an
     alphabet property.
     """
-    alpha = CharAlphabet(chars, gap=gap)
+    alpha = CharAlphabet(chars, gap=gap, missing=missing)
     _alphabet_moltype_map[alpha] = moltype
     return alpha
diff --git a/src/cogent3/core/new_moltype.py b/src/cogent3/core/new_moltype.py
index e84a106e1..7471b1467 100644
--- a/src/cogent3/core/new_moltype.py
+++ b/src/cogent3/core/new_moltype.py
@@ -318,6 +318,12 @@ AA_COLORS = _expand_colors(
 )
 
 
+def _strictly_upper(monomers: tuple[StrORBytes]):
+    """whether all elements correspond to upper case characters"""
+    cast = str if isinstance(monomers[0], str) else chr
+    return all(cast(c).isupper() for c in monomers)
+
+
 @dataclasses.dataclass
 class MolType:
     name: str
@@ -333,6 +339,7 @@ class MolType:
     # private attributes to be delivered via properties
     _monomers: new_alphabet.CharAlphabet = dataclasses.field(init=False)
     _gapped: new_alphabet.CharAlphabet = dataclasses.field(init=False)
+    _gapped_missing: new_alphabet.CharAlphabet = dataclasses.field(init=False)
     _degen: new_alphabet.CharAlphabet = dataclasses.field(init=False)
     _degen_gapped: new_alphabet.CharAlphabet = dataclasses.field(init=False)
     _colors: dict[str, str] = dataclasses.field(init=False)
@@ -356,21 +363,41 @@ class MolType:
         ambigs = new_alphabet._coerce_to_type(monomers, "".join(self.ambiguities or ""))
 
         self._monomers = new_alphabet.make_alphabet(
-            chars=monomers, gap=None, moltype=self
+            chars=monomers, gap=None, missing=None, moltype=self
         )
         self._degen = (
-            new_alphabet.make_alphabet(chars=monomers + ambigs, gap=None, moltype=self)
+            new_alphabet.make_alphabet(
+                chars=monomers + ambigs + missing,
+                gap=None,
+                missing=missing or None,
+                moltype=self,
+            )
             if ambigs
             else None
         )
         self._gapped = (
-            new_alphabet.make_alphabet(chars=monomers + gap, gap=self.gap, moltype=self)
+            new_alphabet.make_alphabet(
+                chars=monomers + gap, gap=self.gap, missing=None, moltype=self
+            )
             if gap
             else None
         )
+        self._gapped_missing = (
+            new_alphabet.make_alphabet(
+                chars=monomers + gap + missing,
+                gap=self.gap,
+                missing=missing or None,
+                moltype=self,
+            )
+            if missing and gap
+            else None
+        )
         self._degen_gapped = (
             new_alphabet.make_alphabet(
-                chars=monomers + gap + ambigs + missing, gap=self.gap, moltype=self
+                chars=monomers + gap + ambigs + missing,
+                gap=self.gap,
+                missing=missing or None,
+                moltype=self,
             )
             if ambigs and gap
             else None
@@ -426,22 +453,24 @@ class MolType:
         return self._degen_gapped
 
     @property
-    def gaps(self) -> frozenset:
+    def gaps(self) -> frozenset:  # refactor: docstring
         gaps = [char for char in (self.gap, self.missing) if char is not None]
         return frozenset(gaps)
 
     def is_valid(self, seq: StrORArray) -> bool:
         """checks against most degenerate alphabet"""
-        alpha = next(
-            alpha
-            for alpha in (self._degen_gapped, self._degen, self._gapped, self._monomers)
-            if alpha
-        )
+        alpha = self.most_degen_alphabet()
         return alpha.is_valid(seq)
 
     def iter_alphabets(self):
-        """yield the different defined alphabets"""
-        alphas = (self._monomers, self._gapped, self._degen, self._degen_gapped)
+        """yield alphabets in order of most to least degenerate"""
+        alphas = (
+            self._degen_gapped,
+            self._degen,
+            self._gapped_missing,
+            self._gapped,
+            self._monomers,
+        )
         yield from (a for a in alphas if a)
 
     def is_compatible_alphabet(
@@ -462,7 +491,9 @@ class MolType:
 
         return any(alpha == alphabet for alpha in self.iter_alphabets())
 
-    def make_seq(self, *, seq: str, name: OptStr = None, check_seq=True, **kwargs):
+    def make_seq(
+        self, *, seq: str, name: OptStr = None, check_seq=True, **kwargs
+    ):  # refactor: docstring
         if check_seq:
             assert self.is_valid(
                 seq
@@ -470,42 +501,89 @@ class MolType:
         return self._make_seq(moltype=self, seq=seq or "", name=name, **kwargs)
 
     @functools.singledispatchmethod
-    def complement(self, seq: StrORBytesORArray) -> str:
-        """converts a string or bytes into it's nucleic acid complement"""
+    def complement(self, seq: StrORBytesORArray, validate: bool = True) -> str:
+        """converts a string or bytes into it's nucleic acid complement
+
+        Parameters
+        ----------
+        seq
+            sequence to be complemented
+        validate
+            if True, checks the sequence is validated against the most
+            degenerate alphabet
+        """
         raise TypeError(f"{type(seq)} not supported")
 
     @complement.register
-    def _(self, seq: str) -> str:
-        return self.complement(seq.encode("utf8"))
+    def _(self, seq: str, validate: bool = True) -> str:
+        if validate and not self.is_valid(seq):
+            raise new_alphabet.AlphabetError(
+                f"{seq[:4]!r} not valid for moltype {self.name!r}"
+            )
+
+        return self.complement(seq.encode("utf8"), validate=False)
 
     @complement.register
-    def _(self, seq: bytes) -> str:
+    def _(self, seq: bytes, validate: bool = True) -> str:
+        if validate and not self.is_valid(seq):
+            raise new_alphabet.AlphabetError(
+                f"{seq[:4]!r} not valid for moltype {self.name!r}"
+            )
         return self._complement(seq).decode("utf8")
 
     @complement.register
-    def _(self, seq: numpy.ndarray) -> str:
-        return self.complement(self.degen_gapped_alphabet.array_to_bytes(seq))
+    def _(self, seq: numpy.ndarray, validate: bool = True) -> str:
+        if validate and not self.is_valid(seq):
+            raise new_alphabet.AlphabetError(
+                f"{seq[:4]!r} not valid for moltype {self.name!r}"
+            )
+
+        return self.complement(
+            self.degen_gapped_alphabet.array_to_bytes(seq), validate=False
+        )
 
-    def rc(self, seq: str) -> str:
+    def rc(self, seq: str, validate: bool = True) -> str:
         """reverse reverse complement of a sequence"""
+        if validate and not self.is_valid(seq):
+            raise new_alphabet.AlphabetError(
+                f"{seq[:4]!r} not valid for moltype {self.name!r}"
+            )
+
         return self.complement(seq)[::-1]
 
     @functools.singledispatchmethod
-    def is_degenerate(self, seq: StrORBytesORArray) -> bool:
+    def is_degenerate(self, seq: StrORBytesORArray, validate: bool = True) -> bool:
         """checks if a sequence contains degenerate characters"""
         raise TypeError(f"{type(seq)} not supported")
 
     @is_degenerate.register
-    def _(self, seq: bytes) -> bool:
-        return self.is_degenerate(self.degen_gapped_alphabet.to_indices(seq))
+    def _(self, seq: bytes, validate: bool = True) -> bool:  # refactor: docstring
+        if validate and not self.is_valid(seq):
+            raise new_alphabet.AlphabetError(
+                f"{seq[:4]!r} not valid for moltype {self.name!r}"
+            )
+        return self.is_degenerate(
+            self.degen_gapped_alphabet.to_indices(seq), validate=False
+        )
 
     @is_degenerate.register
-    def _(self, seq: str) -> bool:
-        return self.is_degenerate(self.degen_gapped_alphabet.to_indices(seq))
+    def _(self, seq: str, validate: bool = True) -> bool:
+        if validate and not self.is_valid(seq):
+            raise new_alphabet.AlphabetError(
+                f"{seq[:4]!r} not valid for moltype {self.name!r}"
+            )
+        return self.is_degenerate(
+            self.degen_gapped_alphabet.to_indices(seq), validate=False
+        )
 
     @is_degenerate.register
-    def _(self, seq: numpy.ndarray) -> bool:
+    def _(self, seq: numpy.ndarray, validate: bool = True) -> bool:
         # what index is the first degenerate character
+        if validate and not self.is_valid(seq):
+            raise new_alphabet.AlphabetError(
+                f"{seq[:4]!r} not valid for moltype {self.name!r}"
+            )
+
         for index, val in enumerate(self.degen_gapped_alphabet):
             if val in self.ambiguities:
                 break
@@ -514,31 +592,54 @@ class MolType:
         return (seq >= index).any()
 
     @functools.singledispatchmethod
-    def is_gapped(self, seq) -> bool:
+    def is_gapped(self, seq, validate: bool = True) -> bool:
         """checks if a sequence contains gaps"""
         raise TypeError(f"{type(seq)} not supported")
 
     @is_gapped.register
-    def _(self, seq: str) -> bool:
+    def _(self, seq: str, validate: bool = True) -> bool:
+        if validate and not self.is_valid(seq):
+            raise new_alphabet.AlphabetError(
+                f"{seq[:4]!r} not valid for moltype {self.name!r}"
+            )
         return any(gap in seq for gap in self.gaps)
 
     @is_gapped.register
-    def _(self, seq: bytes) -> bool:
-        return self.is_gapped(seq.decode("utf8"))
+    def _(self, seq: bytes, validate: bool = True) -> bool:
+        if validate and not self.is_valid(seq):
+            raise new_alphabet.AlphabetError(
+                f"{seq[:4]!r} not valid for moltype {self.name!r}"
+            )
+        return self.is_gapped(seq.decode("utf8"), validate=False)
 
     @is_gapped.register
-    def _(self, seq: bytes) -> bool:
-        return self.is_gapped(seq.decode("utf8"))
+    def _(self, seq: bytes, validate: bool = True) -> bool:
+        if validate and not self.is_valid(seq):
+            raise new_alphabet.AlphabetError(
+                f"{seq[:4]!r} not valid for moltype {self.name!r}"
+            )
+        return self.is_gapped(seq.decode("utf8"), validate=False)
 
     @is_gapped.register
-    def _(self, seq: numpy.ndarray) -> bool:
-        gaps_indices = [self.degen_gapped_alphabet.index(gap) for gap in self.gaps]
+    def _(self, seq: numpy.ndarray, validate: bool = True) -> bool:
+        if validate and not self.is_valid(seq):
+            raise new_alphabet.AlphabetError(
+                f"{seq[:4]!r} not valid for moltype {self.name!r}"
+            )
+        alpha = self.most_degen_alphabet()
+        gaps_indices = [alpha.index(gap) for gap in self.gaps]
         return numpy.isin(seq, gaps_indices).any()
 
     def get_degenerate_positions(
-        self, seq: StrORBytesORArray, include_gap: bool = True
-    ) -> list[int]:
-        seq = self.degen_gapped_alphabet.to_indices(seq)
+        self, seq: StrORBytesORArray, include_gap: bool = True, validate: bool = True
+    ) -> list[int]:  # refactor: docstring
+        if validate and not self.is_valid(seq):
+            raise new_alphabet.AlphabetError(
+                f"{seq[:4]!r} not valid for moltype {self.name!r}"
+            )
+
+        alpha = self.most_degen_alphabet()
+        seq = alpha.to_indices(seq)
         for index, val in enumerate(self.degen_gapped_alphabet):
             if include_gap and val in self.gap or val in self.ambiguities:
                 break
@@ -546,7 +647,7 @@ class MolType:
         return numpy.where(degens)[0].tolist()
 
     @functools.singledispatchmethod
-    def degap(self, seq) -> StrORBytesORArray:
+    def degap(self, seq, validate: bool = True) -> StrORBytesORArray:
         """removes all gap and missing characters from a sequence"""
         # refactor: design
         # cache translation callables (using alpabet.convert_alphabet)
@@ -554,20 +655,41 @@ class MolType:
         raise TypeError(f"{type(seq)} not supported")
 
     @degap.register
-    def _(self, seq: bytes) -> bytes:
+    def _(self, seq: bytes, validate: bool = True) -> bytes:
+        if validate and not self.is_valid(seq):
+            raise new_alphabet.AlphabetError(
+                f"{seq[:4]!r} not valid for moltype {self.name!r}"
+            )
+
+        if self.gap is None:
+            raise TypeError(f"no gap character defined for {self.name!r}")
+
         return seq.translate(None, delete="".join(self.gaps).encode("utf8"))
 
     @degap.register
-    def _(self, seq: str) -> str:
-        degapped = self.degap(seq.encode("utf8"))
+    def _(self, seq: str, validate: bool = True) -> str:
+        if validate and not self.is_valid(seq):
+            raise new_alphabet.AlphabetError(
+                f"{seq[:4]!r} not valid for moltype {self.name!r}"
+            )
+        degapped = self.degap(seq.encode("utf8"), validate=False)
         return degapped.decode("utf8")
 
     @degap.register
-    def _(self, seq: numpy.ndarray) -> numpy.ndarray:
-        degapped = self.degen_gapped_alphabet.array_to_bytes(seq)
-        return self.degen_gapped_alphabet.to_indices(self.degap(degapped))
+    def _(self, seq: numpy.ndarray, validate: bool = True) -> numpy.ndarray:
+        if validate and not self.is_valid(seq):
+            raise new_alphabet.AlphabetError(
+                f"{seq[:4]!r} not valid for moltype {self.name!r}"
+            )
+
+        degen = self.most_degen_alphabet()
+        if not degen.gap_char:
+            raise TypeError(f"no gap character defined for {self.name!r}")
 
-    def is_ambiguity(self, query_motif: str) -> bool:
+        degapped = degen.array_to_bytes(seq)
+        return degen.to_indices(self.degap(degapped, validate=False))
+
+    def is_ambiguity(self, query_motif: str, validate: bool = True) -> bool:
         """Return True if querymotif is an amibiguity character in alphabet.
 
         Parameters
@@ -576,6 +698,11 @@ class MolType:
             the motif being queried.
 
         """
+        if validate and not self.is_valid(query_motif):
+            raise new_alphabet.AlphabetError(
+                f"{query_motif[:4]!r} not valid for moltype {self.name!r}"
+            )
+
         ambigs_missing = {
             self.missing,
             *frozenset(self.ambiguities.keys()),
@@ -587,6 +714,7 @@ class MolType:
         ambig_motif: str,
         alphabet: typing.Optional[new_alphabet.CharAlphabet] = None,
         allow_gap: bool = False,
+        validate: bool = True,
     ) -> typing.Tuple[str]:
         """Returns tuple of all possible canonical characters corresponding
         to ambig_motif
@@ -608,6 +736,11 @@ class MolType:
         If ambig_motif is > 1 character long and alphabet is None, we construct
         a word alphabet with the same length.
         """
+        if validate and not self.is_valid(ambig_motif):
+            raise new_alphabet.AlphabetError(
+                f"{ambig_motif[:4]!r} not valid for moltype {self.name!r}"
+            )
+
         # refactor: simplify
         ambiguities = {
             **self.ambiguities,
@@ -644,7 +777,9 @@ class MolType:
 
         return result
 
-    def strand_symmetric_motifs(self, motif_length: int = 1):
+    def strand_symmetric_motifs(
+        self, motif_length: int = 1
+    ) -> set[tuple[str, str]]:  # refactor: docstring
         """returns ordered pairs of strand complementary motifs"""
         if len(self.alphabet) != 4:
             raise TypeError("moltype must be DNA or RNA")
@@ -689,6 +824,11 @@ class MolType:
 
         return css, styles
 
+    @functools.cache
+    def most_degen_alphabet(self):
+        """returns the most degenerate alphabet for this instance"""
+        return next(self.iter_alphabets())
+
 
 def _make_moltype_dict() -> dict[str, MolType]:
     """make a dictionary of local name space molecular types"""
@@ -736,10 +876,12 @@ def available_moltypes():
 # constant instances of the core molecular types
 ASCII = MolType(
     # A default type for text read from a file etc. when we don't
-    # want to prematurely assume DNA or Protein.
+    # want to prematurely assume DNA or Protein. We therefore need to include
+    # characters that could be present in any of those files.
     monomers="".join(ascii_letters),
     name="text",
     make_seq=new_sequence.Sequence,
+    missing=IUPAC_missing,
 )
 
 DNA = MolType(


Test patch:
diff --git a/tests/test_core/test_new_alphabet.py b/tests/test_core/test_new_alphabet.py
index 165980920..a7af187ae 100644
--- a/tests/test_core/test_new_alphabet.py
+++ b/tests/test_core/test_new_alphabet.py
@@ -172,6 +172,13 @@ def test_coord2index_fail():
         new_alphabet.coord_to_index(coord, coeffs)
 
 
+@pytest.mark.parametrize("gap", ("", "-"))
+def test_char_alphabet_with_gap(gap):
+    alpha = new_alphabet.CharAlphabet(list(f"TCAG{gap}"), gap=gap or None)
+    gapped = alpha.with_gap_motif()
+    assert "-" in gapped
+
+
 @pytest.mark.parametrize("k", (2, 3))
 def test_kmer_alphabet_construction(k):
     dna = new_moltype.get_moltype("dna")
diff --git a/tests/test_core/test_new_moltype.py b/tests/test_core/test_new_moltype.py
index 453b51a5f..c8306a2c2 100644
--- a/tests/test_core/test_new_moltype.py
+++ b/tests/test_core/test_new_moltype.py
@@ -72,7 +72,7 @@ def test_str_moltype():
 
 
 @pytest.mark.parametrize(
-    "seq", ("ACCCG", b"ACCCG", numpy.array([2, 1, 1, 1, 3], dtype=numpy.uint8))[-1:]
+    "seq", ("ACCCG", b"ACCCG", numpy.array([2, 1, 1, 1, 3], dtype=numpy.uint8))
 )
 @pytest.mark.parametrize("name", ("dna", "rna"))
 def test_complement(name, seq):
@@ -84,7 +84,7 @@ def test_complement(name, seq):
 
 def make_typed(seq, data_type, moltype):
     if data_type is numpy.ndarray:
-        seq = moltype.degen_gapped_alphabet.to_indices(seq)
+        seq = moltype.most_degen_alphabet().to_indices(seq)
     elif data_type is bytes:
         seq = seq.encode("utf-8")
     return seq
@@ -104,7 +104,9 @@ def make_typed(seq, data_type, moltype):
 )
 def test_is_degenerate(seq, data_type):
     seq = make_typed(seq, data_type, new_moltype.RNA)
-    assert new_moltype.RNA.is_degenerate(seq)
+    # note that the last sequence is NOT a valid RNA sequence, so
+    # we need to turn off validation
+    assert new_moltype.RNA.is_degenerate(seq, validate=False)
 
 
 @pytest.mark.parametrize("data_type", (str, bytes, numpy.ndarray))
@@ -238,6 +240,7 @@ def test_is_ambiguity_false(char):
 
 
 @pytest.mark.parametrize("data_type", (str, bytes, numpy.ndarray))
+@pytest.mark.parametrize("moltype", ("text", "rna"))
 @pytest.mark.parametrize(
     "seq, expect",
     (
@@ -251,12 +254,14 @@ def test_is_ambiguity_false(char):
         ("-g?a-u?g-", "gaug"),
     ),
 )
-def test_degap(seq, expect, data_type):
+def test_degap(seq, expect, data_type, moltype):
     """MolType degap should remove all gaps from sequence"""
-    degap = new_moltype.RNA.degap
-    seq = make_typed(seq, data_type, new_moltype.RNA)
-    expect = make_typed(expect, data_type, new_moltype.RNA)
-    got = degap(seq)
+    moltype = new_moltype.get_moltype(moltype)
+    seq = seq.upper() if moltype.name == "rna" else seq
+    expect = expect.upper() if moltype.name == "rna" else expect
+    seq = make_typed(seq, data_type, moltype)
+    expect = make_typed(expect, data_type, moltype)
+    got = moltype.degap(seq)
     assert (
         numpy.array_equal(got, expect) if data_type == numpy.ndarray else got == expect
     )
@@ -291,12 +296,17 @@ def test_strand_symmetric_motifs():
     ),
 )
 def test_gaps(moltype):
-    # TODO: fred, add new_moltype.BYTES
     got = moltype.gaps
     expect = frozenset({"-", "?"})
     assert got == expect
 
 
+def test_gaps_bytes():
+    got = new_moltype.get_moltype("bytes").gaps
+    expect = frozenset()
+    assert got == expect
+
+
 def test_gaps_none():
     mt = new_moltype.MolType(
         "no_gap",
@@ -318,3 +328,42 @@ def test_gaps_none():
     got = mt.gaps
     expect = frozenset({"-"})
     assert got == expect
+
+
+@pytest.mark.parametrize(
+    "label",
+    (
+        "bytes",
+        "dna",
+        "rna",
+        "protein",
+        "protein_with_stop",
+        "text",
+    ),
+)
+def test_most_degenerate_alphabet(label):
+    moltype = new_moltype.get_moltype(label)
+    got = moltype.most_degen_alphabet()
+    # expected value for number of characters is
+    # length of monomers + len(moltype gaps) + len(ambiguities)
+    num_ambigs = len(moltype.ambiguities or [])
+    expected = len(moltype.alphabet) + len(moltype.gaps or []) + num_ambigs
+    assert len(got) == expected
+
+
+def test_validate_seq():
+    moltype = new_moltype.ASCII
+    alpha = moltype.most_degen_alphabet()
+    assert alpha.is_valid("?gau")
+
+
+def test_degap_raises():
+    with pytest.raises(new_alphabet.AlphabetError):
+        new_moltype.RNA.degap("-g?a-u?g-", validate=True)
+
+
+def test_is_invalid_rna():
+    rna = new_moltype.RNA
+    seq = "ACGYAUGCUGYEWEWNFMNFUWBYBCWUYBCJWBEIWFUB"
+    ambigs = set(seq) - set("ACGU")
+    assert not rna.is_valid(seq)